@martinloop/mcp 0.3.9 → 0.5.1

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 (113) hide show
  1. package/README.md +82 -141
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +1 -1
  4. package/dist/resources.d.ts +1 -2
  5. package/dist/resources.js +62 -98
  6. package/dist/server-validation.d.ts +1 -1
  7. package/dist/server-validation.js +16 -11
  8. package/dist/server.d.ts +23 -0
  9. package/dist/server.js +192 -15
  10. package/dist/tools/doctor.js +5 -4
  11. package/dist/tools/get-status.d.ts +2 -0
  12. package/dist/tools/get-status.js +1 -0
  13. package/dist/tools/preflight.d.ts +2 -0
  14. package/dist/tools/preflight.js +43 -3
  15. package/dist/tools/run-dossier.d.ts +3 -1
  16. package/dist/tools/run-dossier.js +40 -2
  17. package/dist/tools/run-loop.d.ts +5 -3
  18. package/dist/tools/run-loop.js +15 -9
  19. package/dist/tools/tool-errors.js +1 -1
  20. package/dist/tools/tool-response.js +9 -2
  21. package/dist/tools/tool-support.d.ts +4 -2
  22. package/dist/tools/tool-support.js +6 -15
  23. package/dist/vendor/adapters/claude-cli.d.ts +2 -2
  24. package/dist/vendor/adapters/claude-cli.js +97 -69
  25. package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
  26. package/dist/vendor/adapters/cli-bridge.js +54 -13
  27. package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
  28. package/dist/vendor/adapters/codex-launcher.js +118 -19
  29. package/dist/vendor/adapters/index.d.ts +2 -4
  30. package/dist/vendor/adapters/index.js +2 -4
  31. package/dist/vendor/adapters/openai-compatible.d.ts +2 -3
  32. package/dist/vendor/adapters/openai-compatible.js +80 -15
  33. package/dist/vendor/adapters/verifier-only.js +9 -4
  34. package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
  35. package/dist/vendor/contracts/context-handoff.d.ts +100 -0
  36. package/dist/vendor/contracts/context-handoff.js +11 -0
  37. package/dist/vendor/contracts/context-manifest.d.ts +195 -0
  38. package/dist/vendor/contracts/context-manifest.js +17 -0
  39. package/dist/vendor/contracts/context-shadow.d.ts +75 -0
  40. package/dist/vendor/contracts/context-shadow.js +8 -0
  41. package/dist/vendor/contracts/delivery.d.ts +47 -0
  42. package/dist/vendor/contracts/delivery.js +18 -0
  43. package/dist/vendor/contracts/exits.d.ts +126 -0
  44. package/dist/vendor/contracts/exits.js +32 -0
  45. package/dist/vendor/contracts/index.d.ts +55 -6
  46. package/dist/vendor/contracts/index.js +24 -0
  47. package/dist/vendor/contracts/mission.d.ts +102 -0
  48. package/dist/vendor/contracts/mission.js +64 -0
  49. package/dist/vendor/contracts/operator.d.ts +3 -1
  50. package/dist/vendor/contracts/operator.js +2 -1
  51. package/dist/vendor/contracts/verified-handoff.d.ts +106 -0
  52. package/dist/vendor/contracts/verified-handoff.js +40 -0
  53. package/dist/vendor/core/context-chain-gate.d.ts +89 -0
  54. package/dist/vendor/core/context-chain-gate.js +173 -0
  55. package/dist/vendor/core/context-compiler.d.ts +53 -0
  56. package/dist/vendor/core/context-compiler.js +261 -0
  57. package/dist/vendor/core/context-handoff.d.ts +42 -0
  58. package/dist/vendor/core/context-handoff.js +167 -0
  59. package/dist/vendor/core/context-shadow.d.ts +39 -0
  60. package/dist/vendor/core/context-shadow.js +200 -0
  61. package/dist/vendor/core/delivery/index.d.ts +6 -0
  62. package/dist/vendor/core/delivery/index.js +4 -0
  63. package/dist/vendor/core/delivery/message-client.d.ts +18 -0
  64. package/dist/vendor/core/delivery/message-client.js +38 -0
  65. package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
  66. package/dist/vendor/core/delivery/message-ledger.js +97 -0
  67. package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
  68. package/dist/vendor/core/delivery/message-schema.js +111 -0
  69. package/dist/vendor/core/delivery/update-check.d.ts +19 -0
  70. package/dist/vendor/core/delivery/update-check.js +107 -0
  71. package/dist/vendor/core/exit-signal.d.ts +88 -0
  72. package/dist/vendor/core/exit-signal.js +339 -0
  73. package/dist/vendor/core/exits.d.ts +41 -0
  74. package/dist/vendor/core/exits.js +258 -0
  75. package/dist/vendor/core/grounding.d.ts +2 -1
  76. package/dist/vendor/core/grounding.js +40 -5
  77. package/dist/vendor/core/index.d.ts +47 -6
  78. package/dist/vendor/core/index.js +1102 -670
  79. package/dist/vendor/core/mission/aggregate.d.ts +27 -0
  80. package/dist/vendor/core/mission/aggregate.js +42 -0
  81. package/dist/vendor/core/mission/index.d.ts +2 -0
  82. package/dist/vendor/core/mission/index.js +1 -0
  83. package/dist/vendor/core/persistence/compiler.d.ts +8 -0
  84. package/dist/vendor/core/persistence/compiler.js +156 -0
  85. package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
  86. package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
  87. package/dist/vendor/core/persistence/index.d.ts +4 -0
  88. package/dist/vendor/core/persistence/index.js +2 -0
  89. package/dist/vendor/core/persistence/ledger.d.ts +1 -1
  90. package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
  91. package/dist/vendor/core/persistence/mission-store.js +281 -0
  92. package/dist/vendor/core/persistence/runs-reader.d.ts +2 -0
  93. package/dist/vendor/core/policy.d.ts +2 -0
  94. package/dist/vendor/core/policy.js +11 -8
  95. package/dist/vendor/core/red-blue/red-phase.d.ts +2 -1
  96. package/dist/vendor/core/red-blue/red-phase.js +3 -3
  97. package/dist/vendor/core/red-blue/risk-tiers.d.ts +2 -4
  98. package/dist/vendor/core/red-blue/risk-tiers.js +1 -3
  99. package/dist/vendor/core/rollback.js +15 -1
  100. package/dist/vendor/core/routing.d.ts +0 -42
  101. package/dist/vendor/core/routing.js +4 -154
  102. package/dist/vendor/core/savings.d.ts +13 -0
  103. package/dist/vendor/core/savings.js +22 -0
  104. package/dist/vendor/core/termination-store.d.ts +15 -0
  105. package/dist/vendor/core/termination-store.js +44 -0
  106. package/dist/vendor/core/verified-handoff.d.ts +72 -0
  107. package/dist/vendor/core/verified-handoff.js +238 -0
  108. package/package.json +4 -10
  109. package/server.json +3 -3
  110. package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
  111. package/dist/vendor/adapters/stub-agent-cli.js +0 -40
  112. package/dist/vendor/adapters/stub-direct-provider.d.ts +0 -8
  113. package/dist/vendor/adapters/stub-direct-provider.js +0 -12
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Mission Store — C2 durable persistence.
3
+ *
4
+ * Layout under <runsRoot>/missions/<missionId>/:
5
+ * mission.json — rebuildable snapshot/cache (NOT the authority)
6
+ * ledger.jsonl — append-only, SHA-256 hash-chained event log (authority)
7
+ * ledger-chain.json — chain head hash for tamper detection
8
+ * .lock — cross-process exclusive lock (created with O_EXCL)
9
+ *
10
+ * Rules:
11
+ * - Ledger is always written before mission.json is updated.
12
+ * - mission.json is written atomically (temp file → rename).
13
+ * - Cross-process lock is held during every write; stale locks (>8s) are removed.
14
+ * - CAS revision must match before any write is accepted.
15
+ * - Corrupt or unverifiable ledger data fails closed.
16
+ * - Workspace isolation: all paths are under caller-supplied runsRoot.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { constants, open } from "node:fs/promises";
20
+ import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { MISSION_SCHEMA_VERSION, isMissionTransitionAllowed } from "../../contracts/index.js";
23
+ // ─── Paths ────────────────────────────────────────────────────────────────────
24
+ export function missionDir(runsRoot, missionId) {
25
+ return join(runsRoot, "missions", missionId);
26
+ }
27
+ function missionJsonPath(runsRoot, missionId) {
28
+ return join(missionDir(runsRoot, missionId), "mission.json");
29
+ }
30
+ function ledgerPath(runsRoot, missionId) {
31
+ return join(missionDir(runsRoot, missionId), "ledger.jsonl");
32
+ }
33
+ function chainPath(runsRoot, missionId) {
34
+ return join(missionDir(runsRoot, missionId), "ledger-chain.json");
35
+ }
36
+ function lockPath(runsRoot, missionId) {
37
+ return join(missionDir(runsRoot, missionId), ".lock");
38
+ }
39
+ // ─── Hash chain ───────────────────────────────────────────────────────────────
40
+ function sha256(value) {
41
+ return createHash("sha256").update(value, "utf8").digest("hex");
42
+ }
43
+ async function readChainHead(runsRoot, missionId) {
44
+ const raw = await readFile(chainPath(runsRoot, missionId), "utf8").catch(() => null);
45
+ if (raw === null)
46
+ return { headHash: "root", entryCount: 0 };
47
+ return JSON.parse(raw);
48
+ }
49
+ async function appendLedgerEntry(runsRoot, missionId, event) {
50
+ const line = JSON.stringify(event);
51
+ const head = await readChainHead(runsRoot, missionId);
52
+ const newHash = sha256(`${head.headHash}\n${line}`);
53
+ const newHead = { headHash: newHash, entryCount: head.entryCount + 1 };
54
+ // Append event first, then update chain head
55
+ await appendFile(ledgerPath(runsRoot, missionId), `${line}\n`, "utf8");
56
+ await atomicWrite(chainPath(runsRoot, missionId), JSON.stringify(newHead, null, 2));
57
+ }
58
+ // ─── Atomic write ─────────────────────────────────────────────────────────────
59
+ async function atomicWrite(filePath, content) {
60
+ const tmp = `${filePath}.tmp`;
61
+ await writeFile(tmp, content, "utf8");
62
+ await rename(tmp, filePath);
63
+ }
64
+ // ─── Cross-process lock ───────────────────────────────────────────────────────
65
+ const LOCK_STALE_MS = 8_000;
66
+ const LOCK_RETRY_INTERVAL_MS = 50;
67
+ const LOCK_MAX_RETRIES = 60; // 3s total
68
+ async function acquireLock(runsRoot, missionId) {
69
+ const lp = lockPath(runsRoot, missionId);
70
+ for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
71
+ try {
72
+ // O_EXCL — fails if file exists (atomic on all POSIX and Windows NTFS)
73
+ const fh = await open(lp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
74
+ await fh.write(String(Date.now()));
75
+ await fh.close();
76
+ return;
77
+ }
78
+ catch {
79
+ // Lock exists — check if stale
80
+ try {
81
+ const st = await stat(lp);
82
+ const ageMs = Date.now() - st.mtimeMs;
83
+ if (ageMs > LOCK_STALE_MS) {
84
+ await rm(lp, { force: true });
85
+ continue; // retry immediately after removing stale lock
86
+ }
87
+ }
88
+ catch {
89
+ // Lock disappeared between check and stat — retry
90
+ continue;
91
+ }
92
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_INTERVAL_MS));
93
+ }
94
+ }
95
+ throw new Error(`mission-store: could not acquire lock for ${missionId} after ${LOCK_MAX_RETRIES} retries`);
96
+ }
97
+ async function releaseLock(runsRoot, missionId) {
98
+ await rm(lockPath(runsRoot, missionId), { force: true });
99
+ }
100
+ async function withLock(runsRoot, missionId, fn) {
101
+ await acquireLock(runsRoot, missionId);
102
+ try {
103
+ return await fn();
104
+ }
105
+ finally {
106
+ await releaseLock(runsRoot, missionId);
107
+ }
108
+ }
109
+ // ─── Event ID factory ─────────────────────────────────────────────────────────
110
+ let _seq = 0;
111
+ function makeEventId(missionId) {
112
+ return `evt_${missionId.slice(0, 8)}_${Date.now()}_${(_seq++).toString().padStart(4, "0")}`;
113
+ }
114
+ // ─── Read mission ─────────────────────────────────────────────────────────────
115
+ /**
116
+ * Read the current mission snapshot.
117
+ * Returns null when no mission exists at this path.
118
+ * Fails closed when the snapshot schema version is not recognised.
119
+ */
120
+ export async function readMission(runsRoot, missionId) {
121
+ const raw = await readFile(missionJsonPath(runsRoot, missionId), "utf8").catch(() => null);
122
+ if (raw === null)
123
+ return null;
124
+ const record = JSON.parse(raw);
125
+ if (record.schemaVersion !== MISSION_SCHEMA_VERSION) {
126
+ throw new Error(`mission-store: unsupported schema "${record.schemaVersion}" for mission ${missionId}`);
127
+ }
128
+ return record;
129
+ }
130
+ // ─── Read ledger ──────────────────────────────────────────────────────────────
131
+ export async function readMissionLedger(runsRoot, missionId) {
132
+ const raw = await readFile(ledgerPath(runsRoot, missionId), "utf8").catch(() => "");
133
+ return raw
134
+ .split(/\r?\n/u)
135
+ .map((l) => l.trim())
136
+ .filter(Boolean)
137
+ .map((l) => JSON.parse(l));
138
+ }
139
+ /**
140
+ * Verify the stored chain head matches a full replay of the ledger.
141
+ * Returns ok=false when the ledger has been tampered with or entries are missing.
142
+ */
143
+ export async function verifyMissionLedger(runsRoot, missionId) {
144
+ const events = await readMissionLedger(runsRoot, missionId);
145
+ const stored = await readChainHead(runsRoot, missionId);
146
+ let hash = "root";
147
+ for (const event of events) {
148
+ hash = sha256(`${hash}\n${JSON.stringify(event)}`);
149
+ }
150
+ if (hash !== stored.headHash) {
151
+ return {
152
+ ok: false,
153
+ reason: `chain_mismatch: expected ${stored.headHash}, replayed ${hash}`,
154
+ entryCount: events.length
155
+ };
156
+ }
157
+ if (events.length !== stored.entryCount) {
158
+ return {
159
+ ok: false,
160
+ reason: `count_mismatch: stored ${stored.entryCount}, actual ${events.length}`,
161
+ entryCount: events.length
162
+ };
163
+ }
164
+ return { ok: true, entryCount: events.length };
165
+ }
166
+ // ─── Create mission ───────────────────────────────────────────────────────────
167
+ export async function createMission(runsRoot, mission) {
168
+ const dir = missionDir(runsRoot, mission.missionId);
169
+ await mkdir(dir, { recursive: true });
170
+ await withLock(runsRoot, mission.missionId, async () => {
171
+ // Fail if already exists
172
+ const existing = await readMission(runsRoot, mission.missionId);
173
+ if (existing !== null) {
174
+ throw new Error(`mission-store: mission ${mission.missionId} already exists`);
175
+ }
176
+ const event = {
177
+ eventId: makeEventId(mission.missionId),
178
+ kind: "mission.created",
179
+ missionId: mission.missionId,
180
+ timestamp: mission.createdAt,
181
+ payload: { status: mission.status, ownerId: mission.ownerId }
182
+ };
183
+ await appendLedgerEntry(runsRoot, mission.missionId, event);
184
+ await atomicWrite(missionJsonPath(runsRoot, mission.missionId), JSON.stringify(mission, null, 2));
185
+ });
186
+ }
187
+ export async function attachRun(runsRoot, missionId, options) {
188
+ return withLock(runsRoot, missionId, async () => {
189
+ const mission = await readMission(runsRoot, missionId);
190
+ if (mission === null)
191
+ throw new Error(`mission-store: mission ${missionId} not found`);
192
+ if (mission.revision !== options.expectedRevision) {
193
+ throw new Error(`mission-store: CAS revision mismatch for ${missionId}: ` +
194
+ `expected ${options.expectedRevision}, found ${mission.revision}`);
195
+ }
196
+ const ts = options.now ? options.now() : new Date().toISOString();
197
+ const link = {
198
+ loopId: options.loopId,
199
+ role: options.role,
200
+ attachedAt: ts,
201
+ ...(options.verifiedOutcome !== undefined ? { verifiedOutcome: options.verifiedOutcome } : {}),
202
+ ...(options.actualUsd !== undefined ? { actualUsd: options.actualUsd } : {})
203
+ };
204
+ const newCost = {
205
+ totalActualUsd: mission.cost.totalActualUsd + (options.actualUsd ?? 0),
206
+ verifiedOutcomeCount: mission.cost.verifiedOutcomeCount + (options.verifiedOutcome === true ? 1 : 0),
207
+ totalRunCount: mission.cost.totalRunCount + 1
208
+ };
209
+ const updated = {
210
+ ...mission,
211
+ revision: mission.revision + 1,
212
+ runLinks: [...mission.runLinks, link],
213
+ cost: newCost,
214
+ updatedAt: ts
215
+ };
216
+ const event = {
217
+ eventId: makeEventId(missionId),
218
+ kind: "mission.run_attached",
219
+ missionId,
220
+ timestamp: ts,
221
+ payload: {
222
+ loopId: options.loopId,
223
+ role: options.role,
224
+ verifiedOutcome: options.verifiedOutcome,
225
+ actualUsd: options.actualUsd
226
+ }
227
+ };
228
+ await appendLedgerEntry(runsRoot, missionId, event);
229
+ await atomicWrite(missionJsonPath(runsRoot, missionId), JSON.stringify(updated, null, 2));
230
+ return updated;
231
+ });
232
+ }
233
+ export async function changeMissionStatus(runsRoot, missionId, options) {
234
+ return withLock(runsRoot, missionId, async () => {
235
+ const mission = await readMission(runsRoot, missionId);
236
+ if (mission === null)
237
+ throw new Error(`mission-store: mission ${missionId} not found`);
238
+ if (mission.revision !== options.expectedRevision) {
239
+ throw new Error(`mission-store: CAS revision mismatch for ${missionId}: ` +
240
+ `expected ${options.expectedRevision}, found ${mission.revision}`);
241
+ }
242
+ if (!isMissionTransitionAllowed(mission.status, options.toStatus)) {
243
+ throw new Error(`mission-store: transition ${mission.status} → ${options.toStatus} is not allowed`);
244
+ }
245
+ const ts = options.now ? options.now() : new Date().toISOString();
246
+ const updated = {
247
+ ...mission,
248
+ revision: mission.revision + 1,
249
+ status: options.toStatus,
250
+ updatedAt: ts,
251
+ ...(options.decision && options.decidedBy
252
+ ? {
253
+ outcome: {
254
+ decision: options.decision,
255
+ decidedAt: ts,
256
+ decidedBy: options.decidedBy,
257
+ ...(options.note ? { note: options.note } : {})
258
+ }
259
+ }
260
+ : {})
261
+ };
262
+ const eventKind = options.toStatus === "shipped" || options.toStatus === "killed" || options.toStatus === "rolled_back"
263
+ ? "mission.closed"
264
+ : "mission.status_changed";
265
+ const event = {
266
+ eventId: makeEventId(missionId),
267
+ kind: eventKind,
268
+ missionId,
269
+ timestamp: ts,
270
+ payload: {
271
+ from: mission.status,
272
+ to: options.toStatus,
273
+ decision: options.decision,
274
+ decidedBy: options.decidedBy
275
+ }
276
+ };
277
+ await appendLedgerEntry(runsRoot, missionId, event);
278
+ await atomicWrite(missionJsonPath(runsRoot, missionId), JSON.stringify(updated, null, 2));
279
+ return updated;
280
+ });
281
+ }
@@ -6,6 +6,7 @@
6
6
  * - legacy root JSONL files: <runsRoot>/*.jsonl
7
7
  * - canonical run trees: <runsRoot>/<loopId>/loop-record.json
8
8
  */
9
+ import type { CostProvenance } from "../../contracts/index.js";
9
10
  export interface LoopAttemptRecord {
10
11
  index: number;
11
12
  model?: string;
@@ -32,6 +33,7 @@ export interface LoopRunRecord {
32
33
  tokensIn: number;
33
34
  tokensOut: number;
34
35
  avoidedUsd?: number;
36
+ provenance?: CostProvenance;
35
37
  };
36
38
  attempts: LoopAttemptRecord[];
37
39
  task: {
@@ -138,6 +138,8 @@ export interface PatchDecisionInput {
138
138
  safetyViolationCount?: number;
139
139
  scopeViolationCount?: number;
140
140
  changedFileCount?: number;
141
+ mutationRequired?: boolean;
142
+ definitionOfDonePreSatisfied?: boolean;
141
143
  diffNovelty?: number;
142
144
  diffStats?: {
143
145
  filesChanged: number;
@@ -152,14 +152,14 @@ export function classifyFailure(input) {
152
152
  failureClass: "logic_error",
153
153
  rationale: "Oscillating failure pattern detected — switching strategy to break the cycle.",
154
154
  retryable: true,
155
- recommendedIntervention: "change_model"
155
+ recommendedIntervention: "compress_context"
156
156
  };
157
157
  }
158
158
  return {
159
159
  failureClass: repeatedFailure ?? "logic_error",
160
160
  rationale: "The loop is still failing to produce a correct implementation.",
161
161
  retryable: true,
162
- recommendedIntervention: repeatedFailure === "logic_error" ? "change_model" : "compress_context"
162
+ recommendedIntervention: "compress_context"
163
163
  };
164
164
  }
165
165
  export function evaluateCostGovernor(input) {
@@ -318,7 +318,7 @@ function mapClassHintToAssessment(classHint, attempts) {
318
318
  failureClass: "hallucination",
319
319
  rationale: "Structural evidence: suspiciously short or trivial agent response.",
320
320
  retryable: true,
321
- recommendedIntervention: repeatedFailure === "hallucination" ? "change_model" : "run_verifier"
321
+ recommendedIntervention: repeatedFailure === "hallucination" ? "compress_context" : "run_verifier"
322
322
  };
323
323
  case "verification_failure":
324
324
  case "test_regression":
@@ -333,7 +333,7 @@ function mapClassHintToAssessment(classHint, attempts) {
333
333
  failureClass: classHint,
334
334
  rationale: "Structural hint provided by adapter.",
335
335
  retryable: true,
336
- recommendedIntervention: repeatedFailure ? "change_model" : "compress_context"
336
+ recommendedIntervention: "compress_context"
337
337
  };
338
338
  }
339
339
  }
@@ -472,7 +472,7 @@ export function selectRecoveryRecipe(evidence) {
472
472
  return {
473
473
  recipe: "strategy_swap",
474
474
  rationale: "Very low diff novelty across repeated retries. Swap strategy.",
475
- intervention: "change_model"
475
+ intervention: "compress_context"
476
476
  };
477
477
  }
478
478
  return {
@@ -485,7 +485,7 @@ export function selectRecoveryRecipe(evidence) {
485
485
  return {
486
486
  recipe: "downgrade_model",
487
487
  rationale: "Cost efficiency is degrading or retries are exhausted. Downgrade the model.",
488
- intervention: "change_model"
488
+ intervention: "compress_context"
489
489
  };
490
490
  }
491
491
  return {
@@ -514,6 +514,9 @@ export function scorePatchDecision(input) {
514
514
  const safetyViolationCount = input.safetyViolationCount ?? 0;
515
515
  const changedFileEvidenceAvailable = input.changedFileCount !== undefined;
516
516
  const changedFileCount = input.changedFileCount ?? 0;
517
+ const auditablePreSatisfiedNoChange = input.verificationPassed &&
518
+ input.mutationRequired === true &&
519
+ input.definitionOfDonePreSatisfied === true;
517
520
  const noveltyScore = input.diffNovelty ?? (changedFileCount > 0 ? 1 : 0);
518
521
  const diffRiskScore = computeDiffRiskScore(input.diffStats);
519
522
  const costUsd = roundUsd(input.costUsd ?? 0);
@@ -527,7 +530,7 @@ export function scorePatchDecision(input) {
527
530
  if (scopeViolationCount > 0) {
528
531
  reasonCodes.push("scope_violation");
529
532
  }
530
- if (changedFileEvidenceAvailable && changedFileCount === 0) {
533
+ if (changedFileEvidenceAvailable && changedFileCount === 0 && !auditablePreSatisfiedNoChange) {
531
534
  reasonCodes.push("no_code_change");
532
535
  }
533
536
  if (input.humanApprovalRequired) {
@@ -560,7 +563,7 @@ export function scorePatchDecision(input) {
560
563
  if (input.humanApprovalRequired) {
561
564
  score -= 0.25;
562
565
  }
563
- if (changedFileEvidenceAvailable && changedFileCount === 0) {
566
+ if (changedFileEvidenceAvailable && changedFileCount === 0 && !auditablePreSatisfiedNoChange) {
564
567
  score -= 0.35;
565
568
  }
566
569
  if (!input.verificationPassed && noveltyScore < 0.2 && verifierDelta <= 0) {
@@ -20,6 +20,7 @@ export interface MockModelClient {
20
20
  findings: RedFinding[];
21
21
  tokensUsed: number;
22
22
  costUsd: number;
23
+ model?: string;
23
24
  }>;
24
25
  }
25
26
  export interface RunRedPhaseOptions {
@@ -47,7 +48,7 @@ export interface PatchInput {
47
48
  *
48
49
  * - baseline: programmatic probes only, no model call
49
50
  * - high_risk: paranoid programmatic scan, no model call
50
- * - release_critical: paranoid scan + one Haiku model call
51
+ * - release_critical: paranoid scan + one configured model-client call
51
52
  */
52
53
  export declare function runRedPhase(patch: PatchInput, tier: RiskTier, blueBudgetUsd: number, options?: RunRedPhaseOptions): Promise<RedFindings>;
53
54
  /**
@@ -1,4 +1,4 @@
1
- import { PROBE_COUNTS, RED_PHASE_MODEL, resolveRedBudgetPolicy } from "./risk-tiers.js";
1
+ import { PROBE_COUNTS, resolveRedBudgetPolicy } from "./risk-tiers.js";
2
2
  // ─── Programmatic probes ──────────────────────────────────────────────────────
3
3
  function runProgrammaticProbes(patch, paranoid) {
4
4
  const findings = [];
@@ -62,7 +62,7 @@ function runProgrammaticProbes(patch, paranoid) {
62
62
  *
63
63
  * - baseline: programmatic probes only, no model call
64
64
  * - high_risk: paranoid programmatic scan, no model call
65
- * - release_critical: paranoid scan + one Haiku model call
65
+ * - release_critical: paranoid scan + one configured model-client call
66
66
  */
67
67
  export async function runRedPhase(patch, tier, blueBudgetUsd, options = {}) {
68
68
  const policy = resolveRedBudgetPolicy(tier, blueBudgetUsd);
@@ -77,7 +77,7 @@ export async function runRedPhase(patch, tier, blueBudgetUsd, options = {}) {
77
77
  const result = await options.modelClient.complete(prompt);
78
78
  findings = [...findings, ...result.findings];
79
79
  modelCallMade = true;
80
- modelUsed = RED_PHASE_MODEL;
80
+ modelUsed = result.model;
81
81
  budgetUsedUsd += result.costUsd;
82
82
  }
83
83
  const result = {
@@ -4,7 +4,7 @@ export interface RedBudgetPolicy {
4
4
  blueBudgetUsd: number;
5
5
  /** Cap on Red phase spend: 30% / 100% / 150% of Blue */
6
6
  redBudgetCapUsd: number;
7
- /** Only release_critical permits a Haiku model call */
7
+ /** Only release_critical permits an optional configured model-client call. */
8
8
  modelCallAllowed: boolean;
9
9
  }
10
10
  /**
@@ -15,8 +15,6 @@ export declare function resolveRedBudgetPolicy(tier: RiskTier, blueBudgetUsd: nu
15
15
  * Probe counts per tier.
16
16
  * baseline = standard 6-probe sweep
17
17
  * high_risk = paranoid 12-probe sweep
18
- * release_critical = paranoid 12-probe sweep + model
18
+ * release_critical = paranoid 12-probe sweep + configured model client
19
19
  */
20
20
  export declare const PROBE_COUNTS: Record<RiskTier, number>;
21
- /** The only model ever permitted in the Red phase. */
22
- export declare const RED_PHASE_MODEL: "claude-haiku-4-5-20251001";
@@ -21,12 +21,10 @@ export function resolveRedBudgetPolicy(tier, blueBudgetUsd) {
21
21
  * Probe counts per tier.
22
22
  * baseline = standard 6-probe sweep
23
23
  * high_risk = paranoid 12-probe sweep
24
- * release_critical = paranoid 12-probe sweep + model
24
+ * release_critical = paranoid 12-probe sweep + configured model client
25
25
  */
26
26
  export const PROBE_COUNTS = {
27
27
  baseline: 6,
28
28
  high_risk: 12,
29
29
  release_critical: 12
30
30
  };
31
- /** The only model ever permitted in the Red phase. */
32
- export const RED_PHASE_MODEL = "claude-haiku-4-5-20251001";
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { dirname, relative, resolve } from "node:path";
4
5
  export function listAttemptChangedFilesSinceBoundary(input) {
@@ -11,11 +12,24 @@ export function listAttemptChangedFilesSinceBoundary(input) {
11
12
  }
12
13
  const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
13
14
  const baselineUntracked = new Set(input.boundary.untrackedFiles);
15
+ const baselineContentChanges = input.boundary.snapshots
16
+ .filter((snapshot) => snapshot.existed && repoFileDiffersFromSnapshot(input.repoRoot, snapshot))
17
+ .map((snapshot) => snapshot.path);
14
18
  return uniqueSorted([
15
19
  ...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
16
- ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath))
20
+ ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)),
21
+ ...baselineContentChanges
17
22
  ]);
18
23
  }
24
+ function repoFileDiffersFromSnapshot(repoRoot, snapshot) {
25
+ try {
26
+ const current = readFileSync(resolveRepoPath(repoRoot, snapshot.path));
27
+ return current.toString("base64") !== snapshot.contentBase64;
28
+ }
29
+ catch {
30
+ return true;
31
+ }
32
+ }
19
33
  export async function captureRollbackBoundary(input) {
20
34
  if (!input.repoRoot) {
21
35
  return undefined;
@@ -15,14 +15,6 @@ export interface RouteDecision {
15
15
  blockedSteps: string[];
16
16
  compressed: boolean;
17
17
  compressionSummary?: string;
18
- /**
19
- * Recommended model tier based on task complexity and route.
20
- * Follows cost-aware routing: direct → haiku/sonnet, manager → sonnet,
21
- * consensus → opus. Never use a more expensive model than the task needs.
22
- */
23
- recommendedModelTier: "haiku" | "sonnet" | "opus";
24
- /** Estimated cost saving vs always using sonnet */
25
- estimatedSavingVsSonnetUsd: number;
26
18
  }
27
19
  export interface RouteClassificationInput {
28
20
  objective: string;
@@ -37,40 +29,6 @@ export interface RouteClassificationInput {
37
29
  /** Historical success rate for similar tasks with direct execution (0-1). */
38
30
  historicalDirectSuccessRate?: number;
39
31
  }
40
- /**
41
- * Resolve a tier to a concrete model ID for a specific engine.
42
- * Allows downstream adapters to switch models automatically.
43
- */
44
- export declare function resolveModelForTier(tier: RouteDecision["recommendedModelTier"], engine: "claude" | "codex" | "gemini" | "openai" | string): string;
45
- /**
46
- * Available engine registry — describes what's installed and its capability.
47
- * Populated by doctor/preflight from PATH and env var detection.
48
- */
49
- export interface AvailableEngine {
50
- id: string;
51
- available: boolean;
52
- /** Cost tier: cheap < mid < expensive */
53
- costTier: "cheap" | "mid" | "expensive";
54
- /** Capability tier: determines which task types this engine can handle */
55
- capabilityTier: "haiku" | "sonnet" | "opus";
56
- }
57
- /**
58
- * Select the best available engine for a given tier requirement.
59
- *
60
- * When multiple engines are available, picks the cheapest that meets
61
- * the capability requirement. Falls back gracefully:
62
- * - If required tier unavailable, upgrades to next tier of same engine
63
- * - If only one engine family available, uses its best equivalent
64
- *
65
- * @param requiredTier - Minimum capability tier needed
66
- * @param availableEngines - What's installed and authenticated
67
- * @returns Best engine ID and concrete model to use
68
- */
69
- export declare function selectBestEngine(requiredTier: RouteDecision["recommendedModelTier"], availableEngines: AvailableEngine[]): {
70
- engineId: string;
71
- model: string;
72
- reasoning: string;
73
- };
74
32
  export declare function classifyRoute(input: RouteClassificationInput): RouteDecision;
75
33
  /**
76
34
  * Checks whether a run's Pre Work Burn exceeds the routing policy limits.