@martinloop/mcp 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/tools/preflight.js +8 -4
- package/dist/tools/run-loop.js +7 -1
- package/dist/vendor/adapters/claude-cli.d.ts +4 -0
- package/dist/vendor/adapters/claude-cli.js +81 -13
- package/dist/vendor/contracts/index.d.ts +79 -0
- package/dist/vendor/contracts/index.js +34 -0
- package/dist/vendor/contracts/swarm.js +1 -1
- package/dist/vendor/core/index.d.ts +43 -4
- package/dist/vendor/core/index.js +529 -74
- package/dist/vendor/core/leash.d.ts +11 -1
- package/dist/vendor/core/leash.js +77 -3
- package/dist/vendor/core/output-directories.d.ts +7 -0
- package/dist/vendor/core/output-directories.js +36 -0
- package/dist/vendor/core/policy.d.ts +18 -1
- package/dist/vendor/core/policy.js +113 -10
- package/dist/vendor/core/rollback.d.ts +24 -0
- package/dist/vendor/core/rollback.js +402 -19
- package/dist/vendor/core/test-integrity.d.ts +18 -0
- package/dist/vendor/core/test-integrity.js +114 -1
- package/dist/vendor/core/test-surface.d.ts +27 -0
- package/dist/vendor/core/test-surface.js +231 -0
- package/dist/vendor/core/verified-handoff.d.ts +1 -0
- package/dist/vendor/core/verified-handoff.js +6 -0
- package/dist/vendor/core/verifier-input.d.ts +4 -0
- package/dist/vendor/core/verifier-input.js +107 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ApprovalPolicy, ExecutionProfile, LoopTask } from "../contracts/index.js";
|
|
2
2
|
export type SafetySurface = "command" | "filesystem" | "secret" | "spend" | "network" | "dependency";
|
|
3
|
-
export type SafetyViolationKind = "command_blocked" | "path_not_allowed" | "path_denied" | "path_outside_repo" | "network_blocked" | "dependency_approval_required" | "migration_approval_required" | "config_change_approval_required" | "secret_value" | "protected_path";
|
|
3
|
+
export type SafetyViolationKind = "command_blocked" | "path_not_allowed" | "path_denied" | "path_outside_repo" | "network_blocked" | "dependency_approval_required" | "migration_approval_required" | "config_change_approval_required" | "test_change_approval_required" | "secret_value" | "protected_path";
|
|
4
4
|
export interface SafetyViolation {
|
|
5
5
|
kind: SafetyViolationKind;
|
|
6
6
|
message: string;
|
|
@@ -30,6 +30,14 @@ export declare function evaluateVerificationLeash(task: Pick<LoopTask, "verifica
|
|
|
30
30
|
export declare function evaluateFilesystemLeash(input: {
|
|
31
31
|
repoRoot?: string;
|
|
32
32
|
changedFiles: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Git-ignored files the attempt created or modified. These are checked only
|
|
35
|
+
* against denied paths and protected secret files, never allowed paths, so
|
|
36
|
+
* dependency installs and build output stay unaffected.
|
|
37
|
+
*/
|
|
38
|
+
ignoredFiles?: string[];
|
|
39
|
+
/** Symlinks or junctions created inside the repo that resolve outside it. */
|
|
40
|
+
escapingLinks?: string[];
|
|
33
41
|
allowedPaths?: string[];
|
|
34
42
|
deniedPaths?: string[];
|
|
35
43
|
mutationMode?: "edit" | "read_only" | "verify_only";
|
|
@@ -43,6 +51,8 @@ export declare function resolveExecutionProfile(input: {
|
|
|
43
51
|
}): ResolvedExecutionProfile;
|
|
44
52
|
export declare function evaluateChangeApprovalLeash(input: {
|
|
45
53
|
changedFiles: string[];
|
|
54
|
+
/** Existing test files the attempt deleted or removed lines from. */
|
|
55
|
+
weakenedTestFiles?: string[];
|
|
46
56
|
executionProfile?: ExecutionProfile;
|
|
47
57
|
approvalPolicy?: ApprovalPolicy;
|
|
48
58
|
}): SafetyLeashDecision;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import { isInsideDependencyOrBuildDirectory } from "./output-directories.js";
|
|
2
3
|
const BLOCKED_PATTERNS = [
|
|
3
4
|
/(^|[\s;&|`(])rm\s+-rf(\s|$)/iu,
|
|
4
5
|
/git\s+reset\s+--hard/iu,
|
|
@@ -186,8 +187,13 @@ export function evaluateVerificationLeash(task) {
|
|
|
186
187
|
};
|
|
187
188
|
}
|
|
188
189
|
export function evaluateFilesystemLeash(input) {
|
|
189
|
-
const violations = []
|
|
190
|
-
|
|
190
|
+
const violations = (input.escapingLinks ?? []).map((link) => ({
|
|
191
|
+
kind: "path_outside_repo",
|
|
192
|
+
file: link,
|
|
193
|
+
message: `Link inside the repo points outside the configured repo root: ${link}`
|
|
194
|
+
}));
|
|
195
|
+
const escapingLinks = new Set(input.escapingLinks ?? []);
|
|
196
|
+
for (const rawFile of input.changedFiles.filter((file) => !escapingLinks.has(file))) {
|
|
191
197
|
const normalized = normalizeChangedFile(rawFile, input.repoRoot);
|
|
192
198
|
if (normalized.outsideRepo) {
|
|
193
199
|
violations.push({
|
|
@@ -218,6 +224,10 @@ export function evaluateFilesystemLeash(input) {
|
|
|
218
224
|
continue;
|
|
219
225
|
}
|
|
220
226
|
const allowedPaths = input.allowedPaths ?? [];
|
|
227
|
+
if (isProtectedSecretFile(file) && !isExplicitlyAllowed(file, allowedPaths)) {
|
|
228
|
+
violations.push(protectedSecretViolation(file));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
221
231
|
if (allowedPaths.length > 0 && !allowedPaths.some((pattern) => matchesPathPattern(file, pattern))) {
|
|
222
232
|
violations.push({
|
|
223
233
|
kind: "path_not_allowed",
|
|
@@ -226,6 +236,25 @@ export function evaluateFilesystemLeash(input) {
|
|
|
226
236
|
});
|
|
227
237
|
}
|
|
228
238
|
}
|
|
239
|
+
for (const rawFile of input.ignoredFiles ?? []) {
|
|
240
|
+
const file = normalizePathForMatching(rawFile);
|
|
241
|
+
if (!file) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if ((input.deniedPaths ?? []).some((pattern) => matchesPathPattern(file, pattern))) {
|
|
245
|
+
violations.push({
|
|
246
|
+
kind: "path_denied",
|
|
247
|
+
file,
|
|
248
|
+
message: `Git-ignored file matches a denylisted path: ${file}`
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!isInsideDependencyOrBuildDirectory(file) &&
|
|
253
|
+
isProtectedSecretFile(file) &&
|
|
254
|
+
!isExplicitlyAllowed(file, input.allowedPaths ?? [])) {
|
|
255
|
+
violations.push(protectedSecretViolation(file));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
229
258
|
if (violations.length > 0) {
|
|
230
259
|
return {
|
|
231
260
|
allowed: false,
|
|
@@ -357,6 +386,15 @@ export function evaluateChangeApprovalLeash(input) {
|
|
|
357
386
|
});
|
|
358
387
|
}
|
|
359
388
|
}
|
|
389
|
+
if (!input.approvalPolicy?.testChanges) {
|
|
390
|
+
for (const file of input.weakenedTestFiles ?? []) {
|
|
391
|
+
violations.push({
|
|
392
|
+
kind: "test_change_approval_required",
|
|
393
|
+
file,
|
|
394
|
+
message: `Removing or rewriting existing test code requires approval: ${file}`
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
360
398
|
if (violations.length > 0) {
|
|
361
399
|
return {
|
|
362
400
|
allowed: false,
|
|
@@ -366,7 +404,7 @@ export function evaluateChangeApprovalLeash(input) {
|
|
|
366
404
|
profile: profile.name,
|
|
367
405
|
blockedCommands: [],
|
|
368
406
|
violations,
|
|
369
|
-
reason: "Safety leash blocked dependency, migration, or
|
|
407
|
+
reason: "Safety leash blocked dependency, migration, configuration, or existing-test changes that require approval."
|
|
370
408
|
};
|
|
371
409
|
}
|
|
372
410
|
return {
|
|
@@ -408,6 +446,42 @@ function normalizeChangedFile(file, repoRoot) {
|
|
|
408
446
|
outsideRepo: false
|
|
409
447
|
};
|
|
410
448
|
}
|
|
449
|
+
const SAFE_ENV_TEMPLATE_SUFFIXES = new Set(["example", "sample", "template", "dist", "defaults"]);
|
|
450
|
+
const PRIVATE_KEY_BASENAMES = new Set(["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"]);
|
|
451
|
+
const SECRET_MATERIAL_EXTENSIONS = new Set(["pem", "key", "p12", "pfx"]);
|
|
452
|
+
/** Files that conventionally hold credentials or private key material. */
|
|
453
|
+
function isProtectedSecretFile(file) {
|
|
454
|
+
const leaf = normalizePathForMatching(file).split("/").at(-1) ?? "";
|
|
455
|
+
if (leaf === ".env") {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
if (leaf.startsWith(".env.")) {
|
|
459
|
+
return !SAFE_ENV_TEMPLATE_SUFFIXES.has(leaf.slice(".env.".length).toLowerCase());
|
|
460
|
+
}
|
|
461
|
+
if (PRIVATE_KEY_BASENAMES.has(leaf)) {
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
const extension = leaf.includes(".") ? leaf.split(".").at(-1)?.toLowerCase() : undefined;
|
|
465
|
+
return extension !== undefined && SECRET_MATERIAL_EXTENSIONS.has(extension);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* A protected file counts as allowed only when an allowed-path entry targets it
|
|
469
|
+
* specifically; broad wildcards such as `**` or `src/*` do not approve secrets.
|
|
470
|
+
*/
|
|
471
|
+
function isExplicitlyAllowed(file, allowedPaths) {
|
|
472
|
+
return allowedPaths.some((pattern) => {
|
|
473
|
+
const leafPattern = normalizePathForMatching(pattern).split("/").at(-1) ?? "";
|
|
474
|
+
return !/^\*+$/u.test(leafPattern) && matchesPathPattern(file, pattern);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function protectedSecretViolation(file) {
|
|
478
|
+
return {
|
|
479
|
+
kind: "protected_path",
|
|
480
|
+
file,
|
|
481
|
+
message: `Changed file is a protected secret or key file: ${file}. ` +
|
|
482
|
+
"Name it explicitly in allowed paths to permit agent writes."
|
|
483
|
+
};
|
|
484
|
+
}
|
|
411
485
|
function matchesPathPattern(file, pattern) {
|
|
412
486
|
const normalizedFile = normalizePathForMatching(file);
|
|
413
487
|
const normalizedPattern = normalizePathForMatching(pattern);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory names that hold dependency installs or build output. Git-ignored
|
|
3
|
+
* content under them is tracked as a single collapsed entry and is not checked
|
|
4
|
+
* file by file for protected secrets.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEPENDENCY_AND_BUILD_DIRECTORIES: ReadonlySet<string>;
|
|
7
|
+
export declare function isInsideDependencyOrBuildDirectory(repoPath: string): boolean;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory names that hold dependency installs or build output. Git-ignored
|
|
3
|
+
* content under them is tracked as a single collapsed entry and is not checked
|
|
4
|
+
* file by file for protected secrets.
|
|
5
|
+
*/
|
|
6
|
+
export const DEPENDENCY_AND_BUILD_DIRECTORIES = new Set([
|
|
7
|
+
"node_modules",
|
|
8
|
+
"bower_components",
|
|
9
|
+
"jspm_packages",
|
|
10
|
+
".pnpm-store",
|
|
11
|
+
".yarn",
|
|
12
|
+
".venv",
|
|
13
|
+
"venv",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".mypy_cache",
|
|
16
|
+
".pytest_cache",
|
|
17
|
+
".ruff_cache",
|
|
18
|
+
".tox",
|
|
19
|
+
".gradle",
|
|
20
|
+
".next",
|
|
21
|
+
".nuxt",
|
|
22
|
+
".turbo",
|
|
23
|
+
".cache",
|
|
24
|
+
".parcel-cache",
|
|
25
|
+
"target",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
"out",
|
|
29
|
+
"coverage",
|
|
30
|
+
"vendor"
|
|
31
|
+
]);
|
|
32
|
+
export function isInsideDependencyOrBuildDirectory(repoPath) {
|
|
33
|
+
const segments = repoPath.replace(/\\/gu, "/").split("/").filter(Boolean);
|
|
34
|
+
// A trailing collapsed entry such as `node_modules/` is itself the directory.
|
|
35
|
+
return segments.some((segment) => DEPENDENCY_AND_BUILD_DIRECTORIES.has(segment));
|
|
36
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchScore, PolicyPhase } from "../contracts/index.js";
|
|
1
|
+
import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, PolicyPhase } from "../contracts/index.js";
|
|
2
2
|
export interface FailureAssessment {
|
|
3
3
|
failureClass: FailureClass;
|
|
4
4
|
rationale: string;
|
|
@@ -106,6 +106,17 @@ export interface BudgetPreflightInput {
|
|
|
106
106
|
remainingTokens?: number;
|
|
107
107
|
/** Adapter-declared estimated floor required to admit a credible first turn. */
|
|
108
108
|
minimumViableTokens?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Remaining iterations allowed. Used to compute a budget-aware per-attempt cap
|
|
111
|
+
* that reserves capacity for verifier and at least one repair attempt.
|
|
112
|
+
* When omitted, the preflight uses remaining budget divided by a conservative
|
|
113
|
+
* factor to prevent over-allocation on the first attempt.
|
|
114
|
+
*/
|
|
115
|
+
remainingIterations?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Explicit per-attempt spending cap. When set, overrides the computed cap.
|
|
118
|
+
* Only use when the caller has specific rate-limiting requirements.
|
|
119
|
+
*/
|
|
109
120
|
perAttemptCapUsd?: number;
|
|
110
121
|
pricePerMTokenUsd?: number;
|
|
111
122
|
}
|
|
@@ -158,5 +169,11 @@ export interface EvaluatedPatchDecision extends PatchDecisionArtifact {
|
|
|
158
169
|
score: PatchScore;
|
|
159
170
|
}
|
|
160
171
|
export declare function selectRecoveryRecipe(evidence: EvidenceVector): RecoveryDecision;
|
|
172
|
+
/**
|
|
173
|
+
* Returns true ONLY when every reason code is in the explicit allowlist of
|
|
174
|
+
* retainable soft failures. Unknown or future reason codes default to UNSAFE
|
|
175
|
+
* (fail-closed), preventing silent candidate retention for unrecognized states.
|
|
176
|
+
*/
|
|
177
|
+
export declare function isSafeUnverifiedDiscard(reasonCodes: readonly PatchDecisionReasonCode[]): boolean;
|
|
161
178
|
export declare function evaluatePatchDecision(input: PatchDecisionInput): EvaluatedPatchDecision;
|
|
162
179
|
export declare function scorePatchDecision(input: PatchDecisionInput): PatchScore;
|
|
@@ -244,17 +244,42 @@ export function inferExit(input) {
|
|
|
244
244
|
reason: "Oscillating failure pattern detected — loop is cycling without measurable progress."
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
|
+
// C2 — progress-aware repetition check.
|
|
248
|
+
// Two consecutive attempts with the same failureClass are NOT necessarily
|
|
249
|
+
// the same state. If the verifierScore improved (e.g. fewer failing tests),
|
|
250
|
+
// that is measurable progress and we must not stop prematurely.
|
|
251
|
+
// Only stop when the failure class repeated AND there is no measurable improvement:
|
|
252
|
+
// - verifierScore did not increase, AND
|
|
253
|
+
// - candidate diff did not change (candidateRetained with same failure)
|
|
247
254
|
const lastTwo = input.loop.attempts.slice(-2);
|
|
248
|
-
const
|
|
255
|
+
const repeatedFailureClass = lastTwo.length === 2 &&
|
|
249
256
|
lastTwo.every((attempt) => attempt.failureClass &&
|
|
250
257
|
attempt.failureClass === lastTwo[0]?.failureClass &&
|
|
251
258
|
attempt.failureClass === input.lastFailure?.failureClass);
|
|
252
|
-
|
|
259
|
+
// Measure progress using REAL evidence from actual verifier step results.
|
|
260
|
+
// Multiple signals are checked; any one constituting real progress allows continuation.
|
|
261
|
+
const measurableProgressOccurred = (() => {
|
|
262
|
+
const [prev, curr] = lastTwo;
|
|
263
|
+
if (!prev || !curr)
|
|
264
|
+
return false;
|
|
265
|
+
// Signal 1: verifierScore improved (computed from real passing/total step ratio)
|
|
266
|
+
const prevScore = prev.verifierScore ?? 0;
|
|
267
|
+
const currScore = curr.verifierScore ?? 0;
|
|
268
|
+
if (currScore > prevScore)
|
|
269
|
+
return true;
|
|
270
|
+
// Signal 2: fewer failing verifier steps than previous attempt (monotonic improvement)
|
|
271
|
+
if (prev.failingVerifierStepCount !== undefined &&
|
|
272
|
+
curr.failingVerifierStepCount !== undefined &&
|
|
273
|
+
curr.failingVerifierStepCount < prev.failingVerifierStepCount)
|
|
274
|
+
return true;
|
|
275
|
+
return false;
|
|
276
|
+
})();
|
|
277
|
+
if (repeatedFailureClass && !measurableProgressOccurred && input.lastFailure) {
|
|
253
278
|
return {
|
|
254
279
|
shouldExit: true,
|
|
255
280
|
lifecycleState: "diminishing_returns",
|
|
256
281
|
status: "exited",
|
|
257
|
-
reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts.`
|
|
282
|
+
reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts with no measurable progress (score, step count, or failure surface unchanged).`
|
|
258
283
|
};
|
|
259
284
|
}
|
|
260
285
|
if (input.lastFailure?.failureClass === "environment_mismatch" &&
|
|
@@ -398,11 +423,34 @@ export function evaluateBudgetPreflight(input) {
|
|
|
398
423
|
estimate
|
|
399
424
|
};
|
|
400
425
|
}
|
|
401
|
-
|
|
426
|
+
// D2 — Budget-aware per-attempt cap.
|
|
427
|
+
//
|
|
428
|
+
// Old behavior: rigid maxUsd * 0.25 — blocked viable attempts when total budget
|
|
429
|
+
// was large (e.g. $0.30 cost blocked on $1.00 budget because 0.30 > 0.25).
|
|
430
|
+
//
|
|
431
|
+
// New behavior: if an explicit cap is provided, honor it. Otherwise compute a
|
|
432
|
+
// cap that reserves budget for the verifier and at least one repair attempt beyond
|
|
433
|
+
// the current one, giving the provider meaningful runway.
|
|
434
|
+
//
|
|
435
|
+
// Formula: cap = remainingBudget / (remainingIterations + 1)
|
|
436
|
+
// +1 accounts for the current attempt being the "next" attempt.
|
|
437
|
+
// Minimum cap of $0.05 prevents degenerate near-zero rejections.
|
|
438
|
+
const perAttemptCap = (() => {
|
|
439
|
+
if (input.perAttemptCapUsd !== undefined)
|
|
440
|
+
return input.perAttemptCapUsd;
|
|
441
|
+
const iters = input.remainingIterations;
|
|
442
|
+
if (iters !== undefined && iters > 0) {
|
|
443
|
+
// Divide remaining budget evenly across remaining iterations.
|
|
444
|
+
return Math.max(roundUsd(input.remainingBudgetUsd / iters), 0.05);
|
|
445
|
+
}
|
|
446
|
+
// No iteration information: allow up to 80% of remaining budget per attempt
|
|
447
|
+
// (down from the old 25%-of-total which was frequently too low).
|
|
448
|
+
return Math.max(roundUsd(input.remainingBudgetUsd * 0.8), 0.05);
|
|
449
|
+
})();
|
|
402
450
|
if (estimatedAttemptCostUsd > perAttemptCap) {
|
|
403
451
|
return {
|
|
404
452
|
allowed: false,
|
|
405
|
-
reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)}.`,
|
|
453
|
+
reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)} (remaining budget $${input.remainingBudgetUsd}, ${input.remainingIterations ?? "?"} iterations left).`,
|
|
406
454
|
estimate
|
|
407
455
|
};
|
|
408
456
|
}
|
|
@@ -517,6 +565,41 @@ export function selectRecoveryRecipe(evidence) {
|
|
|
517
565
|
intervention: "escalate_human"
|
|
518
566
|
};
|
|
519
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* Explicit allowlist of reason codes that are SAFE to retain as an unverified
|
|
570
|
+
* candidate for the next repair attempt.
|
|
571
|
+
*
|
|
572
|
+
* FAIL-CLOSED: any reason code NOT in this set causes an immediate rollback.
|
|
573
|
+
* This prevents future or unknown reason codes from silently becoming "safe."
|
|
574
|
+
*
|
|
575
|
+
* Retainable only when ALL of these are also true:
|
|
576
|
+
* - scope_violation is absent (agent stayed within allowed paths)
|
|
577
|
+
* - safety_violation is absent (no secret/tenancy/test-integrity violation)
|
|
578
|
+
* - workspace observation was authoritative (changed files were tracked)
|
|
579
|
+
* - provider did not crash/fail (status !== "failed")
|
|
580
|
+
*
|
|
581
|
+
* no_code_change is explicitly NOT retainable — there is nothing to repair.
|
|
582
|
+
* Use a different recovery strategy instead.
|
|
583
|
+
*/
|
|
584
|
+
const RETAINABLE_SOFT_REASON_CODES = new Set([
|
|
585
|
+
"verifier_not_improved",
|
|
586
|
+
"low_novelty_no_progress",
|
|
587
|
+
"large_diff_no_improvement",
|
|
588
|
+
"grounding_failure",
|
|
589
|
+
"verifier_regressed", // actual test regression — retry with different approach
|
|
590
|
+
]);
|
|
591
|
+
/**
|
|
592
|
+
* Returns true ONLY when every reason code is in the explicit allowlist of
|
|
593
|
+
* retainable soft failures. Unknown or future reason codes default to UNSAFE
|
|
594
|
+
* (fail-closed), preventing silent candidate retention for unrecognized states.
|
|
595
|
+
*/
|
|
596
|
+
export function isSafeUnverifiedDiscard(reasonCodes) {
|
|
597
|
+
if (reasonCodes.length === 0) {
|
|
598
|
+
// No specific signal — we don't know why it's being discarded; fail closed.
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
return reasonCodes.every((code) => RETAINABLE_SOFT_REASON_CODES.has(code));
|
|
602
|
+
}
|
|
520
603
|
export function evaluatePatchDecision(input) {
|
|
521
604
|
const score = scorePatchDecision(input);
|
|
522
605
|
const reasonCodes = [...score.reasonCodes];
|
|
@@ -624,21 +707,41 @@ function tokenizeDiff(diff) {
|
|
|
624
707
|
return tokens;
|
|
625
708
|
}
|
|
626
709
|
function decidePatchOutcome(input, reasonCodes) {
|
|
710
|
+
// 1. Explicit human approval required — always escalate regardless of verifier.
|
|
711
|
+
// 2. Safety violation — always escalate.
|
|
627
712
|
if (input.humanApprovalRequired || reasonCodes.includes("safety_violation")) {
|
|
628
713
|
return "ESCALATE";
|
|
629
714
|
}
|
|
715
|
+
// 3. Scope violation — hard boundary: out-of-scope writes are never kept,
|
|
716
|
+
// even if the verifier passes, because the change violated the task contract.
|
|
717
|
+
if (reasonCodes.includes("scope_violation")) {
|
|
718
|
+
return "DISCARD";
|
|
719
|
+
}
|
|
720
|
+
// 4. Required mutation is absent — a green generic test suite is not proof
|
|
721
|
+
// that the requested work happened. Discard if:
|
|
722
|
+
// - a code change was required (mutation_required = edit mode)
|
|
723
|
+
// - no files were actually changed
|
|
724
|
+
// - the task was not pre-satisfied by the existing codebase
|
|
725
|
+
// Note: the scorer already suppresses no_code_change when
|
|
726
|
+
// definitionOfDonePreSatisfied is true, so that legitimate case passes through.
|
|
727
|
+
if (reasonCodes.includes("no_code_change")) {
|
|
728
|
+
return "DISCARD";
|
|
729
|
+
}
|
|
730
|
+
// 5. Passing verifier is the authoritative signal and overrides lower-confidence
|
|
731
|
+
// heuristics such as grounding failure. A grounding heuristic (symbol
|
|
732
|
+
// resolution, repo anatomy check) is weaker evidence than an actual passing
|
|
733
|
+
// host-run test exit code.
|
|
734
|
+
if (input.verificationPassed) {
|
|
735
|
+
return "KEEP";
|
|
736
|
+
}
|
|
737
|
+
// 6. Non-passing verifier: discard on any remaining negative heuristic signal.
|
|
630
738
|
if (reasonCodes.includes("grounding_failure") ||
|
|
631
|
-
reasonCodes.includes("scope_violation") ||
|
|
632
|
-
reasonCodes.includes("no_code_change") ||
|
|
633
739
|
reasonCodes.includes("verifier_regressed") ||
|
|
634
740
|
reasonCodes.includes("large_diff_no_improvement") ||
|
|
635
741
|
reasonCodes.includes("low_novelty_no_progress") ||
|
|
636
742
|
reasonCodes.includes("verifier_not_improved")) {
|
|
637
743
|
return "DISCARD";
|
|
638
744
|
}
|
|
639
|
-
if (input.verificationPassed) {
|
|
640
|
-
return "KEEP";
|
|
641
|
-
}
|
|
642
745
|
return "DISCARD";
|
|
643
746
|
}
|
|
644
747
|
function buildPatchDecisionSummary(decision, reasonCodes, summary) {
|
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
import type { PatchDecision, RollbackBoundaryArtifact, RollbackOutcomeArtifact } from "../contracts/index.js";
|
|
2
|
+
export interface IgnoredFileChanges {
|
|
3
|
+
created: string[];
|
|
4
|
+
modified: string[];
|
|
5
|
+
deleted: string[];
|
|
6
|
+
}
|
|
2
7
|
export declare class RollbackStateUnavailableError extends Error {
|
|
3
8
|
constructor(message: string);
|
|
4
9
|
}
|
|
5
10
|
export declare function listAttemptChangedFilesSinceBoundary(input: {
|
|
6
11
|
repoRoot?: string;
|
|
7
12
|
boundary?: RollbackBoundaryArtifact;
|
|
13
|
+
/** Include content changes to dirty/untracked paths that existed at the boundary. */
|
|
14
|
+
includeBaselineSnapshotChanges?: boolean;
|
|
8
15
|
}): string[];
|
|
9
16
|
export declare function captureRollbackBoundary(input: {
|
|
10
17
|
repoRoot?: string;
|
|
11
18
|
capturedAt: string;
|
|
12
19
|
}): Promise<RollbackBoundaryArtifact | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Git-ignored files the attempt created or modified since the boundary.
|
|
22
|
+
* Boundaries recorded before the ignored inventory existed report nothing.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listIgnoredFileChangesSinceBoundary(input: {
|
|
25
|
+
repoRoot?: string;
|
|
26
|
+
boundary?: RollbackBoundaryArtifact;
|
|
27
|
+
}): IgnoredFileChanges;
|
|
13
28
|
export declare function restoreRollbackBoundary(input: {
|
|
14
29
|
repoRoot?: string;
|
|
15
30
|
boundary?: RollbackBoundaryArtifact;
|
|
16
31
|
restoredAt: string;
|
|
17
32
|
decision: PatchDecision;
|
|
33
|
+
/** Extra attempt-created paths (for example ignored files) to delete. */
|
|
34
|
+
removePaths?: string[];
|
|
35
|
+
/** Pre-existing ignored paths to restore from the in-memory boundary. */
|
|
36
|
+
restoreIgnoredPaths?: string[];
|
|
18
37
|
}): Promise<RollbackOutcomeArtifact | undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Repo-relative links (symlinks or junctions) among `paths` whose target
|
|
40
|
+
* resolves outside the repository. Paths are first collapsed to the link.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findEscapingLinks(repoRoot: string, paths: string[]): string[];
|