@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.
- package/README.md +82 -141
- package/dist/discovery-metadata.d.ts +1 -1
- package/dist/discovery-metadata.js +1 -1
- package/dist/resources.d.ts +1 -2
- package/dist/resources.js +62 -98
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +16 -11
- package/dist/server.d.ts +23 -0
- package/dist/server.js +192 -15
- package/dist/tools/doctor.js +5 -4
- package/dist/tools/get-status.d.ts +2 -0
- package/dist/tools/get-status.js +1 -0
- package/dist/tools/preflight.d.ts +2 -0
- package/dist/tools/preflight.js +43 -3
- package/dist/tools/run-dossier.d.ts +3 -1
- package/dist/tools/run-dossier.js +40 -2
- package/dist/tools/run-loop.d.ts +5 -3
- package/dist/tools/run-loop.js +15 -9
- package/dist/tools/tool-errors.js +1 -1
- package/dist/tools/tool-response.js +9 -2
- package/dist/tools/tool-support.d.ts +4 -2
- package/dist/tools/tool-support.js +6 -15
- package/dist/vendor/adapters/claude-cli.d.ts +2 -2
- package/dist/vendor/adapters/claude-cli.js +97 -69
- package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
- package/dist/vendor/adapters/cli-bridge.js +54 -13
- package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
- package/dist/vendor/adapters/codex-launcher.js +118 -19
- package/dist/vendor/adapters/index.d.ts +2 -4
- package/dist/vendor/adapters/index.js +2 -4
- package/dist/vendor/adapters/openai-compatible.d.ts +2 -3
- package/dist/vendor/adapters/openai-compatible.js +80 -15
- package/dist/vendor/adapters/verifier-only.js +9 -4
- package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
- package/dist/vendor/contracts/context-handoff.d.ts +100 -0
- package/dist/vendor/contracts/context-handoff.js +11 -0
- package/dist/vendor/contracts/context-manifest.d.ts +195 -0
- package/dist/vendor/contracts/context-manifest.js +17 -0
- package/dist/vendor/contracts/context-shadow.d.ts +75 -0
- package/dist/vendor/contracts/context-shadow.js +8 -0
- package/dist/vendor/contracts/delivery.d.ts +47 -0
- package/dist/vendor/contracts/delivery.js +18 -0
- package/dist/vendor/contracts/exits.d.ts +126 -0
- package/dist/vendor/contracts/exits.js +32 -0
- package/dist/vendor/contracts/index.d.ts +55 -6
- package/dist/vendor/contracts/index.js +24 -0
- package/dist/vendor/contracts/mission.d.ts +102 -0
- package/dist/vendor/contracts/mission.js +64 -0
- package/dist/vendor/contracts/operator.d.ts +3 -1
- package/dist/vendor/contracts/operator.js +2 -1
- package/dist/vendor/contracts/verified-handoff.d.ts +106 -0
- package/dist/vendor/contracts/verified-handoff.js +40 -0
- package/dist/vendor/core/context-chain-gate.d.ts +89 -0
- package/dist/vendor/core/context-chain-gate.js +173 -0
- package/dist/vendor/core/context-compiler.d.ts +53 -0
- package/dist/vendor/core/context-compiler.js +261 -0
- package/dist/vendor/core/context-handoff.d.ts +42 -0
- package/dist/vendor/core/context-handoff.js +167 -0
- package/dist/vendor/core/context-shadow.d.ts +39 -0
- package/dist/vendor/core/context-shadow.js +200 -0
- package/dist/vendor/core/delivery/index.d.ts +6 -0
- package/dist/vendor/core/delivery/index.js +4 -0
- package/dist/vendor/core/delivery/message-client.d.ts +18 -0
- package/dist/vendor/core/delivery/message-client.js +38 -0
- package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
- package/dist/vendor/core/delivery/message-ledger.js +97 -0
- package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
- package/dist/vendor/core/delivery/message-schema.js +111 -0
- package/dist/vendor/core/delivery/update-check.d.ts +19 -0
- package/dist/vendor/core/delivery/update-check.js +107 -0
- package/dist/vendor/core/exit-signal.d.ts +88 -0
- package/dist/vendor/core/exit-signal.js +339 -0
- package/dist/vendor/core/exits.d.ts +41 -0
- package/dist/vendor/core/exits.js +258 -0
- package/dist/vendor/core/grounding.d.ts +2 -1
- package/dist/vendor/core/grounding.js +40 -5
- package/dist/vendor/core/index.d.ts +47 -6
- package/dist/vendor/core/index.js +1102 -670
- package/dist/vendor/core/mission/aggregate.d.ts +27 -0
- package/dist/vendor/core/mission/aggregate.js +42 -0
- package/dist/vendor/core/mission/index.d.ts +2 -0
- package/dist/vendor/core/mission/index.js +1 -0
- package/dist/vendor/core/persistence/compiler.d.ts +8 -0
- package/dist/vendor/core/persistence/compiler.js +156 -0
- package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
- package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
- package/dist/vendor/core/persistence/index.d.ts +4 -0
- package/dist/vendor/core/persistence/index.js +2 -0
- package/dist/vendor/core/persistence/ledger.d.ts +1 -1
- package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
- package/dist/vendor/core/persistence/mission-store.js +281 -0
- package/dist/vendor/core/persistence/runs-reader.d.ts +2 -0
- package/dist/vendor/core/policy.d.ts +2 -0
- package/dist/vendor/core/policy.js +11 -8
- package/dist/vendor/core/red-blue/red-phase.d.ts +2 -1
- package/dist/vendor/core/red-blue/red-phase.js +3 -3
- package/dist/vendor/core/red-blue/risk-tiers.d.ts +2 -4
- package/dist/vendor/core/red-blue/risk-tiers.js +1 -3
- package/dist/vendor/core/rollback.js +15 -1
- package/dist/vendor/core/routing.d.ts +0 -42
- package/dist/vendor/core/routing.js +4 -154
- package/dist/vendor/core/savings.d.ts +13 -0
- package/dist/vendor/core/savings.js +22 -0
- package/dist/vendor/core/termination-store.d.ts +15 -0
- package/dist/vendor/core/termination-store.js +44 -0
- package/dist/vendor/core/verified-handoff.d.ts +72 -0
- package/dist/vendor/core/verified-handoff.js +238 -0
- package/package.json +4 -10
- package/server.json +3 -3
- package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
- package/dist/vendor/adapters/stub-agent-cli.js +0 -40
- package/dist/vendor/adapters/stub-direct-provider.d.ts +0 -8
- 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: "
|
|
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:
|
|
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" ? "
|
|
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:
|
|
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: "
|
|
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: "
|
|
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
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
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.
|