@mrrlin-dev/mcp 0.3.64 → 0.3.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bin.cjs +1051 -605
  2. package/package.json +5 -5
package/dist/bin.cjs CHANGED
@@ -20658,14 +20658,17 @@ var toml = __toESM(require_toml(), 1);
20658
20658
 
20659
20659
  // src/api-base-url.ts
20660
20660
  var DEFAULT_API_BASE_URL = "http://127.0.0.1:8787";
20661
- var STAGING_API_BASE_URL = "https://mrrlin-api-mrrlin3.vercel.app";
20661
+ var STAGING_API_BASE_URL = "https://api-staging.mrrlin.com";
20662
20662
  function normalizeUrl(value) {
20663
20663
  return value.replace(/\/$/, "");
20664
20664
  }
20665
20665
  var RETIRED_API_BASE_URLS = {
20666
20666
  // Retired 2026-07-28 when staging moved to the mrrlin2 Vercel team; serves a decommissioned
20667
20667
  // Supabase project and now answers `GET /health` with 402 DEPLOYMENT_DISABLED. Added in 0.3.50.
20668
- "mrrlin-api-fnnzzz-fnnzzzs-projects.vercel.app": STAGING_API_BASE_URL
20668
+ "mrrlin-api-fnnzzz-fnnzzzs-projects.vercel.app": STAGING_API_BASE_URL,
20669
+ // Retired 2026-08-14 when staging decoupled onto the stable custom domain api-staging.mrrlin.com.
20670
+ // A persisted operator configuration may still pin the mrrlin3 Vercel alias; retarget it in one hop.
20671
+ "mrrlin-api-mrrlin3.vercel.app": STAGING_API_BASE_URL
20669
20672
  };
20670
20673
  function isSameApiHost(a, b) {
20671
20674
  const origin = (value) => {
@@ -36387,6 +36390,9 @@ var openApiOperationIds = {
36387
36390
  getExecutionRun: "getExecutionRun",
36388
36391
  getProjectSchedulerPolicy: "getProjectSchedulerPolicy",
36389
36392
  getProjectDeployWorkflow: "getProjectDeployWorkflow",
36393
+ getDeployTopology: "getDeployTopology",
36394
+ writeDeployTopology: "writeDeployTopology",
36395
+ getTaskDeployTarget: "getTaskDeployTarget",
36390
36396
  getSchedulerPendingTasks: "getSchedulerPendingTasks",
36391
36397
  wakeDirector: "wakeDirector",
36392
36398
  getGithubInstallationRepositories: "getGithubInstallationRepositories",
@@ -36742,6 +36748,16 @@ var mrrlinTaskEventAutonomyLevelSchema = external_exports.enum([
36742
36748
  ]);
36743
36749
  var mrrlinAutoDeploySchema = external_exports.enum(["off", "dev", "prod"]);
36744
36750
  var mrrlinTaskDomainSchema = external_exports.enum(["code", "seo", "marketing", "content"]);
36751
+ function normalizeTaskDomain(value) {
36752
+ if (typeof value !== "string")
36753
+ return "code";
36754
+ const v = value.trim().toLowerCase();
36755
+ return mrrlinTaskDomainSchema.options.includes(v) ? v : "code";
36756
+ }
36757
+ function domainScopedReviewHash(rawHash, domain2) {
36758
+ const d = normalizeTaskDomain(domain2);
36759
+ return d === "code" ? rawHash : `${rawHash}@${d}`;
36760
+ }
36745
36761
  var mrrlinSpecApprovedSchema = external_exports.enum(["pending", "approved", "skipped", "skipped_legacy"]);
36746
36762
  var mrrlinTaskSourceSchema = external_exports.enum(["fixture-import", "api"]);
36747
36763
  var mrrlinPlanStatusSchema = external_exports.enum(["active", "paused", "done", "archived"]);
@@ -37123,6 +37139,47 @@ function refineTaskCreateDomain(data, ctx) {
37123
37139
  }
37124
37140
  var mrrlinTaskCreateSchema = mrrlinTaskCreateShapeSchema.superRefine(refineTaskCreateDomain);
37125
37141
  var mrrlinTaskCreateWithoutIdSchema = mrrlinTaskCreateShapeSchema.omit({ id: true }).superRefine(refineTaskCreateDomain);
37142
+ var mrrlinDeployWorkflowEnvSchema = external_exports.enum(["dev", "prod"]);
37143
+ var mrrlinTaskDeployTargetInputSchema = external_exports.object({
37144
+ env: mrrlinDeployWorkflowEnvSchema,
37145
+ targetLabel: external_exports.string().min(1),
37146
+ workflowId: external_exports.number().int().positive(),
37147
+ workflowPath: external_exports.string().min(1),
37148
+ workflowSha: external_exports.string().min(1),
37149
+ topologyFingerprint: external_exports.string().min(1)
37150
+ });
37151
+ var deployTopologyResponseSchema = external_exports.object({
37152
+ data: external_exports.object({
37153
+ fingerprint: external_exports.string().nullable(),
37154
+ snapshot: external_exports.array(external_exports.object({ id: external_exports.number(), path: external_exports.string(), sha: external_exports.string(), state: external_exports.string() })).nullable(),
37155
+ entries: external_exports.array(external_exports.object({
37156
+ label: external_exports.string(),
37157
+ env: mrrlinDeployWorkflowEnvSchema,
37158
+ workflowId: external_exports.number(),
37159
+ workflowPath: external_exports.string(),
37160
+ workflowSha: external_exports.string()
37161
+ }))
37162
+ })
37163
+ });
37164
+ var mrrlinDeployTopologyWriteSchema = external_exports.object({
37165
+ fingerprint: external_exports.string().min(1),
37166
+ entries: external_exports.array(external_exports.object({
37167
+ label: external_exports.string().min(1),
37168
+ env: mrrlinDeployWorkflowEnvSchema,
37169
+ workflowId: external_exports.number().int().positive(),
37170
+ workflowPath: external_exports.string().min(1),
37171
+ workflowSha: external_exports.string().min(1)
37172
+ }))
37173
+ });
37174
+ var taskDeployTargetResponseSchema = external_exports.object({
37175
+ data: external_exports.object({
37176
+ targetLabel: external_exports.string(),
37177
+ workflowId: external_exports.number(),
37178
+ workflowPath: external_exports.string(),
37179
+ workflowSha: external_exports.string(),
37180
+ topologyFingerprint: external_exports.string()
37181
+ })
37182
+ });
37126
37183
  var mrrlinTaskUpdateSchema = external_exports.object({
37127
37184
  actor: external_exports.string().min(1).optional(),
37128
37185
  assignee: mrrlinTaskAssigneeSchema.optional(),
@@ -37145,8 +37202,13 @@ var mrrlinTaskUpdateSchema = external_exports.object({
37145
37202
  title: external_exports.string().min(1).optional(),
37146
37203
  type: mrrlinTaskTypeSchema.optional(),
37147
37204
  followUpAt: external_exports.string().datetime().nullable().optional(),
37148
- verificationDueAt: external_exports.string().datetime().nullable().optional()
37149
- }).refine((value) => value.assignee !== void 0 || value.autoDeploy !== void 0 || value.domain !== void 0 || value.autonomyLevel !== void 0 || value.autonomyReason !== void 0 || value.category !== void 0 || value.directorThreadId !== void 0 || value.dueDate !== void 0 || value.followUpAt !== void 0 || value.impact !== void 0 || value.notes !== void 0 || value.planId !== void 0 || value.specApproved !== void 0 || value.specWikiPageId !== void 0 || value.status !== void 0 || value.subcategory !== void 0 || value.tags !== void 0 || value.title !== void 0 || value.type !== void 0 || value.verificationDueAt !== void 0, { message: "TaskUpdate requires at least one field" }).superRefine((value, context) => {
37205
+ verificationDueAt: external_exports.string().datetime().nullable().optional(),
37206
+ deployTargets: external_exports.array(mrrlinTaskDeployTargetInputSchema).optional(),
37207
+ // design.md D5 step 6 hint (task 3.16): the deploy gate's own bookkeeping write when the
37208
+ // fingerprint moved but the topology still maps the label to the same workflow. Never sent
37209
+ // by any other caller.
37210
+ reverifyRequested: external_exports.boolean().optional()
37211
+ }).refine((value) => value.assignee !== void 0 || value.autoDeploy !== void 0 || value.domain !== void 0 || value.autonomyLevel !== void 0 || value.autonomyReason !== void 0 || value.category !== void 0 || value.directorThreadId !== void 0 || value.dueDate !== void 0 || value.followUpAt !== void 0 || value.impact !== void 0 || value.notes !== void 0 || value.planId !== void 0 || value.specApproved !== void 0 || value.specWikiPageId !== void 0 || value.status !== void 0 || value.subcategory !== void 0 || value.tags !== void 0 || value.title !== void 0 || value.type !== void 0 || value.verificationDueAt !== void 0 || value.deployTargets !== void 0 || value.reverifyRequested !== void 0, { message: "TaskUpdate requires at least one field" }).superRefine((value, context) => {
37150
37212
  if (value.autonomyLevel !== void 0 && value.autonomyReason === void 0) {
37151
37213
  context.addIssue({
37152
37214
  code: external_exports.ZodIssueCode.custom,
@@ -37230,6 +37292,7 @@ var mrrlinWikiPageUpdateSchema = external_exports.object({
37230
37292
  }).refine((value) => Object.keys(value).length > 0, { message: "WikiPageUpdate requires at least one field" });
37231
37293
  var mrrlinWikiPageListFiltersSchema = external_exports.object({
37232
37294
  folder: external_exports.string().min(1).optional(),
37295
+ id: mrrlinWikiPageIdSchema.optional(),
37233
37296
  q: external_exports.string().min(1).optional(),
37234
37297
  taskId: mrrlinTaskIdSchema.optional()
37235
37298
  });
@@ -37806,7 +37869,6 @@ var mrrlinProjectSchedulerPolicyUpsertSchema = external_exports.object({
37806
37869
  var projectSchedulerPolicyResponseSchema = external_exports.object({
37807
37870
  data: mrrlinProjectSchedulerPolicySchema
37808
37871
  });
37809
- var mrrlinDeployWorkflowEnvSchema = external_exports.enum(["dev", "prod"]);
37810
37872
  var mrrlinDeployWorkflowSourceSchema = external_exports.enum(["agent", "operator"]);
37811
37873
  var mrrlinDeployWorkflowStateSchema = external_exports.enum(["resolved", "ambiguous"]);
37812
37874
  var mrrlinProjectDeployWorkflowSchema = external_exports.object({
@@ -39291,6 +39353,29 @@ function createMrrlinClient(config2) {
39291
39353
  throw error51;
39292
39354
  }
39293
39355
  },
39356
+ async getDeployTopology(projectSlug) {
39357
+ const body = await request(`${projectPath(projectSlug)}/deploy-topology`);
39358
+ return deployTopologyResponseSchema.parse(body).data;
39359
+ },
39360
+ async writeDeployTopology(projectSlug, input) {
39361
+ const body = await request(`${projectPath(projectSlug)}/deploy-topology`, {
39362
+ body: JSON.stringify(mrrlinDeployTopologyWriteSchema.parse(input)),
39363
+ headers: { "content-type": "application/json" },
39364
+ method: "PUT"
39365
+ });
39366
+ return deployTopologyResponseSchema.parse(body).data;
39367
+ },
39368
+ async getTaskDeployTarget(projectSlug, taskId, env) {
39369
+ try {
39370
+ const body = await request(`${projectPath(projectSlug)}/tasks/${taskId}/deploy-target/${env}`);
39371
+ return taskDeployTargetResponseSchema.parse(body).data;
39372
+ } catch (error51) {
39373
+ if (error51 instanceof MrrlinApiError && error51.status === 404 && error51.code === "DEPLOY_TARGET_UNSET") {
39374
+ return null;
39375
+ }
39376
+ throw error51;
39377
+ }
39378
+ },
39294
39379
  async upsertProjectDeployWorkflow(projectSlug, env, input) {
39295
39380
  const body = await request(`${projectPath(projectSlug)}/deploy-workflows/${env}`, {
39296
39381
  body: JSON.stringify(input),
@@ -39965,7 +40050,7 @@ function startCodexProbe(deps = {}) {
39965
40050
  }
39966
40051
 
39967
40052
  // src/_generated/version.ts
39968
- var PKG_VERSION = "0.3.64";
40053
+ var PKG_VERSION = "0.3.68";
39969
40054
 
39970
40055
  // src/api-base-url-migrate.ts
39971
40056
  var import_node_fs2 = __toESM(require("node:fs"), 1);
@@ -40936,41 +41021,303 @@ function errMessage(err) {
40936
41021
  return err instanceof Error ? err.message : String(err);
40937
41022
  }
40938
41023
 
40939
- // src/consensus/resolve-gated-task.ts
40940
- async function resolveGatedTaskId(client, projectSlug, page) {
40941
- const tasks = await client.listTasks(projectSlug);
40942
- const linking = tasks.find((t) => t.specWikiPageId === page.id);
40943
- if (linking) {
40944
- if (page.taskId !== linking.id) {
40945
- throw new Error(
40946
- `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`
40947
- );
41024
+ // src/consensus/verdict.ts
41025
+ var CATEGORIES = [
41026
+ "correctness",
41027
+ "security",
41028
+ "scope",
41029
+ "ambiguity",
41030
+ "performance",
41031
+ "ops"
41032
+ ];
41033
+ var CAT_SET = new Set(CATEGORIES);
41034
+ function parseVerdict(raw) {
41035
+ const verdictMatch = raw.match(/\*\*Verdict\*\*:\s*(APPROVE|REQUEST_CHANGES|REJECT)\b/i);
41036
+ const parsedVerdict = verdictMatch?.[1]?.toUpperCase();
41037
+ const verdict = parsedVerdict === "APPROVE" || parsedVerdict === "REQUEST_CHANGES" || parsedVerdict === "REJECT" ? parsedVerdict : "REQUEST_CHANGES";
41038
+ const afterCritical = raw.split(/\*\*Critical issues\*\*/i)[1];
41039
+ const block = afterCritical?.split(/\n\*\*/)[0] ?? "";
41040
+ const issues = [];
41041
+ const parseFallbacks = [];
41042
+ for (const line of block.split(/\r?\n/)) {
41043
+ const bulletMatch = line.match(/^\s*-\s+(.*\S)\s*$/);
41044
+ if (!bulletMatch) continue;
41045
+ const body = bulletMatch[1] ?? "";
41046
+ if (/^\(?(empty list|none)\b/i.test(body)) continue;
41047
+ if (!body) continue;
41048
+ const catMatch = body.match(/`?\[(\w+)\]`?/);
41049
+ const catToken = catMatch?.[1]?.toLowerCase() ?? "";
41050
+ const isKnownCat = catToken !== "" && CAT_SET.has(catToken);
41051
+ const category = isKnownCat ? catToken : "ambiguity";
41052
+ if (!catMatch || !isKnownCat) {
41053
+ parseFallbacks.push(body);
40948
41054
  }
40949
- return linking.id;
41055
+ const text = body.replace(/`?\[\w+\]`?\s*/, "").trim();
41056
+ issues.push({ category, text });
40950
41057
  }
40951
- return page.taskId;
41058
+ return { verdict, issues, parseFallbacks };
41059
+ }
41060
+ function parseArbiterDecisions(raw) {
41061
+ const decisions = [];
41062
+ const afterDecisions = raw.split(/\*\*Decisions\*\*/i)[1];
41063
+ const block = afterDecisions?.split(/\n\*\*Adjudicated/i)[0] ?? "";
41064
+ for (const line of block.split(/\r?\n/)) {
41065
+ const m = line.match(
41066
+ /^\s*-\s+`?\[([^\]]+)\]`?\s+(accept|dismiss|defer)\b(?:\s*[—-]\s*(.*\S))?\s*$/i
41067
+ );
41068
+ if (!m) continue;
41069
+ const id = m[1] ?? "";
41070
+ const decision = (m[2] ?? "accept").toLowerCase();
41071
+ const reason = (m[3] ?? "").trim();
41072
+ decisions.push({ id, decision, reason });
41073
+ }
41074
+ return { decisions };
40952
41075
  }
40953
41076
 
40954
- // src/consensus/review-identity.ts
40955
- function domainScopedReviewHash(rawHash, domain2) {
40956
- const d = normalizeDomain(domain2);
40957
- return d === "code" ? rawHash : `${rawHash}@${d}`;
41077
+ // src/consensus/arbiter.ts
41078
+ function buildArbiterPrompt(issues) {
41079
+ const list = issues.map((i) => `- [${i.id}] (${i.category}) ${i.text}`).join("\n");
41080
+ return `Adjudicate each issue below.
41081
+
41082
+ ISSUES:
41083
+ ${list}`;
40958
41084
  }
40959
- async function resolveSpecDomain(client, projectSlug, page) {
40960
- try {
40961
- const taskId = await resolveGatedTaskId(client, projectSlug, page);
40962
- if (taskId !== null) {
40963
- const task = await client.getTask(projectSlug, taskId);
40964
- return normalizeDomain(task?.domain);
41085
+ async function adjudicate(issues, deps) {
41086
+ if (issues.length === 0) return { adjudicatedVerdict: "APPROVE", accepted: [], dismissed: [] };
41087
+ const byId = new Map(issues.map((i) => [i.id, i]));
41088
+ for (let attempt = 0; attempt < 2; attempt++) {
41089
+ const res = await deps.runArbiter(buildArbiterPrompt(issues));
41090
+ if (!res.ok) continue;
41091
+ const { decisions } = parseArbiterDecisions(res.text);
41092
+ const seen = new Set(decisions.map((d) => d.id));
41093
+ const everyDecided = issues.every((i) => seen.has(i.id));
41094
+ const missingReason = decisions.some((d) => d.decision !== "accept" && !d.reason);
41095
+ if (!everyDecided || missingReason && attempt === 0) continue;
41096
+ const accepted = [];
41097
+ const dismissed = [];
41098
+ for (const i of issues) {
41099
+ const d = decisions.find((x) => x.id === i.id);
41100
+ if (!d || d.decision !== "accept" && !d.reason) {
41101
+ accepted.push(i);
41102
+ continue;
41103
+ }
41104
+ if (d.decision === "accept") accepted.push(i);
41105
+ else dismissed.push({ id: i.id, decision: d.decision, reason: d.reason });
40965
41106
  }
40966
- const plans = await client.listPlans(projectSlug);
40967
- const plan = plans.find((p) => p.specWikiPageId === page.id);
40968
- if (plan?.domain != null) return normalizeDomain(plan.domain);
40969
- const project = await client.getProject(projectSlug);
40970
- return normalizeDomain(project?.defaultDomain);
40971
- } catch {
40972
- return "code";
41107
+ return { adjudicatedVerdict: accepted.length === 0 ? "APPROVE" : "REQUEST_CHANGES", accepted, dismissed };
40973
41108
  }
41109
+ return { adjudicatedVerdict: "REQUEST_CHANGES", accepted: [...byId.values()], dismissed: [] };
41110
+ }
41111
+
41112
+ // src/consensus/semaphore.ts
41113
+ function createBoundedSemaphore(slots) {
41114
+ let available = slots;
41115
+ const queue = [];
41116
+ return {
41117
+ acquire: () => available > 0 ? (available -= 1, Promise.resolve()) : new Promise((resolve) => queue.push(resolve)),
41118
+ release: () => {
41119
+ const next = queue.shift();
41120
+ if (next) next();
41121
+ else available += 1;
41122
+ }
41123
+ };
41124
+ }
41125
+
41126
+ // src/consensus/escalation.ts
41127
+ function diagnoseStall(history, configuredRounds) {
41128
+ const n = history.length;
41129
+ const nonRespPerRound = history.map((h) => h.headOutcomes.filter((o) => !o.responded).length);
41130
+ const infraRoundCount = nonRespPerRound.filter((c) => c >= 2).length;
41131
+ const infraQualifies = infraRoundCount > n / 2;
41132
+ if (infraQualifies) {
41133
+ const roundIndices = [];
41134
+ nonRespPerRound.forEach((c, i) => {
41135
+ if (c >= 2) roundIndices.push(i + 1);
41136
+ });
41137
+ const evidence2 = `INFRA basis: \u22652 non\u2011responding heads in ${infraRoundCount} of ${n} rounds (rounds ${roundIndices.join(",")})`;
41138
+ let grant = 3;
41139
+ const headroom = 2 * configuredRounds - n;
41140
+ grant = Math.max(1, Math.min(grant, headroom));
41141
+ return { diagnosis: "INFRA", grantedRounds: grant, evidence: evidence2 };
41142
+ }
41143
+ if (n >= 2) {
41144
+ const counts = history.map((h) => h.accepted.length);
41145
+ const last = counts[n - 1];
41146
+ const secondLast = counts[n - 2];
41147
+ const lastFall = last < secondLast;
41148
+ if (lastFall) {
41149
+ let falls = 1;
41150
+ if (n >= 3 && secondLast < counts[n - 3]) {
41151
+ falls++;
41152
+ }
41153
+ let grant = 2 + falls;
41154
+ if (grant > 5) grant = 5;
41155
+ const headroom = 2 * configuredRounds - n;
41156
+ grant = Math.max(1, Math.min(grant, headroom));
41157
+ const seqDesc = counts.map((c, i) => `round${i + 1}:${c}`).join(" \u2192 ");
41158
+ const evidence2 = `SUBSTANCE basis: accepted count fell (${seqDesc})`;
41159
+ return { diagnosis: "SUBSTANCE", grantedRounds: grant, evidence: evidence2 };
41160
+ }
41161
+ }
41162
+ const evidence = n === 0 ? "empty history" : `no infra or substance basis found in ${n} rounds`;
41163
+ return { diagnosis: "AMBIGUITY", grantedRounds: 0, evidence };
41164
+ }
41165
+
41166
+ // src/consensus/loop.ts
41167
+ async function runConsensus(input) {
41168
+ const sem = createBoundedSemaphore(input.concurrency ?? input.heads.length);
41169
+ const history = [];
41170
+ let artifact = input.artifact;
41171
+ let calls = 0;
41172
+ const dismissedFwd = [];
41173
+ let effectiveRounds = input.rounds;
41174
+ let escalated = false;
41175
+ let decision;
41176
+ for (let round = 1; round <= effectiveRounds; round++) {
41177
+ if (calls + input.heads.length > input.maxHeadCalls) {
41178
+ return { outcome: "CONSENSUS_BLOCKED", rounds: round - 1, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41179
+ }
41180
+ const dismissedBlock = dismissedFwd.length ? `PREVIOUSLY DISMISSED (do not re-raise unless new information):
41181
+ ${dismissedFwd.slice(-20).join("\n")}` : "";
41182
+ const results = await Promise.all(
41183
+ input.heads.map(async (persona, idx) => {
41184
+ await sem.acquire();
41185
+ try {
41186
+ calls++;
41187
+ const res = await input.runHead(persona, artifact, dismissedBlock);
41188
+ return { idx, res };
41189
+ } catch (e) {
41190
+ return { idx, res: { ok: false, error: String(e) } };
41191
+ } finally {
41192
+ sem.release();
41193
+ }
41194
+ })
41195
+ );
41196
+ const responding = results.filter(
41197
+ (r) => r.res.ok
41198
+ );
41199
+ if (responding.length === 0) {
41200
+ return { outcome: "CONSENSUS_UNAVAILABLE", rounds: round, finalArtifact: artifact, history };
41201
+ }
41202
+ const headOutcomes = input.heads.map((persona) => ({
41203
+ persona,
41204
+ responded: false
41205
+ }));
41206
+ const issues = [];
41207
+ for (const { idx, res } of responding) {
41208
+ const parsed = parseVerdict(res.text);
41209
+ parsed.issues.forEach((iss, n) => issues.push({ ...iss, id: `r${round}-h${idx}-${n}` }));
41210
+ const outcome = headOutcomes[idx];
41211
+ if (outcome) {
41212
+ outcome.responded = true;
41213
+ outcome.verdict = parsed.verdict;
41214
+ }
41215
+ if (parsed.verdict !== "APPROVE" && parsed.issues.length === 0 && parsed.parseFallbacks.length === 0) {
41216
+ issues.push({
41217
+ category: "ambiguity",
41218
+ text: `head ${input.heads[idx] ?? idx}/${idx} returned ${parsed.verdict} but no parseable critical issues (possible malformed/blocked review)`,
41219
+ id: `r${round}-h${idx}-0`
41220
+ });
41221
+ }
41222
+ }
41223
+ let adj;
41224
+ if (issues.length > 0) {
41225
+ if (calls + 1 > input.maxHeadCalls) {
41226
+ return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41227
+ }
41228
+ calls++;
41229
+ try {
41230
+ adj = await adjudicate(issues, { runArbiter: input.runArbiter });
41231
+ } catch {
41232
+ adj = { adjudicatedVerdict: "REQUEST_CHANGES", accepted: issues, dismissed: [] };
41233
+ }
41234
+ } else {
41235
+ adj = { adjudicatedVerdict: "APPROVE", accepted: [], dismissed: [] };
41236
+ }
41237
+ history.push({ round, accepted: adj.accepted, dismissed: adj.dismissed, headOutcomes });
41238
+ adj.dismissed.forEach((d) => dismissedFwd.push(`- ${d.id}: ${d.reason}`));
41239
+ if (adj.accepted.length === 0) {
41240
+ return { outcome: "CONVERGED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41241
+ }
41242
+ if (round === effectiveRounds) {
41243
+ if (!decision) {
41244
+ decision = diagnoseStall(history, input.rounds);
41245
+ }
41246
+ if (decision.grantedRounds > 0 && !escalated) {
41247
+ escalated = true;
41248
+ effectiveRounds = Math.min(effectiveRounds + decision.grantedRounds, input.rounds * 2);
41249
+ } else {
41250
+ return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41251
+ }
41252
+ }
41253
+ artifact = await input.revise(artifact, adj.accepted);
41254
+ }
41255
+ return { outcome: "CONSENSUS_BLOCKED", rounds: effectiveRounds, finalArtifact: artifact, history };
41256
+ }
41257
+
41258
+ // src/consensus/spec-identity.ts
41259
+ function validateSpecIdentity(markdown, taskId) {
41260
+ const target = taskId.toUpperCase();
41261
+ const fieldToken = frontmatterTaskToken(markdown);
41262
+ if (fieldToken !== null && fieldToken.toUpperCase() !== target) return false;
41263
+ const headingToken = firstHeadingToken(markdown);
41264
+ if (headingToken !== null && headingToken.toUpperCase() !== target) return false;
41265
+ return true;
41266
+ }
41267
+ var TASK_ID_TOKEN = /\b[A-Za-z]{1,12}-\d{1,10}\b/;
41268
+ var ATX_HEADING = /^ {0,3}#{1,6}\s+(.*)$/;
41269
+ var FRONTMATTER_TASK = /^task(?:_id)?:\s*(.+)$/i;
41270
+ function frontmatterTaskToken(markdown) {
41271
+ const lines2 = markdown.split("\n");
41272
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
41273
+ if (firstContent === -1 || lines2[firstContent]?.trim() !== "---") return null;
41274
+ for (let i = firstContent + 1; i < lines2.length; i++) {
41275
+ const trimmed = (lines2[i] ?? "").trim();
41276
+ if (trimmed === "---") break;
41277
+ const m = FRONTMATTER_TASK.exec(trimmed);
41278
+ if (m) return firstTaskIdToken(m[1] ?? "");
41279
+ }
41280
+ return null;
41281
+ }
41282
+ function firstHeadingToken(markdown) {
41283
+ const heading = firstHeading(markdown);
41284
+ return heading === null ? null : firstTaskIdToken(heading);
41285
+ }
41286
+ function firstHeading(markdown) {
41287
+ const lines2 = markdown.split("\n");
41288
+ let i = 0;
41289
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
41290
+ if (firstContent !== -1 && lines2[firstContent]?.trim() === "---") {
41291
+ i = firstContent + 1;
41292
+ while (i < lines2.length && lines2[i]?.trim() !== "---") i++;
41293
+ i++;
41294
+ }
41295
+ let inFence = false;
41296
+ let inComment = false;
41297
+ for (; i < lines2.length; i++) {
41298
+ const line = lines2[i] ?? "";
41299
+ const trimmed = line.trim();
41300
+ if (inComment) {
41301
+ if (trimmed.includes("-->")) inComment = false;
41302
+ continue;
41303
+ }
41304
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
41305
+ inFence = !inFence;
41306
+ continue;
41307
+ }
41308
+ if (inFence) continue;
41309
+ if (trimmed.startsWith("<!--")) {
41310
+ if (!trimmed.includes("-->")) inComment = true;
41311
+ continue;
41312
+ }
41313
+ const m = ATX_HEADING.exec(line);
41314
+ if (m) return m[1] ?? "";
41315
+ }
41316
+ return null;
41317
+ }
41318
+ function firstTaskIdToken(text) {
41319
+ const m = TASK_ID_TOKEN.exec(text);
41320
+ return m ? m[0] : null;
40974
41321
  }
40975
41322
 
40976
41323
  // src/consensus/acceptance-criteria.ts
@@ -41003,6 +41350,249 @@ function parseAcceptanceCriteria(markdown) {
41003
41350
  return items.length > 0 ? { items } : null;
41004
41351
  }
41005
41352
 
41353
+ // src/consensus/spec-gate.ts
41354
+ var SEED_ABSENCE_QUESTION = "What are the requirements for this task? There is no operator request on record, so the spec cannot be validated against your intent \u2014 please state what this task must do.";
41355
+ var SEED_ABSENCE_MARKER = "There is no operator request on record";
41356
+ function mapAcceptedIssueToClarification(issue2) {
41357
+ const text = issue2.text.toLowerCase();
41358
+ let kind;
41359
+ if (issue2.category === "scope") {
41360
+ const directionCues = ["only", "or also", "narrower", "broader", "include", "exclude", "versus", "vs"];
41361
+ kind = directionCues.some((cue) => text.includes(cue)) ? "scope_direction" : "missing_requirement";
41362
+ } else {
41363
+ const acceptanceCues = ["acceptance", "done", "success", "criteria", "satisfy"];
41364
+ kind = acceptanceCues.some((cue) => text.includes(cue)) ? "acceptance_criteria_detail" : "default_choice";
41365
+ }
41366
+ return {
41367
+ kind,
41368
+ diagnostic: false,
41369
+ rawQuestion: issue2.text,
41370
+ whyBlocking: `reviewer raised a ${issue2.category} issue`,
41371
+ category: issue2.category
41372
+ };
41373
+ }
41374
+ var CLARIFICATION_CAP_BY_IMPACT = { low: 2, medium: 3, high: 5 };
41375
+ function resolveClarificationCap(impact) {
41376
+ const tier = impact?.trim().toLowerCase();
41377
+ if (tier === "medium") return CLARIFICATION_CAP_BY_IMPACT.medium;
41378
+ if (tier === "high") return CLARIFICATION_CAP_BY_IMPACT.high;
41379
+ return CLARIFICATION_CAP_BY_IMPACT.low;
41380
+ }
41381
+ function headIndexOf(id) {
41382
+ const match = /^r\d+-h(\d+)-\d+$/.exec(id);
41383
+ return match?.[1] !== void 0 ? Number(match[1]) : null;
41384
+ }
41385
+ function orderClarificationIssues(issues) {
41386
+ const distinctHeadsByKey = /* @__PURE__ */ new Map();
41387
+ for (const issue2 of issues) {
41388
+ const key = `${issue2.category} ${issue2.text}`;
41389
+ const heads = distinctHeadsByKey.get(key) ?? /* @__PURE__ */ new Set();
41390
+ const headIdx = headIndexOf(issue2.id);
41391
+ if (headIdx !== null) heads.add(headIdx);
41392
+ distinctHeadsByKey.set(key, heads);
41393
+ }
41394
+ const categoryRank = (category) => category === "scope" ? 0 : category === "ambiguity" ? 1 : 2;
41395
+ return issues.map((issue2, index) => ({ issue: issue2, index })).sort((a, b) => {
41396
+ const rankDiff = categoryRank(a.issue.category) - categoryRank(b.issue.category);
41397
+ if (rankDiff !== 0) return rankDiff;
41398
+ const headCountA = distinctHeadsByKey.get(`${a.issue.category} ${a.issue.text}`)?.size ?? 0;
41399
+ const headCountB = distinctHeadsByKey.get(`${b.issue.category} ${b.issue.text}`)?.size ?? 0;
41400
+ if (headCountA !== headCountB) return headCountB - headCountA;
41401
+ return a.index - b.index;
41402
+ }).map(({ issue: issue2 }) => issue2);
41403
+ }
41404
+ function applyClarificationCap(issues, impact) {
41405
+ const ordered = orderClarificationIssues(issues);
41406
+ const cap = resolveClarificationCap(impact);
41407
+ return {
41408
+ kept: ordered.slice(0, cap).map(mapAcceptedIssueToClarification),
41409
+ dropped: ordered.slice(cap).map(mapAcceptedIssueToClarification)
41410
+ };
41411
+ }
41412
+ async function runSpecGate(_target, deps, opts) {
41413
+ if (!await deps.consensusEnabled()) return { outcome: "SKIPPED" };
41414
+ const intent = await deps.readIntent();
41415
+ if (intent.taskId !== null && (intent.seed === null || intent.seed.trim() === "")) {
41416
+ return {
41417
+ outcome: "CONSENSUS_BLOCKED",
41418
+ operatorClarifications: [
41419
+ {
41420
+ kind: "missing_requirement",
41421
+ diagnostic: false,
41422
+ // Filed byte-for-byte from the shared constant so `loadIntent` can later recognise its
41423
+ // answered form and lift the answer into the seed (GT-1005). Extracting the constant must
41424
+ // not change the filed text; the wiring.test.ts marker-substring assertion locks that.
41425
+ rawQuestion: SEED_ABSENCE_QUESTION,
41426
+ whyBlocking: "operator_seed_request is empty",
41427
+ category: "scope"
41428
+ }
41429
+ ]
41430
+ };
41431
+ }
41432
+ const baseHeads = resolveHeads(intent.domain, intent.impact);
41433
+ const extraHeads = opts?.extraHeads ?? [];
41434
+ const heads = [...baseHeads, ...extraHeads.filter((h) => !baseHeads.includes(h))];
41435
+ const spec = await deps.readSpec();
41436
+ let currentHash = spec.contentHash;
41437
+ let currentMarkdown = spec.markdownBody;
41438
+ let conflicted = false;
41439
+ if (parseAcceptanceCriteria(currentMarkdown) === null) {
41440
+ const bootstrapIssue = {
41441
+ id: "acceptance-criteria-bootstrap",
41442
+ category: "scope",
41443
+ text: "This spec has no `## Acceptance criteria` section. Add one, written as a markdown checklist (`- [ ] \u2026` items), derived from this spec's OWN requirements and the operator's stated intent. Each item must be concretely verifiable \u2014 a check the finished deliverable either passes or fails. Change NOTHING ELSE about the spec."
41444
+ };
41445
+ const revised = await deps.runReviser(currentMarkdown, [bootstrapIssue]);
41446
+ const bootstrapFailed = (why) => {
41447
+ process.stderr.write(`[spec-gate] acceptance-criteria bootstrap failed for ${spec.pageId}: ${why}
41448
+ `);
41449
+ return {
41450
+ outcome: "CONSENSUS_BLOCKED",
41451
+ diagnostics: [
41452
+ {
41453
+ kind: "acceptance_criteria_unauthorable",
41454
+ diagnostic: true,
41455
+ rawQuestion: "This spec has no machine-usable acceptance criteria and the reviser could not add them automatically. A human needs to write an `## Acceptance criteria` section as a markdown checklist (`- [ ] \u2026` items) describing exactly what the finished deliverable must satisfy \u2014 the review of the produced work is scored against it.",
41456
+ whyBlocking: "spec is missing a parseable `## Acceptance criteria` checklist and self-authoring failed",
41457
+ category: "scope"
41458
+ }
41459
+ ]
41460
+ };
41461
+ };
41462
+ if (!revised.ok) return bootstrapFailed("reviser unavailable");
41463
+ if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
41464
+ return bootstrapFailed(`revision heading does not identify ${intent.taskId}`);
41465
+ }
41466
+ if (parseAcceptanceCriteria(revised.text) === null) {
41467
+ return bootstrapFailed("revised spec still has no parseable `## Acceptance criteria` checklist");
41468
+ }
41469
+ const cas = await deps.casUpdateSpec(revised.text, currentHash);
41470
+ if (!cas.ok) {
41471
+ await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
41472
+ return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
41473
+ }
41474
+ currentHash = cas.contentHash;
41475
+ currentMarkdown = revised.text;
41476
+ }
41477
+ const result = await runConsensus({
41478
+ artifact: currentMarkdown,
41479
+ heads,
41480
+ rounds: deps.rounds,
41481
+ maxHeadCalls: deps.maxHeadCalls,
41482
+ runHead: deps.runHead,
41483
+ runArbiter: deps.runArbiter,
41484
+ revise: async (artifact, accepted) => {
41485
+ const revised = await deps.runReviser(artifact, accepted);
41486
+ if (!revised.ok) return artifact;
41487
+ if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
41488
+ process.stderr.write(
41489
+ `[spec-gate] discarding drifted revision for ${spec.pageId}: heading does not identify ${intent.taskId}
41490
+ `
41491
+ );
41492
+ return artifact;
41493
+ }
41494
+ const cas = await deps.casUpdateSpec(revised.text, currentHash);
41495
+ if (!cas.ok) {
41496
+ conflicted = true;
41497
+ throw new Error("SPEC_CHANGED_DURING_REVIEW");
41498
+ }
41499
+ currentHash = cas.contentHash;
41500
+ currentMarkdown = revised.text;
41501
+ return revised.text;
41502
+ }
41503
+ // Only OUR conflict throw is swallowed (→ null). Anything else is a real
41504
+ // error and must propagate so it is not silently masked as a conflict.
41505
+ }).catch((e) => conflicted ? null : Promise.reject(e));
41506
+ if (conflicted || result === null) {
41507
+ await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
41508
+ return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
41509
+ }
41510
+ if (result.outcome === "CONVERGED" && intent.taskId !== null && !validateSpecIdentity(currentMarkdown, intent.taskId)) {
41511
+ process.stderr.write(
41512
+ `[spec-gate] blocking promotion for ${spec.pageId}: converged spec heading does not identify ${intent.taskId}
41513
+ `
41514
+ );
41515
+ return {
41516
+ outcome: "CONSENSUS_BLOCKED",
41517
+ diagnostics: [
41518
+ {
41519
+ kind: "spec_identity_mismatch",
41520
+ diagnostic: true,
41521
+ rawQuestion: `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.`,
41522
+ whyBlocking: "converged spec content belongs to a different task than the one it gates",
41523
+ category: "scope"
41524
+ }
41525
+ ]
41526
+ };
41527
+ }
41528
+ let keptClarifications = [];
41529
+ let droppedByCap = [];
41530
+ if (result.outcome === "CONSENSUS_BLOCKED") {
41531
+ const accepted = result.history.at(-1)?.accepted ?? [];
41532
+ const materialIssues = accepted.filter((issue2) => issue2.category === "ambiguity" || issue2.category === "scope");
41533
+ const capped = applyClarificationCap(materialIssues, intent.impact);
41534
+ keptClarifications = capped.kept;
41535
+ droppedByCap = capped.dropped;
41536
+ }
41537
+ await deps.writeEvidence({
41538
+ specPageId: spec.pageId,
41539
+ outcome: result.outcome,
41540
+ rounds: result.rounds,
41541
+ history: result.history,
41542
+ contentHash: currentHash,
41543
+ ...droppedByCap.length > 0 ? { droppedByCap } : {}
41544
+ });
41545
+ if (result.outcome === "CONVERGED") {
41546
+ await deps.promoteSpecReady(currentHash);
41547
+ return { outcome: "CONVERGED" };
41548
+ }
41549
+ if (result.outcome === "CONSENSUS_BLOCKED") {
41550
+ return {
41551
+ outcome: "CONSENSUS_BLOCKED",
41552
+ ...keptClarifications.length > 0 ? { operatorClarifications: keptClarifications } : {},
41553
+ ...droppedByCap.length > 0 ? { droppedByCap } : {},
41554
+ ...result.escalation ? { escalation: result.escalation } : {}
41555
+ };
41556
+ }
41557
+ return { outcome: result.outcome };
41558
+ }
41559
+
41560
+ // src/consensus/resolve-gated-task.ts
41561
+ async function resolveGatedTaskId(client, projectSlug, page) {
41562
+ const tasks = await client.listTasks(projectSlug);
41563
+ const linking = tasks.find((t) => t.specWikiPageId === page.id);
41564
+ if (linking) {
41565
+ if (page.taskId !== linking.id) {
41566
+ throw new Error(
41567
+ `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`
41568
+ );
41569
+ }
41570
+ return linking.id;
41571
+ }
41572
+ return page.taskId;
41573
+ }
41574
+
41575
+ // src/consensus/review-identity.ts
41576
+ function domainScopedReviewHash2(rawHash, domain2) {
41577
+ return domainScopedReviewHash(rawHash, domain2);
41578
+ }
41579
+ async function resolveSpecDomain(client, projectSlug, page) {
41580
+ try {
41581
+ const taskId = await resolveGatedTaskId(client, projectSlug, page);
41582
+ if (taskId !== null) {
41583
+ const task = await client.getTask(projectSlug, taskId);
41584
+ return normalizeDomain(task?.domain);
41585
+ }
41586
+ const plans = await client.listPlans(projectSlug);
41587
+ const plan = plans.find((p) => p.specWikiPageId === page.id);
41588
+ if (plan?.domain != null) return normalizeDomain(plan.domain);
41589
+ const project = await client.getProject(projectSlug);
41590
+ return normalizeDomain(project?.defaultDomain);
41591
+ } catch {
41592
+ return "code";
41593
+ }
41594
+ }
41595
+
41006
41596
  // src/consensus/code-gate-git.ts
41007
41597
  var import_node_child_process7 = require("node:child_process");
41008
41598
  var DIFF_MAX_BYTES = 2e5;
@@ -41295,16 +41885,25 @@ function buildSpecGateDeps(args) {
41295
41885
  throw new Error(`gated task ${projectSlug}/${taskId} not found for spec ${specPageId} \u2014 cannot gate`);
41296
41886
  }
41297
41887
  const notes = task.notes ?? null;
41888
+ const taskInboxItems = await client.listInboxItems(projectSlug, { taskId });
41298
41889
  const rawSeed = task.operatorSeedRequest ?? null;
41299
41890
  let resolvedSeed = rawSeed;
41300
41891
  if (rawSeed === null || rawSeed.trim() === "") {
41301
- const parsed = notes ? parseAcceptanceCriteria(notes) : null;
41302
- if (parsed !== null && parsed.items.length > 0) {
41303
- resolvedSeed = "(No operator statement on record for this task. Derived from the task's own notes \u2014 the following acceptance criteria were extracted verbatim:)\n\n" + parsed.items.map((c) => `- ${c}`).join("\n");
41892
+ const answered = taskInboxItems.find(
41893
+ (item) => item.origin === "gate" && item.status === "acknowledged" && item.answer !== null && item.answer.trim() !== "" && item.body.includes(SEED_ABSENCE_MARKER)
41894
+ );
41895
+ if (answered && answered.answer) {
41896
+ resolvedSeed = "(No operator statement was recorded when this task was created. Derived from the operator's answer to the gate's requirements question:)\n\n" + answered.answer;
41897
+ } else {
41898
+ const parsed = notes ? parseAcceptanceCriteria(notes) : null;
41899
+ if (parsed !== null && parsed.items.length > 0) {
41900
+ resolvedSeed = "(No operator statement on record for this task. Derived from the task's own notes \u2014 the following acceptance criteria were extracted verbatim:)\n\n" + parsed.items.map((c) => `- ${c}`).join("\n");
41901
+ }
41304
41902
  }
41305
41903
  }
41306
- const taskInboxItems = await client.listInboxItems(projectSlug, { taskId });
41307
- const adjudicated = taskInboxItems.filter((item) => item.origin === "gate" && (item.status === "acknowledged" || item.status === "rejected")).map((item) => ({
41904
+ const adjudicated = taskInboxItems.filter(
41905
+ (item) => item.origin === "gate" && (item.status === "acknowledged" || item.status === "rejected") && item.decidedBy !== "gate"
41906
+ ).map((item) => ({
41308
41907
  body: item.body,
41309
41908
  answer: item.answer,
41310
41909
  status: item.status
@@ -41436,7 +42035,7 @@ ${artifact}`,
41436
42035
  const { domain: domain2 } = await loadIntent();
41437
42036
  try {
41438
42037
  await client.recordSpecConsensusReview(projectSlug, specPageId, {
41439
- contentHash: domainScopedReviewHash(contentHash, domain2),
42038
+ contentHash: domainScopedReviewHash2(contentHash, domain2),
41440
42039
  outcome: String(outcome),
41441
42040
  rounds: typeof rounds2 === "number" ? rounds2 : 0
41442
42041
  });
@@ -41648,7 +42247,7 @@ You revise a deliverable to address the accepted issues so it satisfies the acce
41648
42247
  const rounds2 = typeof r["rounds"] === "number" ? r["rounds"] : 0;
41649
42248
  try {
41650
42249
  await client.recordSpecConsensusReview(projectSlug, deliverablePageId, {
41651
- contentHash: domainScopedReviewHash(contentHash, domain2),
42250
+ contentHash: domainScopedReviewHash2(contentHash, domain2),
41652
42251
  outcome,
41653
42252
  rounds: rounds2
41654
42253
  });
@@ -41831,6 +42430,111 @@ ${body}`;
41831
42430
  }
41832
42431
  return { markdown: body, tldrChars };
41833
42432
  }
42433
+ function extractDeployTarget(markdown) {
42434
+ const section = /(## Deploy Target\s*\n)([\s\S]*?)(?=\n## |$)/i.exec(markdown);
42435
+ if (!section || section.index === void 0) {
42436
+ return { markdown, deployTarget: null };
42437
+ }
42438
+ const remaining = markdown.slice(0, section.index) + markdown.slice(section.index + section[0].length);
42439
+ const fenced = /```(?:json)?\s*\n([\s\S]*?)```/m.exec(section[2] ?? "");
42440
+ let deployTarget = null;
42441
+ if (fenced) {
42442
+ try {
42443
+ const raw = JSON.parse(fenced[1] ?? "");
42444
+ if (raw !== null && typeof raw === "object" && typeof raw.label === "string") {
42445
+ const parsed = { label: raw.label };
42446
+ if (Array.isArray(raw.derive)) {
42447
+ const derive = raw.derive.filter(isDeployTopologyEntry);
42448
+ if (derive.length === raw.derive.length) parsed.derive = derive;
42449
+ }
42450
+ deployTarget = parsed;
42451
+ }
42452
+ } catch {
42453
+ deployTarget = null;
42454
+ }
42455
+ }
42456
+ return { markdown: remaining, deployTarget };
42457
+ }
42458
+ function isDeployTopologyEntry(value) {
42459
+ if (value === null || typeof value !== "object") return false;
42460
+ const e = value;
42461
+ return typeof e.label === "string" && e.label.length > 0 && (e.env === "dev" || e.env === "prod") && typeof e.workflowId === "number" && Number.isInteger(e.workflowId) && e.workflowId > 0 && typeof e.workflowPath === "string" && e.workflowPath.length > 0 && typeof e.workflowSha === "string" && e.workflowSha.length > 0;
42462
+ }
42463
+ function buildDeployTargetPromptSection(ctx) {
42464
+ if (ctx.kind === "closed_menu") {
42465
+ const labels = [...new Set(ctx.entries.map((entry) => entry.label))];
42466
+ return [
42467
+ ``,
42468
+ `--- DEPLOY TARGET (required final section) ---`,
42469
+ `This project's deploy topology is already resolved for its current workflow inventory. Pick the ONE label this task deploys under from this CLOSED menu \u2014 never invent a new label:`,
42470
+ ...labels.map((label) => `- ${label}`),
42471
+ ``,
42472
+ `Append a final section \`## Deploy Target\` whose ONLY content is a single fenced json block of exactly this shape:`,
42473
+ "```json",
42474
+ `{"label": "<one of the menu labels above>"}`,
42475
+ "```"
42476
+ ].join("\n");
42477
+ }
42478
+ return [
42479
+ ``,
42480
+ `--- DEPLOY TARGET (required final section) ---`,
42481
+ `This project's deploy topology is NOT yet resolved for its current workflow inventory. Below is the raw GitHub workflow list (workflowId / path / blob sha / state); every value used below must be copied VERBATIM from it \u2014 never invented:`,
42482
+ ``,
42483
+ JSON.stringify(ctx.snapshot, null, 2),
42484
+ ``,
42485
+ `First derive the FULL label map: identify every deploy target in the snapshot, give each a short kebab-case \`label\`, and classify each workflow into an \`env\` of exactly "dev" or "prod".`,
42486
+ `Heuristic for dev vs prod (there is no fixed naming rule): prefer an explicit \`dev-\`/\`prod-\` filename prefix, then a \`/dev/\` or \`/prod/\` directory segment; if neither applies, group the workflows serving the same target and classify from the pair's context.`,
42487
+ `Then pick the ONE \`label\` this task deploys under, from among the labels you just derived.`,
42488
+ ``,
42489
+ `Append a final section \`## Deploy Target\` whose ONLY content is a single fenced json block of exactly this shape:`,
42490
+ "```json",
42491
+ `{"label": "<label this task deploys under>", "derive": [{"label":"<target label>","env":"dev"|"prod","workflowId":N,"workflowPath":"<path>","workflowSha":"<blob sha>"}, ...]}`,
42492
+ "```",
42493
+ ``,
42494
+ `\`derive\` is the FULL map of every deploy target you can identify in the snapshot (not just this task's pick), so later tasks reuse this derivation instead of re-deriving. \`env\` must be exactly "dev" or "prod". \`workflowId\`/\`workflowPath\`/\`workflowSha\` must be copied verbatim from the snapshot above.`
42495
+ ].join("\n");
42496
+ }
42497
+ function resolveDeployTargetRows(canonical, deployTarget, autoDeploy, fingerprint) {
42498
+ if (autoDeploy === "off") return {};
42499
+ const filtered = canonical.filter((entry) => entry.label === deployTarget.label);
42500
+ const requiredEnvs = autoDeploy === "prod" ? ["dev", "prod"] : ["dev"];
42501
+ const missingEnvs = [];
42502
+ const deployTargets = [];
42503
+ for (const env of requiredEnvs) {
42504
+ const match = filtered.find((entry) => entry.env === env);
42505
+ if (!match) {
42506
+ missingEnvs.push(env);
42507
+ continue;
42508
+ }
42509
+ deployTargets.push({
42510
+ env,
42511
+ targetLabel: deployTarget.label,
42512
+ workflowId: match.workflowId,
42513
+ workflowPath: match.workflowPath,
42514
+ workflowSha: match.workflowSha,
42515
+ topologyFingerprint: fingerprint
42516
+ });
42517
+ }
42518
+ if (missingEnvs.length > 0) return { skippedReason: "incomplete_label", missingEnvs };
42519
+ return { deployTargets };
42520
+ }
42521
+ function findDuplicateLabelEnvPairs(entries) {
42522
+ const seen = /* @__PURE__ */ new Set();
42523
+ const duplicateKeys = /* @__PURE__ */ new Set();
42524
+ for (const entry of entries) {
42525
+ const key = `${entry.label}\0${entry.env}`;
42526
+ if (seen.has(key)) duplicateKeys.add(key);
42527
+ seen.add(key);
42528
+ }
42529
+ const pairs = [];
42530
+ for (const key of duplicateKeys) {
42531
+ const parts = key.split("\0");
42532
+ const label = parts[0] ?? "";
42533
+ const env = parts[1] === "prod" ? "prod" : "dev";
42534
+ pairs.push({ label, env });
42535
+ }
42536
+ return pairs;
42537
+ }
41834
42538
  function nextWikiPageId(pages) {
41835
42539
  let max = 0;
41836
42540
  for (const page of pages) {
@@ -41841,7 +42545,7 @@ function nextWikiPageId(pages) {
41841
42545
  }
41842
42546
  return `WK-${max + 1}`;
41843
42547
  }
41844
- function buildDraftSpecPrompt(task, constitution) {
42548
+ function buildDraftSpecPrompt(task, constitution, deployTarget = null) {
41845
42549
  const budget = resolveSpecBudget(task.impact);
41846
42550
  const lines2 = [
41847
42551
  `Write a complete implementation spec for the task below.`,
@@ -41878,6 +42582,7 @@ function buildDraftSpecPrompt(task, constitution) {
41878
42582
  `The \`## TL;DR\` section MUST be at most ${DRAFT_SPEC_TLDR_MAX_CHARS} characters.`,
41879
42583
  `The spec MUST contain at most ${budget.maxRequirements} acceptance-criteria items and no more than ${budget.maxChars} characters total.`
41880
42584
  );
42585
+ if (deployTarget) lines2.push(buildDeployTargetPromptSection(deployTarget));
41881
42586
  return lines2.join("\n");
41882
42587
  }
41883
42588
  function skipped(reason, extra = {}) {
@@ -41926,6 +42631,22 @@ async function runDraftSpec(ctx, input, deps) {
41926
42631
  }
41927
42632
  if (getActiveBlockedByIds(task.blockedBy, blockers).length > 0) return skipped("blocked_by_active");
41928
42633
  }
42634
+ const autoDeploy = task.autoDeploy ?? "off";
42635
+ let topology = null;
42636
+ let deploySkippedReason = null;
42637
+ let deploySkippedDetail = null;
42638
+ if (autoDeploy !== "off") {
42639
+ try {
42640
+ topology = await client.getDeployTopology(projectSlug);
42641
+ } catch {
42642
+ deploySkippedReason = "topology_unavailable";
42643
+ topology = null;
42644
+ }
42645
+ }
42646
+ if (topology && (topology.fingerprint === null || topology.entries.length === 0 && topology.snapshot === null)) {
42647
+ deploySkippedReason = topology.fingerprint === null ? "no_bound_deploy_target" : "topology_snapshot_unavailable";
42648
+ topology = null;
42649
+ }
41929
42650
  let persona;
41930
42651
  try {
41931
42652
  persona = loadPersonaFn(DRAFT_SPEC_PERSONA);
@@ -41954,7 +42675,8 @@ async function runDraftSpec(ctx, input, deps) {
41954
42675
  } catch {
41955
42676
  constitution = null;
41956
42677
  }
41957
- const prompt = buildDraftSpecPrompt(task, constitution);
42678
+ const deployPromptContext = autoDeploy !== "off" && topology ? topology.entries.length > 0 ? { kind: "closed_menu", entries: topology.entries } : { kind: "derive", snapshot: topology.snapshot ?? [] } : null;
42679
+ const prompt = buildDraftSpecPrompt(task, constitution, deployPromptContext);
41958
42680
  const exec = await runExec({
41959
42681
  prompt,
41960
42682
  developerInstructions: persona,
@@ -41969,7 +42691,8 @@ async function runDraftSpec(ctx, input, deps) {
41969
42691
  if (exec.text.trim().length === 0) {
41970
42692
  return { kind: "retry", failureReason: "zero_output", error: "spec authoring produced empty output" };
41971
42693
  }
41972
- const { markdown, tldrChars } = enforceSpecOutputContract(exec.text, taskId, task.title);
42694
+ const { markdown: specMarkdown, deployTarget: parsedDeployTarget } = extractDeployTarget(exec.text);
42695
+ const { markdown, tldrChars } = enforceSpecOutputContract(specMarkdown, taskId, task.title);
41973
42696
  let pageId;
41974
42697
  if (adoptedId) {
41975
42698
  try {
@@ -42029,6 +42752,45 @@ async function runDraftSpec(ctx, input, deps) {
42029
42752
  }
42030
42753
  pageId = created;
42031
42754
  }
42755
+ let deployTargets;
42756
+ if (topology && parsedDeployTarget) {
42757
+ const fingerprint = topology.fingerprint;
42758
+ if (fingerprint === null) {
42759
+ deploySkippedReason = deploySkippedReason ?? "no_bound_deploy_target";
42760
+ } else if (topology.entries.length > 0) {
42761
+ const resolved = resolveDeployTargetRows(topology.entries, parsedDeployTarget, autoDeploy, fingerprint);
42762
+ deployTargets = resolved.deployTargets;
42763
+ if (resolved.skippedReason) {
42764
+ deploySkippedReason = resolved.skippedReason;
42765
+ if (resolved.skippedReason === "incomplete_label") {
42766
+ deploySkippedDetail = `label "${parsedDeployTarget.label}" has no entry for: ${(resolved.missingEnvs ?? []).join(", ")}`;
42767
+ }
42768
+ }
42769
+ } else {
42770
+ const derive = parsedDeployTarget.derive ?? [];
42771
+ const duplicateLabelEnvPairs = findDuplicateLabelEnvPairs(derive);
42772
+ if (derive.length === 0) {
42773
+ deploySkippedReason = "no_derivation";
42774
+ } else if (duplicateLabelEnvPairs.length > 0) {
42775
+ deploySkippedReason = "ambiguous_derivation";
42776
+ deploySkippedDetail = `duplicate label/env pair(s): ${duplicateLabelEnvPairs.map((pair) => `${pair.label}/${pair.env}`).join(", ")}`;
42777
+ } else {
42778
+ try {
42779
+ const written = await client.writeDeployTopology(projectSlug, { fingerprint, entries: derive });
42780
+ const resolved = resolveDeployTargetRows(written.entries, parsedDeployTarget, autoDeploy, fingerprint);
42781
+ deployTargets = resolved.deployTargets;
42782
+ if (resolved.skippedReason) {
42783
+ deploySkippedReason = resolved.skippedReason;
42784
+ if (resolved.skippedReason === "incomplete_label") {
42785
+ deploySkippedDetail = `label "${parsedDeployTarget.label}" has no entry for: ${(resolved.missingEnvs ?? []).join(", ")}`;
42786
+ }
42787
+ }
42788
+ } catch {
42789
+ deploySkippedReason = "topology_write_failed";
42790
+ }
42791
+ }
42792
+ }
42793
+ }
42032
42794
  let fresh;
42033
42795
  try {
42034
42796
  fresh = await client.getTask(projectSlug, taskId);
@@ -42045,8 +42807,12 @@ async function runDraftSpec(ctx, input, deps) {
42045
42807
  if (fresh.specWikiPageId) {
42046
42808
  return skipped("lost_link_race", { pageId, linkedPageId: fresh.specWikiPageId });
42047
42809
  }
42810
+ const linkPatch = { specWikiPageId: pageId };
42811
+ if (deployTargets !== void 0 && deployTargets.length > 0) {
42812
+ linkPatch.deployTargets = deployTargets;
42813
+ }
42048
42814
  try {
42049
- await client.updateTask(projectSlug, taskId, { specWikiPageId: pageId });
42815
+ await client.updateTask(projectSlug, taskId, linkPatch);
42050
42816
  } catch (error51) {
42051
42817
  return {
42052
42818
  kind: "retry",
@@ -42054,16 +42820,20 @@ async function runDraftSpec(ctx, input, deps) {
42054
42820
  error: `failed to link spec page ${pageId} to task ${taskId}: ${errMessage2(error51)}`
42055
42821
  };
42056
42822
  }
42823
+ const drafted = {
42824
+ outcome: "DRAFTED",
42825
+ pageId,
42826
+ adopted: adoptedId !== null,
42827
+ constitution: constitution !== null ? "included" : "missing",
42828
+ tldrChars,
42829
+ relatedContext: "skipped_no_client_method"
42830
+ };
42831
+ if (deploySkippedReason !== null) drafted.deployTargetSkippedReason = deploySkippedReason;
42832
+ if (deploySkippedDetail !== null) drafted.deployTargetSkippedDetail = deploySkippedDetail;
42833
+ drafted.taskTitle = task.title;
42057
42834
  return {
42058
42835
  kind: "done",
42059
- resultJson: JSON.stringify({
42060
- outcome: "DRAFTED",
42061
- pageId,
42062
- adopted: adoptedId !== null,
42063
- constitution: constitution !== null ? "included" : "missing",
42064
- tldrChars,
42065
- relatedContext: "skipped_no_client_method"
42066
- }),
42836
+ resultJson: JSON.stringify(drafted),
42067
42837
  finalReviewedHash: null
42068
42838
  };
42069
42839
  }
@@ -42077,12 +42847,23 @@ function onComplete(outcome, input) {
42077
42847
  if (parsed.outcome === "SKIPPED") {
42078
42848
  return {
42079
42849
  kind: "info",
42850
+ taskId: input.taskId,
42080
42851
  body: `Spec draft for ${input.taskId} skipped (${parsed.reason ?? "unknown"}); nothing changed.`
42081
42852
  };
42082
42853
  }
42854
+ if (parsed.deployTargetSkippedReason === "ambiguous_derivation" || parsed.deployTargetSkippedReason === "incomplete_label") {
42855
+ return {
42856
+ kind: "handoff",
42857
+ taskId: input.taskId,
42858
+ id: `II-deploy-target-${parsed.deployTargetSkippedReason}-${input.taskId}`,
42859
+ body: `Spec for ${input.taskId} was drafted (page ${parsed.pageId ?? "?"} in Specs/), but its deploy target needs a human: ${parsed.deployTargetSkippedDetail ?? parsed.deployTargetSkippedReason}. The spec itself proceeds to the normal consensus review; the deploy target stays unresolved until this is fixed.`
42860
+ };
42861
+ }
42862
+ const taskRef = parsed.taskTitle ? `${input.taskId} \u2014 ${parsed.taskTitle}` : input.taskId;
42083
42863
  return {
42084
42864
  kind: "info",
42085
- body: `Spec for ${input.taskId} was drafted automatically \u2014 page ${parsed.pageId ?? "?"} in Specs/. The next scheduler tick will run the consensus review.`
42865
+ taskId: input.taskId,
42866
+ body: `Spec drafted automatically for ${taskRef} \u2014 page ${parsed.pageId ?? "?"} in Specs/. The next scheduler tick will run the consensus review.`
42086
42867
  };
42087
42868
  }
42088
42869
  const summary = outcome.error;
@@ -42451,34 +43232,42 @@ async function tryProdAutoRevert(deps, projectSlug, run3, task) {
42451
43232
  return null;
42452
43233
  }
42453
43234
  }
42454
- function computeActiveSetFingerprint(workflows) {
42455
- return workflows.map((workflow) => `${workflow.id}:${workflow.path}`).sort().join(",");
42456
- }
42457
- async function resolveWorkflowId(deps, projectSlug, run3, env) {
43235
+ async function resolveWorkflowId(deps, projectSlug, run3, task, env) {
42458
43236
  const pinned = env === "dev" ? run3.devWorkflowId : run3.prodWorkflowId;
42459
- if (pinned) return { workflowId: pinned, source: null, resolvedFromStore: false };
42460
- const row = await deps.client.getProjectDeployWorkflow(projectSlug, env);
42461
- if (!row || row.state !== "resolved" || row.workflowId === null) {
42462
- return { workflowId: null, source: null, resolvedFromStore: false };
42463
- }
42464
- if (row.source !== "operator") {
42465
- const liveWorkflows = await deps.client.listGithubWorkflows(projectSlug);
42466
- const activeWorkflows = liveWorkflows.filter((workflow) => workflow.state === "active");
42467
- const isLive = activeWorkflows.some((workflow) => String(workflow.id) === String(row.workflowId));
42468
- if (!isLive) return { workflowId: null, source: null, resolvedFromStore: false };
42469
- const liveFingerprint = computeActiveSetFingerprint(activeWorkflows);
42470
- if (!row.reverifyRequested && row.activeSetFingerprint !== null && liveFingerprint !== row.activeSetFingerprint) {
42471
- await deps.client.upsertProjectDeployWorkflow(projectSlug, env, {
42472
- workflowId: row.workflowId,
42473
- workflowPath: row.workflowPath,
42474
- reason: row.reason,
42475
- state: row.state,
42476
- activeSetFingerprint: row.activeSetFingerprint,
42477
- reverifyRequested: true
42478
- });
42479
- }
43237
+ if (pinned) {
43238
+ return { workflowId: pinned, workflowPath: null, blockReason: null, pinned: true, reverifyRequested: false };
43239
+ }
43240
+ const target = await deps.getTaskDeployTarget(projectSlug, task.id, env);
43241
+ if (!target) {
43242
+ return { workflowId: null, workflowPath: null, blockReason: "deploy_target_unset", pinned: false, reverifyRequested: false };
43243
+ }
43244
+ const snapshot = await deps.getRepoSnapshot(projectSlug);
43245
+ const activeWorkflows = (snapshot?.lastSnapshot ?? []).filter((w) => w.state === "active");
43246
+ const liveMatch = activeWorkflows.find((w) => w.id === target.workflowId);
43247
+ if (!liveMatch || liveMatch.path !== target.workflowPath) {
43248
+ return { workflowId: null, workflowPath: null, blockReason: "deploy_target_inactive", pinned: false, reverifyRequested: false };
43249
+ }
43250
+ if (liveMatch.sha !== target.workflowSha) {
43251
+ return { workflowId: null, workflowPath: null, blockReason: "deploy_target_modified", pinned: false, reverifyRequested: false };
43252
+ }
43253
+ const currentFingerprint = snapshot?.lastFingerprint ?? null;
43254
+ if (currentFingerprint !== null && currentFingerprint === target.topologyFingerprint) {
43255
+ return { workflowId: target.workflowId, workflowPath: target.workflowPath, blockReason: null, pinned: false, reverifyRequested: false };
43256
+ }
43257
+ if (currentFingerprint === null) {
43258
+ deps.recordDeployTargetTelemetry("deploy_target_cache_missing");
43259
+ return { workflowId: null, workflowPath: null, blockReason: "deploy_target_cache_missing", pinned: false, reverifyRequested: false };
43260
+ }
43261
+ const entry = await deps.lookupTopologyEntry(projectSlug, currentFingerprint, target.targetLabel, env);
43262
+ if (!entry) {
43263
+ deps.recordDeployTargetTelemetry("deploy_target_cache_missing");
43264
+ return { workflowId: null, workflowPath: null, blockReason: "deploy_target_cache_missing", pinned: false, reverifyRequested: false };
42480
43265
  }
42481
- return { workflowId: row.workflowId, source: row.source, resolvedFromStore: true };
43266
+ if (entry.workflowId !== target.workflowId) {
43267
+ deps.recordDeployTargetTelemetry("deploy_target_reassigned");
43268
+ return { workflowId: null, workflowPath: null, blockReason: "deploy_target_reassigned", pinned: false, reverifyRequested: false };
43269
+ }
43270
+ return { workflowId: target.workflowId, workflowPath: target.workflowPath, blockReason: null, pinned: false, reverifyRequested: true };
42482
43271
  }
42483
43272
  async function runDeployGateTick(deps, projectSlug, run3, task) {
42484
43273
  const artifacts = await deps.client.listExecutionArtifacts(projectSlug, run3.id);
@@ -42500,7 +43289,10 @@ async function runDeployGateTick(deps, projectSlug, run3, task) {
42500
43289
  return { kind: "done" };
42501
43290
  }
42502
43291
  const env = activeEnv;
42503
- const resolution = await resolveWorkflowId(deps, projectSlug, run3, env);
43292
+ const resolution = await resolveWorkflowId(deps, projectSlug, run3, task, env);
43293
+ if (resolution.reverifyRequested) {
43294
+ await deps.setTaskReverifyRequested(projectSlug, task.id);
43295
+ }
42504
43296
  const workflowId = resolution.workflowId;
42505
43297
  const attempt = latestByEnv(artifacts, "deploy_attempt", env, { realAttemptsOnly: true });
42506
43298
  if (!attempt) {
@@ -42527,9 +43319,13 @@ async function runDeployGateTick(deps, projectSlug, run3, task) {
42527
43319
  await deps.handoff(projectSlug, task, run3, "verification_steps_missing");
42528
43320
  return { kind: "handoff", reason: "verification_steps_missing" };
42529
43321
  }
43322
+ if (resolution.blockReason) {
43323
+ await deps.handoff(projectSlug, task, run3, `${resolution.blockReason}:${env}`);
43324
+ return { kind: "handoff", reason: `${resolution.blockReason}:${env}` };
43325
+ }
42530
43326
  if (!workflowId) {
42531
- await deps.handoff(projectSlug, task, run3, `workflow_id_missing:${env}`);
42532
- return { kind: "handoff", reason: `workflow_id_missing:${env}` };
43327
+ await deps.handoff(projectSlug, task, run3, `deploy_target_unset:${env}`);
43328
+ return { kind: "handoff", reason: `deploy_target_unset:${env}` };
42533
43329
  }
42534
43330
  if (!run3.branch) {
42535
43331
  await deps.handoff(projectSlug, task, run3, "run_branch_missing");
@@ -42547,19 +43343,13 @@ async function runDeployGateTick(deps, projectSlug, run3, task) {
42547
43343
  try {
42548
43344
  await deps.client.dispatchTaskDeploy(projectSlug, task.id, { env, ref: run3.branch, workflowId: String(workflowId) });
42549
43345
  } catch (error51) {
42550
- if (resolution.source === "operator") {
42551
- await deps.handoff(
42552
- projectSlug,
42553
- task,
42554
- run3,
42555
- `deploy_workflow_operator_choice_stale:${env}:${String(error51).slice(0, 120)}`
42556
- );
42557
- return { kind: "handoff", reason: `deploy_workflow_operator_choice_stale:${env}` };
43346
+ if (!resolution.pinned && !resolution.reverifyRequested) {
43347
+ deps.recordDeployTargetTelemetry("deploy_dispatch_failed_fingerprint_unchanged");
42558
43348
  }
42559
43349
  await deps.handoff(projectSlug, task, run3, `workflow_dispatch_failed:${env}:${String(error51).slice(0, 120)}`);
42560
43350
  return { kind: "handoff", reason: `workflow_dispatch_failed:${env}` };
42561
43351
  }
42562
- if (resolution.resolvedFromStore) {
43352
+ if (!resolution.pinned) {
42563
43353
  await deps.client.updateExecutionRun(projectSlug, run3.id, {
42564
43354
  [env === "dev" ? "devWorkflowId" : "prodWorkflowId"]: workflowId
42565
43355
  });
@@ -44651,7 +45441,35 @@ async function runBackstopSweep(deps, drive = (slug, id) => driveRun(deps, slug,
44651
45441
  await deployGateHandoff(deps, projectSlug, t, r.id, reason, r.leaseId);
44652
45442
  },
44653
45443
  budgetSeconds: deps.deployGate.budgetSeconds ?? deployPollBudgetSeconds(),
44654
- now: () => /* @__PURE__ */ new Date()
45444
+ now: () => /* @__PURE__ */ new Date(),
45445
+ getTaskDeployTarget: async (projectSlug, taskId, env) => deps.client.getTaskDeployTarget(projectSlug, taskId, env),
45446
+ getRepoSnapshot: async (projectSlug) => {
45447
+ try {
45448
+ const topology = await deps.client.getDeployTopology(projectSlug);
45449
+ return { lastFingerprint: topology.fingerprint, lastSnapshot: topology.snapshot };
45450
+ } catch {
45451
+ return null;
45452
+ }
45453
+ },
45454
+ lookupTopologyEntry: async (projectSlug, fingerprint, label, env) => {
45455
+ try {
45456
+ const topology = await deps.client.getDeployTopology(projectSlug);
45457
+ if (topology.fingerprint !== fingerprint) return null;
45458
+ const entry = topology.entries.find((e) => e.label === label && e.env === env);
45459
+ return entry ? { workflowId: entry.workflowId, workflowPath: entry.workflowPath, workflowSha: entry.workflowSha } : null;
45460
+ } catch {
45461
+ return null;
45462
+ }
45463
+ },
45464
+ setTaskReverifyRequested: async (projectSlug, taskId) => {
45465
+ await deps.client.updateTask(projectSlug, taskId, { reverifyRequested: true }).catch(() => {
45466
+ });
45467
+ },
45468
+ recordDeployTargetTelemetry: (counter) => {
45469
+ console.log(
45470
+ JSON.stringify({ event: "deploy_target_telemetry", counter, projectSlug: slug, taskId: task.id })
45471
+ );
45472
+ }
44655
45473
  };
44656
45474
  await runDeployGateTick(gateDeps, slug, run3, task);
44657
45475
  } catch {
@@ -44831,240 +45649,6 @@ async function runAsyncBackstopSweep(deps, drive = (slug, jobId) => driveAsyncJo
44831
45649
  }
44832
45650
  }
44833
45651
 
44834
- // src/consensus/verdict.ts
44835
- var CATEGORIES = [
44836
- "correctness",
44837
- "security",
44838
- "scope",
44839
- "ambiguity",
44840
- "performance",
44841
- "ops"
44842
- ];
44843
- var CAT_SET = new Set(CATEGORIES);
44844
- function parseVerdict(raw) {
44845
- const verdictMatch = raw.match(/\*\*Verdict\*\*:\s*(APPROVE|REQUEST_CHANGES|REJECT)\b/i);
44846
- const parsedVerdict = verdictMatch?.[1]?.toUpperCase();
44847
- const verdict = parsedVerdict === "APPROVE" || parsedVerdict === "REQUEST_CHANGES" || parsedVerdict === "REJECT" ? parsedVerdict : "REQUEST_CHANGES";
44848
- const afterCritical = raw.split(/\*\*Critical issues\*\*/i)[1];
44849
- const block = afterCritical?.split(/\n\*\*/)[0] ?? "";
44850
- const issues = [];
44851
- const parseFallbacks = [];
44852
- for (const line of block.split(/\r?\n/)) {
44853
- const bulletMatch = line.match(/^\s*-\s+(.*\S)\s*$/);
44854
- if (!bulletMatch) continue;
44855
- const body = bulletMatch[1] ?? "";
44856
- if (/^\(?(empty list|none)\b/i.test(body)) continue;
44857
- if (!body) continue;
44858
- const catMatch = body.match(/`?\[(\w+)\]`?/);
44859
- const catToken = catMatch?.[1]?.toLowerCase() ?? "";
44860
- const isKnownCat = catToken !== "" && CAT_SET.has(catToken);
44861
- const category = isKnownCat ? catToken : "ambiguity";
44862
- if (!catMatch || !isKnownCat) {
44863
- parseFallbacks.push(body);
44864
- }
44865
- const text = body.replace(/`?\[\w+\]`?\s*/, "").trim();
44866
- issues.push({ category, text });
44867
- }
44868
- return { verdict, issues, parseFallbacks };
44869
- }
44870
- function parseArbiterDecisions(raw) {
44871
- const decisions = [];
44872
- const afterDecisions = raw.split(/\*\*Decisions\*\*/i)[1];
44873
- const block = afterDecisions?.split(/\n\*\*Adjudicated/i)[0] ?? "";
44874
- for (const line of block.split(/\r?\n/)) {
44875
- const m = line.match(
44876
- /^\s*-\s+`?\[([^\]]+)\]`?\s+(accept|dismiss|defer)\b(?:\s*[—-]\s*(.*\S))?\s*$/i
44877
- );
44878
- if (!m) continue;
44879
- const id = m[1] ?? "";
44880
- const decision = (m[2] ?? "accept").toLowerCase();
44881
- const reason = (m[3] ?? "").trim();
44882
- decisions.push({ id, decision, reason });
44883
- }
44884
- return { decisions };
44885
- }
44886
-
44887
- // src/consensus/arbiter.ts
44888
- function buildArbiterPrompt(issues) {
44889
- const list = issues.map((i) => `- [${i.id}] (${i.category}) ${i.text}`).join("\n");
44890
- return `Adjudicate each issue below.
44891
-
44892
- ISSUES:
44893
- ${list}`;
44894
- }
44895
- async function adjudicate(issues, deps) {
44896
- if (issues.length === 0) return { adjudicatedVerdict: "APPROVE", accepted: [], dismissed: [] };
44897
- const byId = new Map(issues.map((i) => [i.id, i]));
44898
- for (let attempt = 0; attempt < 2; attempt++) {
44899
- const res = await deps.runArbiter(buildArbiterPrompt(issues));
44900
- if (!res.ok) continue;
44901
- const { decisions } = parseArbiterDecisions(res.text);
44902
- const seen = new Set(decisions.map((d) => d.id));
44903
- const everyDecided = issues.every((i) => seen.has(i.id));
44904
- const missingReason = decisions.some((d) => d.decision !== "accept" && !d.reason);
44905
- if (!everyDecided || missingReason && attempt === 0) continue;
44906
- const accepted = [];
44907
- const dismissed = [];
44908
- for (const i of issues) {
44909
- const d = decisions.find((x) => x.id === i.id);
44910
- if (!d || d.decision !== "accept" && !d.reason) {
44911
- accepted.push(i);
44912
- continue;
44913
- }
44914
- if (d.decision === "accept") accepted.push(i);
44915
- else dismissed.push({ id: i.id, decision: d.decision, reason: d.reason });
44916
- }
44917
- return { adjudicatedVerdict: accepted.length === 0 ? "APPROVE" : "REQUEST_CHANGES", accepted, dismissed };
44918
- }
44919
- return { adjudicatedVerdict: "REQUEST_CHANGES", accepted: [...byId.values()], dismissed: [] };
44920
- }
44921
-
44922
- // src/consensus/semaphore.ts
44923
- function createBoundedSemaphore(slots) {
44924
- let available = slots;
44925
- const queue = [];
44926
- return {
44927
- acquire: () => available > 0 ? (available -= 1, Promise.resolve()) : new Promise((resolve) => queue.push(resolve)),
44928
- release: () => {
44929
- const next = queue.shift();
44930
- if (next) next();
44931
- else available += 1;
44932
- }
44933
- };
44934
- }
44935
-
44936
- // src/consensus/escalation.ts
44937
- function diagnoseStall(history, configuredRounds) {
44938
- const n = history.length;
44939
- const nonRespPerRound = history.map((h) => h.headOutcomes.filter((o) => !o.responded).length);
44940
- const infraRoundCount = nonRespPerRound.filter((c) => c >= 2).length;
44941
- const infraQualifies = infraRoundCount > n / 2;
44942
- if (infraQualifies) {
44943
- const roundIndices = [];
44944
- nonRespPerRound.forEach((c, i) => {
44945
- if (c >= 2) roundIndices.push(i + 1);
44946
- });
44947
- const evidence2 = `INFRA basis: \u22652 non\u2011responding heads in ${infraRoundCount} of ${n} rounds (rounds ${roundIndices.join(",")})`;
44948
- let grant = 3;
44949
- const headroom = 2 * configuredRounds - n;
44950
- grant = Math.max(1, Math.min(grant, headroom));
44951
- return { diagnosis: "INFRA", grantedRounds: grant, evidence: evidence2 };
44952
- }
44953
- if (n >= 2) {
44954
- const counts = history.map((h) => h.accepted.length);
44955
- const last = counts[n - 1];
44956
- const secondLast = counts[n - 2];
44957
- const lastFall = last < secondLast;
44958
- if (lastFall) {
44959
- let falls = 1;
44960
- if (n >= 3 && secondLast < counts[n - 3]) {
44961
- falls++;
44962
- }
44963
- let grant = 2 + falls;
44964
- if (grant > 5) grant = 5;
44965
- const headroom = 2 * configuredRounds - n;
44966
- grant = Math.max(1, Math.min(grant, headroom));
44967
- const seqDesc = counts.map((c, i) => `round${i + 1}:${c}`).join(" \u2192 ");
44968
- const evidence2 = `SUBSTANCE basis: accepted count fell (${seqDesc})`;
44969
- return { diagnosis: "SUBSTANCE", grantedRounds: grant, evidence: evidence2 };
44970
- }
44971
- }
44972
- const evidence = n === 0 ? "empty history" : `no infra or substance basis found in ${n} rounds`;
44973
- return { diagnosis: "AMBIGUITY", grantedRounds: 0, evidence };
44974
- }
44975
-
44976
- // src/consensus/loop.ts
44977
- async function runConsensus(input) {
44978
- const sem = createBoundedSemaphore(input.concurrency ?? input.heads.length);
44979
- const history = [];
44980
- let artifact = input.artifact;
44981
- let calls = 0;
44982
- const dismissedFwd = [];
44983
- let effectiveRounds = input.rounds;
44984
- let escalated = false;
44985
- let decision;
44986
- for (let round = 1; round <= effectiveRounds; round++) {
44987
- if (calls + input.heads.length > input.maxHeadCalls) {
44988
- return { outcome: "CONSENSUS_BLOCKED", rounds: round - 1, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
44989
- }
44990
- const dismissedBlock = dismissedFwd.length ? `PREVIOUSLY DISMISSED (do not re-raise unless new information):
44991
- ${dismissedFwd.slice(-20).join("\n")}` : "";
44992
- const results = await Promise.all(
44993
- input.heads.map(async (persona, idx) => {
44994
- await sem.acquire();
44995
- try {
44996
- calls++;
44997
- const res = await input.runHead(persona, artifact, dismissedBlock);
44998
- return { idx, res };
44999
- } catch (e) {
45000
- return { idx, res: { ok: false, error: String(e) } };
45001
- } finally {
45002
- sem.release();
45003
- }
45004
- })
45005
- );
45006
- const responding = results.filter(
45007
- (r) => r.res.ok
45008
- );
45009
- if (responding.length === 0) {
45010
- return { outcome: "CONSENSUS_UNAVAILABLE", rounds: round, finalArtifact: artifact, history };
45011
- }
45012
- const headOutcomes = input.heads.map((persona) => ({
45013
- persona,
45014
- responded: false
45015
- }));
45016
- const issues = [];
45017
- for (const { idx, res } of responding) {
45018
- const parsed = parseVerdict(res.text);
45019
- parsed.issues.forEach((iss, n) => issues.push({ ...iss, id: `r${round}-h${idx}-${n}` }));
45020
- const outcome = headOutcomes[idx];
45021
- if (outcome) {
45022
- outcome.responded = true;
45023
- outcome.verdict = parsed.verdict;
45024
- }
45025
- if (parsed.verdict !== "APPROVE" && parsed.issues.length === 0 && parsed.parseFallbacks.length === 0) {
45026
- issues.push({
45027
- category: "ambiguity",
45028
- text: `head ${input.heads[idx] ?? idx}/${idx} returned ${parsed.verdict} but no parseable critical issues (possible malformed/blocked review)`,
45029
- id: `r${round}-h${idx}-0`
45030
- });
45031
- }
45032
- }
45033
- let adj;
45034
- if (issues.length > 0) {
45035
- if (calls + 1 > input.maxHeadCalls) {
45036
- return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
45037
- }
45038
- calls++;
45039
- try {
45040
- adj = await adjudicate(issues, { runArbiter: input.runArbiter });
45041
- } catch {
45042
- adj = { adjudicatedVerdict: "REQUEST_CHANGES", accepted: issues, dismissed: [] };
45043
- }
45044
- } else {
45045
- adj = { adjudicatedVerdict: "APPROVE", accepted: [], dismissed: [] };
45046
- }
45047
- history.push({ round, accepted: adj.accepted, dismissed: adj.dismissed, headOutcomes });
45048
- adj.dismissed.forEach((d) => dismissedFwd.push(`- ${d.id}: ${d.reason}`));
45049
- if (adj.accepted.length === 0) {
45050
- return { outcome: "CONVERGED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
45051
- }
45052
- if (round === effectiveRounds) {
45053
- if (!decision) {
45054
- decision = diagnoseStall(history, input.rounds);
45055
- }
45056
- if (decision.grantedRounds > 0 && !escalated) {
45057
- escalated = true;
45058
- effectiveRounds = Math.min(effectiveRounds + decision.grantedRounds, input.rounds * 2);
45059
- } else {
45060
- return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
45061
- }
45062
- }
45063
- artifact = await input.revise(artifact, adj.accepted);
45064
- }
45065
- return { outcome: "CONSENSUS_BLOCKED", rounds: effectiveRounds, finalArtifact: artifact, history };
45066
- }
45067
-
45068
45652
  // src/consensus/deliverable-gate.ts
45069
45653
  async function runDeliverableGate(_target, deps) {
45070
45654
  if (!await deps.consensusEnabled()) return { outcome: "SKIPPED" };
@@ -50071,12 +50655,13 @@ var DEFAULT_DIRECTOR_BRIDGE_ALLOWED_ORIGINS = [
50071
50655
  "http://localhost:*",
50072
50656
  "http://127.0.0.1:*",
50073
50657
  "http://[::1]:*",
50074
- // Staging Vercel team migrated 2026-07-28 (see docs/reference/deployments.md). The prior
50075
- // fnnzzz-fnnzzzs-projects web alias was dropped once no operator was left on an old build.
50076
- // mrrlin2 hit usage limits and was replaced by mrrlin3 on 2026-08-11; the mrrlin2 web alias
50077
- // was dropped the same day since no operator was left on that build either.
50078
- "https://mrrlin-web-mrrlin3.vercel.app",
50079
- "https://app.mrrlin.dev",
50658
+ // Staging decoupled onto the stable custom domain staging.mrrlin.com on 2026-08-14 (Cloudflare DNS
50659
+ // in front of the mrrlin3 Vercel project). The Vercel-owned web alias (mrrlin-web-mrrlin3.vercel.app)
50660
+ // and the old app.mrrlin.dev origin are dropped from the defaults: the browser now reaches the
50661
+ // bridge from the owned domain, which is stable across future Vercel project/team moves.
50662
+ // See docs/reference/deployments.md.
50663
+ "https://staging.mrrlin.com",
50664
+ // Production web (unchanged).
50080
50665
  "https://mrrlin-web.vercel.app"
50081
50666
  ];
50082
50667
  function normalizeReasoningEffort(value) {
@@ -56231,249 +56816,6 @@ async function searchKnowledge(query, options = {}) {
56231
56816
  return searchKnowledgeIndex(index, query, options.limit ?? 5);
56232
56817
  }
56233
56818
 
56234
- // src/consensus/spec-identity.ts
56235
- function validateSpecIdentity(markdown, taskId) {
56236
- const target = taskId.toUpperCase();
56237
- const fieldToken = frontmatterTaskToken(markdown);
56238
- if (fieldToken !== null && fieldToken.toUpperCase() !== target) return false;
56239
- const headingToken = firstHeadingToken(markdown);
56240
- if (headingToken !== null && headingToken.toUpperCase() !== target) return false;
56241
- return true;
56242
- }
56243
- var TASK_ID_TOKEN = /\b[A-Za-z]{1,12}-\d{1,10}\b/;
56244
- var ATX_HEADING = /^ {0,3}#{1,6}\s+(.*)$/;
56245
- var FRONTMATTER_TASK = /^task(?:_id)?:\s*(.+)$/i;
56246
- function frontmatterTaskToken(markdown) {
56247
- const lines2 = markdown.split("\n");
56248
- const firstContent = lines2.findIndex((l) => l.trim() !== "");
56249
- if (firstContent === -1 || lines2[firstContent]?.trim() !== "---") return null;
56250
- for (let i = firstContent + 1; i < lines2.length; i++) {
56251
- const trimmed = (lines2[i] ?? "").trim();
56252
- if (trimmed === "---") break;
56253
- const m = FRONTMATTER_TASK.exec(trimmed);
56254
- if (m) return firstTaskIdToken(m[1] ?? "");
56255
- }
56256
- return null;
56257
- }
56258
- function firstHeadingToken(markdown) {
56259
- const heading = firstHeading(markdown);
56260
- return heading === null ? null : firstTaskIdToken(heading);
56261
- }
56262
- function firstHeading(markdown) {
56263
- const lines2 = markdown.split("\n");
56264
- let i = 0;
56265
- const firstContent = lines2.findIndex((l) => l.trim() !== "");
56266
- if (firstContent !== -1 && lines2[firstContent]?.trim() === "---") {
56267
- i = firstContent + 1;
56268
- while (i < lines2.length && lines2[i]?.trim() !== "---") i++;
56269
- i++;
56270
- }
56271
- let inFence = false;
56272
- let inComment = false;
56273
- for (; i < lines2.length; i++) {
56274
- const line = lines2[i] ?? "";
56275
- const trimmed = line.trim();
56276
- if (inComment) {
56277
- if (trimmed.includes("-->")) inComment = false;
56278
- continue;
56279
- }
56280
- if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
56281
- inFence = !inFence;
56282
- continue;
56283
- }
56284
- if (inFence) continue;
56285
- if (trimmed.startsWith("<!--")) {
56286
- if (!trimmed.includes("-->")) inComment = true;
56287
- continue;
56288
- }
56289
- const m = ATX_HEADING.exec(line);
56290
- if (m) return m[1] ?? "";
56291
- }
56292
- return null;
56293
- }
56294
- function firstTaskIdToken(text) {
56295
- const m = TASK_ID_TOKEN.exec(text);
56296
- return m ? m[0] : null;
56297
- }
56298
-
56299
- // src/consensus/spec-gate.ts
56300
- function mapAcceptedIssueToClarification(issue2) {
56301
- const text = issue2.text.toLowerCase();
56302
- let kind;
56303
- if (issue2.category === "scope") {
56304
- const directionCues = ["only", "or also", "narrower", "broader", "include", "exclude", "versus", "vs"];
56305
- kind = directionCues.some((cue) => text.includes(cue)) ? "scope_direction" : "missing_requirement";
56306
- } else {
56307
- const acceptanceCues = ["acceptance", "done", "success", "criteria", "satisfy"];
56308
- kind = acceptanceCues.some((cue) => text.includes(cue)) ? "acceptance_criteria_detail" : "default_choice";
56309
- }
56310
- return {
56311
- kind,
56312
- diagnostic: false,
56313
- rawQuestion: issue2.text,
56314
- whyBlocking: `reviewer raised a ${issue2.category} issue`,
56315
- category: issue2.category
56316
- };
56317
- }
56318
- var CLARIFICATION_CAP_BY_IMPACT = { low: 2, medium: 3, high: 5 };
56319
- function resolveClarificationCap(impact) {
56320
- const tier = impact?.trim().toLowerCase();
56321
- if (tier === "medium") return CLARIFICATION_CAP_BY_IMPACT.medium;
56322
- if (tier === "high") return CLARIFICATION_CAP_BY_IMPACT.high;
56323
- return CLARIFICATION_CAP_BY_IMPACT.low;
56324
- }
56325
- function headIndexOf(id) {
56326
- const match = /^r\d+-h(\d+)-\d+$/.exec(id);
56327
- return match?.[1] !== void 0 ? Number(match[1]) : null;
56328
- }
56329
- function orderClarificationIssues(issues) {
56330
- const distinctHeadsByKey = /* @__PURE__ */ new Map();
56331
- for (const issue2 of issues) {
56332
- const key = `${issue2.category} ${issue2.text}`;
56333
- const heads = distinctHeadsByKey.get(key) ?? /* @__PURE__ */ new Set();
56334
- const headIdx = headIndexOf(issue2.id);
56335
- if (headIdx !== null) heads.add(headIdx);
56336
- distinctHeadsByKey.set(key, heads);
56337
- }
56338
- const categoryRank = (category) => category === "scope" ? 0 : category === "ambiguity" ? 1 : 2;
56339
- return issues.map((issue2, index) => ({ issue: issue2, index })).sort((a, b) => {
56340
- const rankDiff = categoryRank(a.issue.category) - categoryRank(b.issue.category);
56341
- if (rankDiff !== 0) return rankDiff;
56342
- const headCountA = distinctHeadsByKey.get(`${a.issue.category} ${a.issue.text}`)?.size ?? 0;
56343
- const headCountB = distinctHeadsByKey.get(`${b.issue.category} ${b.issue.text}`)?.size ?? 0;
56344
- if (headCountA !== headCountB) return headCountB - headCountA;
56345
- return a.index - b.index;
56346
- }).map(({ issue: issue2 }) => issue2);
56347
- }
56348
- function applyClarificationCap(issues, impact) {
56349
- const ordered = orderClarificationIssues(issues);
56350
- const cap = resolveClarificationCap(impact);
56351
- return {
56352
- kept: ordered.slice(0, cap).map(mapAcceptedIssueToClarification),
56353
- dropped: ordered.slice(cap).map(mapAcceptedIssueToClarification)
56354
- };
56355
- }
56356
- async function runSpecGate(_target, deps, opts) {
56357
- if (!await deps.consensusEnabled()) return { outcome: "SKIPPED" };
56358
- const intent = await deps.readIntent();
56359
- if (intent.taskId !== null && (intent.seed === null || intent.seed.trim() === "")) {
56360
- return {
56361
- outcome: "CONSENSUS_BLOCKED",
56362
- operatorClarifications: [
56363
- {
56364
- kind: "missing_requirement",
56365
- diagnostic: false,
56366
- rawQuestion: "What are the requirements for this task? There is no operator request on record, so the spec cannot be validated against your intent \u2014 please state what this task must do.",
56367
- whyBlocking: "operator_seed_request is empty",
56368
- category: "scope"
56369
- }
56370
- ]
56371
- };
56372
- }
56373
- const baseHeads = resolveHeads(intent.domain, intent.impact);
56374
- const extraHeads = opts?.extraHeads ?? [];
56375
- const heads = [...baseHeads, ...extraHeads.filter((h) => !baseHeads.includes(h))];
56376
- const spec = await deps.readSpec();
56377
- if (parseAcceptanceCriteria(spec.markdownBody) === null) {
56378
- return {
56379
- outcome: "CONSENSUS_BLOCKED",
56380
- operatorClarifications: [
56381
- {
56382
- kind: "acceptance_criteria_detail",
56383
- diagnostic: false,
56384
- rawQuestion: "This spec has no machine-usable acceptance criteria. Add an `## Acceptance criteria` section written as a markdown checklist (`- [ ] \u2026` items) describing exactly what the finished deliverable must satisfy \u2014 the review of the produced work is scored against it.",
56385
- whyBlocking: "non-code spec is missing a parseable `## Acceptance criteria` checklist",
56386
- category: "scope"
56387
- }
56388
- ]
56389
- };
56390
- }
56391
- let currentHash = spec.contentHash;
56392
- let currentMarkdown = spec.markdownBody;
56393
- let conflicted = false;
56394
- const result = await runConsensus({
56395
- artifact: spec.markdownBody,
56396
- heads,
56397
- rounds: deps.rounds,
56398
- maxHeadCalls: deps.maxHeadCalls,
56399
- runHead: deps.runHead,
56400
- runArbiter: deps.runArbiter,
56401
- revise: async (artifact, accepted) => {
56402
- const revised = await deps.runReviser(artifact, accepted);
56403
- if (!revised.ok) return artifact;
56404
- if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
56405
- process.stderr.write(
56406
- `[spec-gate] discarding drifted revision for ${spec.pageId}: heading does not identify ${intent.taskId}
56407
- `
56408
- );
56409
- return artifact;
56410
- }
56411
- const cas = await deps.casUpdateSpec(revised.text, currentHash);
56412
- if (!cas.ok) {
56413
- conflicted = true;
56414
- throw new Error("SPEC_CHANGED_DURING_REVIEW");
56415
- }
56416
- currentHash = cas.contentHash;
56417
- currentMarkdown = revised.text;
56418
- return revised.text;
56419
- }
56420
- // Only OUR conflict throw is swallowed (→ null). Anything else is a real
56421
- // error and must propagate so it is not silently masked as a conflict.
56422
- }).catch((e) => conflicted ? null : Promise.reject(e));
56423
- if (conflicted || result === null) {
56424
- await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
56425
- return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
56426
- }
56427
- if (result.outcome === "CONVERGED" && intent.taskId !== null && !validateSpecIdentity(currentMarkdown, intent.taskId)) {
56428
- process.stderr.write(
56429
- `[spec-gate] blocking promotion for ${spec.pageId}: converged spec heading does not identify ${intent.taskId}
56430
- `
56431
- );
56432
- return {
56433
- outcome: "CONSENSUS_BLOCKED",
56434
- diagnostics: [
56435
- {
56436
- kind: "spec_identity_mismatch",
56437
- diagnostic: true,
56438
- rawQuestion: `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.`,
56439
- whyBlocking: "converged spec content belongs to a different task than the one it gates",
56440
- category: "scope"
56441
- }
56442
- ]
56443
- };
56444
- }
56445
- let keptClarifications = [];
56446
- let droppedByCap = [];
56447
- if (result.outcome === "CONSENSUS_BLOCKED") {
56448
- const accepted = result.history.at(-1)?.accepted ?? [];
56449
- const materialIssues = accepted.filter((issue2) => issue2.category === "ambiguity" || issue2.category === "scope");
56450
- const capped = applyClarificationCap(materialIssues, intent.impact);
56451
- keptClarifications = capped.kept;
56452
- droppedByCap = capped.dropped;
56453
- }
56454
- await deps.writeEvidence({
56455
- specPageId: spec.pageId,
56456
- outcome: result.outcome,
56457
- rounds: result.rounds,
56458
- history: result.history,
56459
- contentHash: currentHash,
56460
- ...droppedByCap.length > 0 ? { droppedByCap } : {}
56461
- });
56462
- if (result.outcome === "CONVERGED") {
56463
- await deps.promoteSpecReady(currentHash);
56464
- return { outcome: "CONVERGED" };
56465
- }
56466
- if (result.outcome === "CONSENSUS_BLOCKED") {
56467
- return {
56468
- outcome: "CONSENSUS_BLOCKED",
56469
- ...keptClarifications.length > 0 ? { operatorClarifications: keptClarifications } : {},
56470
- ...droppedByCap.length > 0 ? { droppedByCap } : {},
56471
- ...result.escalation ? { escalation: result.escalation } : {}
56472
- };
56473
- }
56474
- return { outcome: result.outcome };
56475
- }
56476
-
56477
56819
  // src/consensus/code-gate.ts
56478
56820
  var CODE_HEADS = ["code-reviewer"];
56479
56821
  var CodeGateUnfixableError = class extends Error {
@@ -56930,13 +57272,29 @@ var HUMANIZE_TEMPLATE_BY_KIND = {
56930
57272
  acceptance_criteria_detail: "The direction is clear, but the spec still needs a sharper success check. What should count as done?"
56931
57273
  };
56932
57274
  var HUMANIZE_CONTEXT_CAP = 200;
57275
+ function truncateToSentenceBoundary(text, cap) {
57276
+ if (text.length <= cap) return text;
57277
+ const sentences = text.match(/.+?[.!?](?=\s|$)/g);
57278
+ if (sentences) {
57279
+ let kept = "";
57280
+ let keptCount = 0;
57281
+ for (const sentence of sentences) {
57282
+ const candidate = kept ? `${kept} ${sentence.trim()}` : sentence.trim();
57283
+ if (candidate.length > cap) break;
57284
+ kept = candidate;
57285
+ keptCount++;
57286
+ }
57287
+ if (kept) return keptCount < sentences.length ? `${kept} \u2026` : kept;
57288
+ }
57289
+ const cutIdx = text.lastIndexOf(" ", cap);
57290
+ return text.slice(0, cutIdx > 0 ? cutIdx : cap).trim() + "\u2026";
57291
+ }
56933
57292
  function humanizeClarificationBody(clarification) {
56934
57293
  const prefix = HUMANIZE_TEMPLATE_BY_KIND[clarification.kind];
56935
- let context = clarification.rawQuestion.replace(/\b(?:parseable|SPEC_IDENTITY_MISMATCH|machine-usable)\b/gi, "").replace(/\s+/g, " ").trim();
56936
- if (context.length > HUMANIZE_CONTEXT_CAP) {
56937
- const cutIdx = context.lastIndexOf(" ", HUMANIZE_CONTEXT_CAP);
56938
- context = context.slice(0, cutIdx > 0 ? cutIdx : HUMANIZE_CONTEXT_CAP).trim() + "\u2026";
56939
- }
57294
+ const context = truncateToSentenceBoundary(
57295
+ clarification.rawQuestion.replace(/\b(?:parseable|SPEC_IDENTITY_MISMATCH|machine-usable)\b/gi, "").replace(/\s+/g, " ").trim(),
57296
+ HUMANIZE_CONTEXT_CAP
57297
+ );
56940
57298
  return context ? `${prefix} ${context}` : prefix;
56941
57299
  }
56942
57300
  var defaultNativeSpecGateBuilders = { buildSpecGateDeps, runSpecGate };
@@ -56962,7 +57320,7 @@ async function cachedResult(client, input) {
56962
57320
  const review = await client.getSpecConsensusReview(
56963
57321
  input.projectSlug,
56964
57322
  input.pageId,
56965
- domainScopedReviewHash(page.contentHash, domain2)
57323
+ domainScopedReviewHash2(page.contentHash, domain2)
56966
57324
  );
56967
57325
  if (review?.outcome === "CONVERGED") {
56968
57326
  const gatedTaskId = await resolveGatedTaskId(client, input.projectSlug, {
@@ -57075,7 +57433,7 @@ async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57075
57433
  ...suppressedAsAdjudicated.length ? { suppressedAsAdjudicated } : {}
57076
57434
  } : void 0;
57077
57435
  await ctx.client.recordSpecConsensusReview(input.projectSlug, input.pageId, {
57078
- contentHash: domainScopedReviewHash(page.contentHash, domain2),
57436
+ contentHash: domainScopedReviewHash2(page.contentHash, domain2),
57079
57437
  outcome: "CONSENSUS_BLOCKED_CLARIFICATION",
57080
57438
  rounds: 0,
57081
57439
  ...suppressionEvidence ? { suppressionEvidence } : {}
@@ -57092,7 +57450,12 @@ async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57092
57450
  if (filedQuestionCount > 0) {
57093
57451
  error51 = `Consensus blocked on ${filedQuestionCount} clarification(s); filed as operator question(s).`;
57094
57452
  } else if (diagnostics.length > 0 && operatorClarifications.length === 0) {
57095
- error51 = "Consensus blocked: the spec content does not match its linked task. This requires fixing the spec/task relationship directly, not an inbox answer.";
57453
+ const diagnosticKinds = new Set(diagnostics.map((d) => d.kind));
57454
+ if (diagnosticKinds.has("acceptance_criteria_unauthorable")) {
57455
+ error51 = "Consensus blocked: the spec has no machine-usable acceptance criteria and the reviser could not add them automatically. A human needs to write the `## Acceptance criteria` section directly, not an inbox answer.";
57456
+ } else {
57457
+ error51 = "Consensus blocked: the spec content does not match its linked task. This requires fixing the spec/task relationship directly, not an inbox answer.";
57458
+ }
57096
57459
  } else if (operatorClarifications.length > 0 && toFile.length === 0 && suppressedAsAdjudicated.length > 0) {
57097
57460
  error51 = `Consensus blocked, but all ${suppressedAsAdjudicated.length} clarification(s) were reworded repeats of already-adjudicated questions \u2014 nothing new filed.`;
57098
57461
  } else if (operatorClarifications.length > 0) {
@@ -57111,8 +57474,41 @@ async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57111
57474
  ...resolvedTaskId ? { taskId: resolvedTaskId } : {}
57112
57475
  };
57113
57476
  }
57114
- async function run(ctx, input) {
57115
- const result = await runNativeSpecGate(ctx, input);
57477
+ function shouldRetireGateQuestions(outcome) {
57478
+ return outcome === "CONVERGED";
57479
+ }
57480
+ async function retireGateQuestionsOnConverge(ctx, input, resolvedTaskId) {
57481
+ if (resolvedTaskId === null) return;
57482
+ try {
57483
+ const page = await ctx.client.getWikiPage(input.projectSlug, input.pageId);
57484
+ const contentHash = page?.contentHash ?? null;
57485
+ const inboxItems = await ctx.client.listInboxItems(input.projectSlug, { taskId: resolvedTaskId });
57486
+ for (const item of inboxItems) {
57487
+ if (item.origin !== "gate" || item.status !== "pending" || item.kind !== "question" || item.taskId != null && item.taskId !== resolvedTaskId) {
57488
+ continue;
57489
+ }
57490
+ try {
57491
+ await ctx.client.decideInboxItem(input.projectSlug, item.id, {
57492
+ verdict: "rejected",
57493
+ decidedBy: "gate",
57494
+ reason: `Superseded by converged spec Specs/${input.pageId} (contentHash: ${contentHash ?? "unknown"})`
57495
+ });
57496
+ } catch (itemError) {
57497
+ process.stderr.write(
57498
+ `[mrrlin-mcp consensus] gate-question retirement failed for ${input.projectSlug}/${input.pageId} item ${item.id}: ${itemError instanceof Error ? itemError.message : String(itemError)}
57499
+ `
57500
+ );
57501
+ }
57502
+ }
57503
+ } catch (error51) {
57504
+ process.stderr.write(
57505
+ `[mrrlin-mcp consensus] gate-question retirement failed for ${input.projectSlug}/${input.pageId}: ${error51 instanceof Error ? error51.message : String(error51)}
57506
+ `
57507
+ );
57508
+ }
57509
+ }
57510
+ async function run(ctx, input, builders = defaultNativeSpecGateBuilders) {
57511
+ const result = await runNativeSpecGate(ctx, input, builders);
57116
57512
  if (process.env[SHADOW_ENV_FLAG]) {
57117
57513
  try {
57118
57514
  const specPage = await ctx.client.getWikiPage(input.projectSlug, input.pageId);
@@ -57133,22 +57529,40 @@ async function run(ctx, input) {
57133
57529
  }
57134
57530
  }
57135
57531
  let resolvedTaskId = null;
57532
+ let resolvedTaskTitle = null;
57136
57533
  try {
57137
57534
  const page = await ctx.client.getWikiPage(input.projectSlug, input.pageId);
57138
57535
  resolvedTaskId = page?.taskId ?? null;
57139
57536
  } catch {
57140
57537
  }
57538
+ const resolveTitle = async () => {
57539
+ if (!resolvedTaskId) return;
57540
+ try {
57541
+ resolvedTaskTitle = (await ctx.client.getTask(input.projectSlug, resolvedTaskId)).title;
57542
+ } catch {
57543
+ }
57544
+ };
57141
57545
  if (result.outcome === "CONSENSUS_BLOCKED") {
57142
57546
  return handleConsensusBlocked(result, ctx, input, resolvedTaskId);
57143
57547
  }
57548
+ if (shouldRetireGateQuestions(result.outcome)) {
57549
+ await retireGateQuestionsOnConverge(ctx, input, resolvedTaskId);
57550
+ }
57144
57551
  switch (result.outcome) {
57145
57552
  case "CONVERGED":
57146
57553
  case "SKIPPED":
57147
- return { kind: "done", resultJson: JSON.stringify(result), finalReviewedHash: null };
57554
+ await resolveTitle();
57555
+ return {
57556
+ kind: "done",
57557
+ resultJson: JSON.stringify(result),
57558
+ finalReviewedHash: null,
57559
+ ...resolvedTaskId ? { taskId: resolvedTaskId } : {},
57560
+ ...resolvedTaskTitle ? { taskTitle: resolvedTaskTitle } : {}
57561
+ };
57148
57562
  case "CONSENSUS_UNAVAILABLE":
57149
- return { kind: "retry", failureReason: "provider_unavailable", error: "Consensus unavailable (provider/quota)." };
57563
+ return { kind: "retry", failureReason: "provider_unavailable", error: "Consensus unavailable (provider/quota).", ...resolvedTaskId ? { taskId: resolvedTaskId } : {} };
57150
57564
  case "SPEC_CHANGED_DURING_REVIEW":
57151
- return { kind: "retry", failureReason: "spec_changed", error: "Spec changed during review." };
57565
+ return { kind: "retry", failureReason: "spec_changed", error: "Spec changed during review.", ...resolvedTaskId ? { taskId: resolvedTaskId } : {} };
57152
57566
  default:
57153
57567
  return {
57154
57568
  kind: "failed",
@@ -57159,7 +57573,8 @@ async function run(ctx, input) {
57159
57573
  }
57160
57574
  }
57161
57575
  function onComplete2(outcome, input) {
57162
- const taskId = outcome.kind === "failed" ? outcome.taskId : void 0;
57576
+ const taskId = "taskId" in outcome ? outcome.taskId : void 0;
57577
+ const taskRef = taskId ? outcome.kind === "done" && outcome.taskTitle ? `${taskId} \u2014 ${outcome.taskTitle}` : taskId : null;
57163
57578
  if (outcome.kind === "done") {
57164
57579
  let specOutcome = "CONVERGED";
57165
57580
  try {
@@ -57169,12 +57584,14 @@ function onComplete2(outcome, input) {
57169
57584
  if (specOutcome === "SKIPPED") {
57170
57585
  return {
57171
57586
  kind: "info",
57172
- body: `Consensus is disabled for this project; Specs/${input.pageId} was not gated and spec_ready is unchanged.`
57587
+ ...taskId ? { taskId } : {},
57588
+ body: taskRef ? `Consensus is disabled for this project; the spec for ${taskRef} (Specs/${input.pageId}) was not gated and spec_ready is unchanged.` : `Consensus is disabled for this project; Specs/${input.pageId} was not gated and spec_ready is unchanged.`
57173
57589
  };
57174
57590
  }
57175
57591
  return {
57176
57592
  kind: "info",
57177
- body: `Consensus CONVERGED for Specs/${input.pageId}; spec_ready set. Ready to create an execution run.`
57593
+ ...taskId ? { taskId } : {},
57594
+ body: taskRef ? `Consensus CONVERGED for ${taskRef} (spec Specs/${input.pageId}); spec_ready set. Ready to create an execution run.` : `Consensus CONVERGED for Specs/${input.pageId}; spec_ready set. Ready to create an execution run.`
57178
57595
  };
57179
57596
  }
57180
57597
  if (outcome.kind === "failed" && (outcome.filedQuestionCount ?? 0) > 0) {
@@ -57183,14 +57600,14 @@ function onComplete2(outcome, input) {
57183
57600
  kind: "info",
57184
57601
  suppressInbox: true,
57185
57602
  ...taskId ? { taskId } : {},
57186
- body: `Consensus blocked on Specs/${input.pageId}; filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.`
57603
+ body: taskRef ? `Consensus blocked on ${taskRef} (Specs/${input.pageId}); filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.` : `Consensus blocked on Specs/${input.pageId}; filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.`
57187
57604
  };
57188
57605
  }
57189
- const taskRef = taskId ? ` (${taskId})` : "";
57606
+ const handoffTaskRef = taskRef ? ` (${taskRef})` : "";
57190
57607
  return {
57191
57608
  kind: "handoff",
57192
57609
  ...taskId ? { taskId } : {},
57193
- body: `The spec review stopped for Specs/${input.pageId}${taskRef}. ${outcome.error} Review the item, consult the Director if needed, then continue in Codex or handle it manually.`
57610
+ body: `The spec review stopped for Specs/${input.pageId}${handoffTaskRef}. ${outcome.error} Review the item, consult the Director if needed, then continue in Codex or handle it manually.`
57194
57611
  };
57195
57612
  }
57196
57613
  var consensusDescriptor = {
@@ -57214,7 +57631,7 @@ async function cachedResult2(client, input) {
57214
57631
  const review = await client.getSpecConsensusReview(
57215
57632
  input.projectSlug,
57216
57633
  input.pageId,
57217
- domainScopedReviewHash(page.contentHash, domain2)
57634
+ domainScopedReviewHash2(page.contentHash, domain2)
57218
57635
  );
57219
57636
  if (review?.outcome === "CONVERGED") {
57220
57637
  return { status: "already_converged", payload: { outcome: "CONVERGED", contentHash: page.contentHash } };
@@ -57224,6 +57641,22 @@ async function cachedResult2(client, input) {
57224
57641
  }
57225
57642
  return null;
57226
57643
  }
57644
+ async function resolveTaskScope(ctx, input, { withTitle }) {
57645
+ let taskId = null;
57646
+ let taskTitle = null;
57647
+ try {
57648
+ const page = await ctx.client.getWikiPage(input.projectSlug, input.pageId);
57649
+ taskId = page?.taskId ?? null;
57650
+ } catch {
57651
+ }
57652
+ if (taskId && withTitle) {
57653
+ try {
57654
+ taskTitle = (await ctx.client.getTask(input.projectSlug, taskId)).title;
57655
+ } catch {
57656
+ }
57657
+ }
57658
+ return { taskId, taskTitle };
57659
+ }
57227
57660
  async function run2(ctx, input) {
57228
57661
  const deps = await buildDeliverableGateDeps({
57229
57662
  client: ctx.client,
@@ -57237,7 +57670,14 @@ async function run2(ctx, input) {
57237
57670
  deps
57238
57671
  );
57239
57672
  if (result.outcome === "CONVERGED" || result.outcome === "SKIPPED") {
57240
- return { kind: "done", resultJson: JSON.stringify(result), finalReviewedHash: null };
57673
+ const { taskId: taskId2, taskTitle } = await resolveTaskScope(ctx, input, { withTitle: true });
57674
+ return {
57675
+ kind: "done",
57676
+ resultJson: JSON.stringify(result),
57677
+ finalReviewedHash: null,
57678
+ ...taskId2 ? { taskId: taskId2 } : {},
57679
+ ...taskTitle ? { taskTitle } : {}
57680
+ };
57241
57681
  }
57242
57682
  const mapped = mapDeliverableGateResult(result);
57243
57683
  if (mapped.action === "retry") {
@@ -57247,10 +57687,9 @@ async function run2(ctx, input) {
57247
57687
  error: mapped.reason
57248
57688
  };
57249
57689
  }
57690
+ const { taskId } = await resolveTaskScope(ctx, input, { withTitle: false });
57250
57691
  let filedQuestionCount = 0;
57251
57692
  if (mapped.action === "questions") {
57252
- const page = await ctx.client.getWikiPage(input.projectSlug, input.pageId);
57253
- const taskId = page?.taskId ?? null;
57254
57693
  if (taskId) {
57255
57694
  filedQuestionCount = await fileDeliverableGateQuestions(ctx.client, input.projectSlug, taskId, mapped.clarifications);
57256
57695
  }
@@ -57260,10 +57699,13 @@ async function run2(ctx, input) {
57260
57699
  kind: "failed",
57261
57700
  failureReason: "consensus_blocked",
57262
57701
  error: error51,
57263
- filedQuestionCount
57702
+ filedQuestionCount,
57703
+ ...taskId ? { taskId } : {}
57264
57704
  };
57265
57705
  }
57266
57706
  function onComplete3(outcome, input) {
57707
+ const taskId = outcome.kind === "failed" || outcome.kind === "done" ? outcome.taskId : void 0;
57708
+ const taskRef = taskId ? outcome.kind === "done" && outcome.taskTitle ? `${taskId} \u2014 ${outcome.taskTitle}` : taskId : null;
57267
57709
  if (outcome.kind === "done") {
57268
57710
  let specOutcome = "CONVERGED";
57269
57711
  try {
@@ -57273,12 +57715,14 @@ function onComplete3(outcome, input) {
57273
57715
  if (specOutcome === "SKIPPED") {
57274
57716
  return {
57275
57717
  kind: "info",
57276
- body: `Consensus is disabled for this project; Deliverables/${input.pageId} was not gated.`
57718
+ ...taskId ? { taskId } : {},
57719
+ body: taskRef ? `Consensus is disabled for this project; the deliverable for ${taskRef} (Deliverables/${input.pageId}) was not gated.` : `Consensus is disabled for this project; Deliverables/${input.pageId} was not gated.`
57277
57720
  };
57278
57721
  }
57279
57722
  return {
57280
57723
  kind: "info",
57281
- body: `Deliverable consensus CONVERGED for Deliverables/${input.pageId}; it meets the acceptance criteria.`
57724
+ ...taskId ? { taskId } : {},
57725
+ body: taskRef ? `Deliverable consensus CONVERGED for ${taskRef} (Deliverables/${input.pageId}); it meets the acceptance criteria.` : `Deliverable consensus CONVERGED for Deliverables/${input.pageId}; it meets the acceptance criteria.`
57282
57726
  };
57283
57727
  }
57284
57728
  if (outcome.kind === "failed" && (outcome.filedQuestionCount ?? 0) > 0) {
@@ -57286,12 +57730,14 @@ function onComplete3(outcome, input) {
57286
57730
  return {
57287
57731
  kind: "info",
57288
57732
  suppressInbox: true,
57289
- body: `Deliverable consensus blocked on Deliverables/${input.pageId}; filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.`
57733
+ ...taskId ? { taskId } : {},
57734
+ body: taskRef ? `Deliverable consensus blocked on ${taskRef} (Deliverables/${input.pageId}); filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.` : `Deliverable consensus blocked on Deliverables/${input.pageId}; filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.`
57290
57735
  };
57291
57736
  }
57292
57737
  return {
57293
57738
  kind: "handoff",
57294
- body: `Deliverable consensus could not complete for Deliverables/${input.pageId}: ${outcome.error} Needs operator attention.`
57739
+ ...taskId ? { taskId } : {},
57740
+ body: taskRef ? `Deliverable consensus could not complete for ${taskRef} (Deliverables/${input.pageId}): ${outcome.error} Needs operator attention.` : `Deliverable consensus could not complete for Deliverables/${input.pageId}: ${outcome.error} Needs operator attention.`
57295
57741
  };
57296
57742
  }
57297
57743
  var deliverableConsensusDescriptor = {
@@ -58654,7 +59100,7 @@ function createMrrlinTools(options) {
58654
59100
  const review = await c.getSpecConsensusReview(
58655
59101
  projectSlug,
58656
59102
  pageId,
58657
- domainScopedReviewHash(page.contentHash, reviewDomain)
59103
+ domainScopedReviewHash2(page.contentHash, reviewDomain)
58658
59104
  );
58659
59105
  if (review && review.outcome === "CONVERGED") {
58660
59106
  try {