pi-workflow-engine 0.11.0 → 0.13.0
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/.pi/extensions/pi-workflow-engine/index.ts +255 -97
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +270 -17
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_RESEARCH_LANES,
|
|
3
|
+
MAX_VERIFICATION_CLAIMS,
|
|
4
|
+
type ResearchClaimCandidate,
|
|
5
|
+
type ResearchEvidence,
|
|
6
|
+
type ResearchLane,
|
|
7
|
+
type ResearchLaneResult,
|
|
8
|
+
type ResearchPlan,
|
|
9
|
+
type ResearchReport,
|
|
10
|
+
type ResearchReportEntry,
|
|
11
|
+
type ResearchSource,
|
|
12
|
+
type ResearchVerification,
|
|
13
|
+
} from "./research-contract.ts";
|
|
14
|
+
|
|
15
|
+
const TRACKING_PARAMETERS = new Set(["fbclid", "gclid", "mc_cid", "mc_eid", "ref_src"]);
|
|
16
|
+
const IMPORTANCE_RANK: Readonly<Record<ResearchEvidence["importance"], number>> = { high: 0, medium: 1, low: 2 };
|
|
17
|
+
|
|
18
|
+
export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
const lanes: ResearchLane[] = [];
|
|
21
|
+
for (const lane of plan.lanes) {
|
|
22
|
+
const id = normalizedText(lane.id);
|
|
23
|
+
const title = normalizedText(lane.title);
|
|
24
|
+
const objective = normalizedText(lane.objective);
|
|
25
|
+
const queries = dedupeText(lane.queries).slice(0, 4);
|
|
26
|
+
if (!id || !title || !objective || queries.length === 0 || seen.has(id.toLowerCase())) continue;
|
|
27
|
+
seen.add(id.toLowerCase());
|
|
28
|
+
lanes.push({ id, title, objective, queries });
|
|
29
|
+
if (lanes.length >= MAX_RESEARCH_LANES) break;
|
|
30
|
+
}
|
|
31
|
+
return lanes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeLaneResults(results: readonly ResearchLaneResult[]): ResearchLaneResult[] {
|
|
35
|
+
return results.map((result) => ({
|
|
36
|
+
laneId: normalizedText(result.laneId),
|
|
37
|
+
gaps: dedupeText(result.gaps),
|
|
38
|
+
evidence: dedupeEvidence(result.evidence.map(sanitizeEvidence).filter(isDefined)),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildClaimCandidates(results: readonly ResearchLaneResult[]): ResearchClaimCandidate[] {
|
|
43
|
+
const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
|
|
44
|
+
for (const item of results.flatMap((result) => result.evidence)) {
|
|
45
|
+
const key = normalizedText(item.claim).toLowerCase();
|
|
46
|
+
if (!key) continue;
|
|
47
|
+
const current = byClaim.get(key);
|
|
48
|
+
if (!current) {
|
|
49
|
+
byClaim.set(key, { claim: normalizedText(item.claim), importance: item.importance, evidence: [item] });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
|
|
53
|
+
current.evidence = dedupeEvidence([...current.evidence, item]);
|
|
54
|
+
}
|
|
55
|
+
return [...byClaim.values()]
|
|
56
|
+
.sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance])
|
|
57
|
+
.slice(0, MAX_VERIFICATION_CLAIMS);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sanitizeVerification(
|
|
61
|
+
verification: ResearchVerification,
|
|
62
|
+
candidate: ResearchClaimCandidate,
|
|
63
|
+
): ResearchVerification {
|
|
64
|
+
const sources = dedupeSources(verification.sources);
|
|
65
|
+
const verdict = (verification.verdict === "SUPPORTED" || verification.verdict === "CONFLICTED") && sources.length === 0
|
|
66
|
+
? "UNCERTAIN"
|
|
67
|
+
: verification.verdict;
|
|
68
|
+
return {
|
|
69
|
+
claim: candidate.claim,
|
|
70
|
+
verdict,
|
|
71
|
+
explanation: normalizedText(verification.explanation) || "Independent verification returned no explanation.",
|
|
72
|
+
sources,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function unavailableVerification(candidate: ResearchClaimCandidate): ResearchVerification {
|
|
77
|
+
return {
|
|
78
|
+
claim: candidate.claim,
|
|
79
|
+
verdict: "UNCERTAIN",
|
|
80
|
+
explanation: "Independent verification did not complete; gathered evidence remains unverified.",
|
|
81
|
+
sources: dedupeSources(candidate.evidence.map((item) => item.source)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function sanitizeResearchReport(
|
|
86
|
+
report: ResearchReport,
|
|
87
|
+
verifications: readonly ResearchVerification[],
|
|
88
|
+
): ResearchReport {
|
|
89
|
+
const sources = dedupeSources(
|
|
90
|
+
verifications
|
|
91
|
+
.filter((verification) => verification.verdict !== "REJECTED")
|
|
92
|
+
.flatMap((verification) => verification.sources),
|
|
93
|
+
);
|
|
94
|
+
const uncertainties = sanitizeEntries(
|
|
95
|
+
report.uncertainties,
|
|
96
|
+
verificationSources(verifications, "UNCERTAIN"),
|
|
97
|
+
false,
|
|
98
|
+
);
|
|
99
|
+
const supportedClaims = sanitizeEntries(
|
|
100
|
+
report.supportedClaims,
|
|
101
|
+
verificationSources(verifications, "SUPPORTED"),
|
|
102
|
+
true,
|
|
103
|
+
uncertainties,
|
|
104
|
+
);
|
|
105
|
+
const conflictingEvidence = sanitizeEntries(
|
|
106
|
+
report.conflictingEvidence,
|
|
107
|
+
verificationSources(verifications, "CONFLICTED"),
|
|
108
|
+
true,
|
|
109
|
+
uncertainties,
|
|
110
|
+
);
|
|
111
|
+
const inferences = sanitizeEntries(
|
|
112
|
+
report.inferences,
|
|
113
|
+
verificationSources(verifications, "INFERENCE"),
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
answer: normalizedText(report.answer),
|
|
118
|
+
supportedClaims,
|
|
119
|
+
conflictingEvidence,
|
|
120
|
+
uncertainties,
|
|
121
|
+
inferences,
|
|
122
|
+
sources,
|
|
123
|
+
limitations: dedupeText(report.limitations),
|
|
124
|
+
nextSteps: dedupeText(report.nextSteps),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function verificationSources(
|
|
129
|
+
verifications: readonly ResearchVerification[],
|
|
130
|
+
verdict: ResearchVerification["verdict"],
|
|
131
|
+
): ReadonlyMap<string, ReadonlyMap<string, ResearchSource>> {
|
|
132
|
+
const byClaim = new Map<string, Map<string, ResearchSource>>();
|
|
133
|
+
for (const verification of verifications) {
|
|
134
|
+
if (verification.verdict !== verdict) continue;
|
|
135
|
+
const key = normalizedText(verification.claim).toLowerCase();
|
|
136
|
+
const claimSources = byClaim.get(key) ?? new Map<string, ResearchSource>();
|
|
137
|
+
dedupeSources(
|
|
138
|
+
verification.sources,
|
|
139
|
+
).forEach((source) => claimSources.set(source.url, source));
|
|
140
|
+
byClaim.set(key, claimSources);
|
|
141
|
+
}
|
|
142
|
+
return byClaim;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function fallbackResearchReport(
|
|
146
|
+
verifications: readonly ResearchVerification[],
|
|
147
|
+
limitation: string,
|
|
148
|
+
): ResearchReport {
|
|
149
|
+
const entry = (verification: ResearchVerification): ResearchReportEntry => ({
|
|
150
|
+
claim: verification.claim,
|
|
151
|
+
explanation: verification.explanation,
|
|
152
|
+
citations: verification.sources,
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
answer: "The synthesis stage did not complete; independently verified claims are grouped below without additional model interpretation.",
|
|
156
|
+
supportedClaims: verifications.filter((item) => item.verdict === "SUPPORTED").map(entry),
|
|
157
|
+
conflictingEvidence: verifications.filter((item) => item.verdict === "CONFLICTED").map(entry),
|
|
158
|
+
uncertainties: verifications.filter((item) => item.verdict === "UNCERTAIN").map(entry),
|
|
159
|
+
inferences: verifications.filter((item) => item.verdict === "INFERENCE").map(entry),
|
|
160
|
+
sources: dedupeSources(verifications.flatMap((verification) => verification.sources)),
|
|
161
|
+
limitations: [limitation],
|
|
162
|
+
nextSteps: ["Review the verified claim groups and rerun synthesis if a narrative answer is required."],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function unavailableResearchReport(reason: "empty-question" | "missing-capability" | "no-evidence"): ResearchReport {
|
|
167
|
+
const message = reason === "empty-question"
|
|
168
|
+
? "No research question was provided."
|
|
169
|
+
: reason === "missing-capability"
|
|
170
|
+
? "Research could not start because pi exposed no installed external web-search or URL-extraction tool."
|
|
171
|
+
: "Research completed without enough direct-page evidence to support an answer.";
|
|
172
|
+
const nextStep = reason === "empty-question"
|
|
173
|
+
? "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments."
|
|
174
|
+
: reason === "missing-capability"
|
|
175
|
+
? "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow."
|
|
176
|
+
: "Check the installed external-search tool, narrow the question, or provide preferred source domains.";
|
|
177
|
+
return {
|
|
178
|
+
answer: message,
|
|
179
|
+
supportedClaims: [],
|
|
180
|
+
conflictingEvidence: [],
|
|
181
|
+
uncertainties: [],
|
|
182
|
+
inferences: [],
|
|
183
|
+
sources: [],
|
|
184
|
+
limitations: [message],
|
|
185
|
+
nextSteps: [nextStep],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function canonicalSourceUrl(raw: string): string | undefined {
|
|
190
|
+
try {
|
|
191
|
+
const url = new URL(raw.trim());
|
|
192
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined;
|
|
193
|
+
if (isSearchResultsUrl(url)) return undefined;
|
|
194
|
+
url.hash = "";
|
|
195
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
196
|
+
if (key.toLowerCase().startsWith("utm_") || TRACKING_PARAMETERS.has(key.toLowerCase())) url.searchParams.delete(key);
|
|
197
|
+
}
|
|
198
|
+
url.searchParams.sort();
|
|
199
|
+
if (url.pathname.length > 1 && url.pathname.endsWith("/")) url.pathname = url.pathname.slice(0, -1);
|
|
200
|
+
return url.toString();
|
|
201
|
+
} catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function dedupeSources(sources: readonly ResearchSource[]): ResearchSource[] {
|
|
207
|
+
const byUrl = new Map<string, ResearchSource>();
|
|
208
|
+
for (const source of sources) {
|
|
209
|
+
const url = canonicalSourceUrl(source.url);
|
|
210
|
+
const title = normalizedText(source.title);
|
|
211
|
+
if (!url || !title || byUrl.has(url)) continue;
|
|
212
|
+
const publishedAt = source.publishedAt === undefined ? undefined : normalizedText(source.publishedAt);
|
|
213
|
+
byUrl.set(url, { title, url, ...(publishedAt ? { publishedAt } : {}) });
|
|
214
|
+
}
|
|
215
|
+
return [...byUrl.values()];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sanitizeEvidence(item: ResearchEvidence): ResearchEvidence | undefined {
|
|
219
|
+
const source = dedupeSources([item.source])[0];
|
|
220
|
+
const claim = normalizedText(item.claim);
|
|
221
|
+
const evidence = normalizedText(item.evidence);
|
|
222
|
+
if (!source || !claim || !evidence) return undefined;
|
|
223
|
+
return { claim, importance: item.importance, stance: item.stance, evidence, source };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function dedupeEvidence(evidence: readonly ResearchEvidence[]): ResearchEvidence[] {
|
|
227
|
+
const seen = new Set<string>();
|
|
228
|
+
return evidence.filter((item) => {
|
|
229
|
+
const key = `${item.claim.toLowerCase()}\0${item.stance}\0${item.source.url}`;
|
|
230
|
+
if (seen.has(key)) return false;
|
|
231
|
+
seen.add(key);
|
|
232
|
+
return true;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sanitizeEntries(
|
|
237
|
+
entries: readonly ResearchReportEntry[],
|
|
238
|
+
allowedByClaim: ReadonlyMap<string, ReadonlyMap<string, ResearchSource>>,
|
|
239
|
+
requireCitation: boolean,
|
|
240
|
+
demoted: ResearchReportEntry[] = [],
|
|
241
|
+
): ResearchReportEntry[] {
|
|
242
|
+
const sanitized: ResearchReportEntry[] = [];
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const claim = normalizedText(entry.claim);
|
|
245
|
+
const explanation = normalizedText(entry.explanation);
|
|
246
|
+
if (!claim || !explanation) continue;
|
|
247
|
+
const allowed = allowedByClaim.get(claim.toLowerCase()) ?? new Map<string, ResearchSource>();
|
|
248
|
+
const citations = dedupeSources(entry.citations)
|
|
249
|
+
.map((source) => allowed.get(source.url))
|
|
250
|
+
.filter(isDefined);
|
|
251
|
+
if (requireCitation && citations.length === 0) {
|
|
252
|
+
demoted.push({ claim, explanation: `${explanation} No verified direct-page citation survived validation.`, citations: [] });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
sanitized.push({ claim, explanation, citations });
|
|
256
|
+
}
|
|
257
|
+
return sanitized;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isSearchResultsUrl(url: URL): boolean {
|
|
261
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
262
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
263
|
+
if (/^(?:google\.[a-z.]+|bing\.com|search\.yahoo\.com|search\.brave\.com|yandex\.[a-z.]+)$/.test(host)) {
|
|
264
|
+
return path === "/search" || path === "/results" || path === "/yandsearch";
|
|
265
|
+
}
|
|
266
|
+
if (host === "duckduckgo.com") return (path === "/" || path === "/html" || path === "/lite") && url.searchParams.has("q");
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function dedupeText(values: readonly string[]): string[] {
|
|
271
|
+
const seen = new Set<string>();
|
|
272
|
+
const result: string[] = [];
|
|
273
|
+
for (const value of values) {
|
|
274
|
+
const normalized = normalizedText(value);
|
|
275
|
+
const key = normalized.toLowerCase();
|
|
276
|
+
if (!normalized || seen.has(key)) continue;
|
|
277
|
+
seen.add(key);
|
|
278
|
+
result.push(normalized);
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizedText(value: string): string {
|
|
284
|
+
return value.trim().replace(/\s+/g, " ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isDefined<T>(value: T | undefined): value is T {
|
|
288
|
+
return value !== undefined;
|
|
289
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { lstat, stat } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { throwIfAborted } from "./cancellation.ts";
|
|
5
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
4
6
|
import type {
|
|
5
7
|
EffectiveAgentSessionIdentity,
|
|
6
8
|
EffectiveToolIdentity,
|
|
@@ -66,6 +68,10 @@ export interface AgentResumeContext extends VerifiedAgentResumeBaseContext {
|
|
|
66
68
|
readonly skills: readonly ResolvedSkillIdentity[];
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
export interface ResumeContextComparisonOptions {
|
|
72
|
+
readonly allowWorkflowSourceMismatch?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
type RepositoryRevision =
|
|
70
76
|
| { readonly state: "git"; readonly head: string }
|
|
71
77
|
| { readonly state: "unborn" };
|
|
@@ -306,57 +312,85 @@ async function captureGitVisibleStateUnchecked(
|
|
|
306
312
|
]);
|
|
307
313
|
throwIfAborted(signal);
|
|
308
314
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
315
|
+
const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
|
|
316
|
+
if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
|
|
317
|
+
|
|
318
|
+
const untracked = await captureDeclaredInputFingerprint({
|
|
319
|
+
root: cwd,
|
|
320
|
+
inputs: validated.untrackedPaths.filter((path) => !isFingerprintExcludedPath(path)),
|
|
321
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
322
|
+
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
323
|
+
maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
|
|
324
|
+
signal,
|
|
325
|
+
});
|
|
326
|
+
if (untracked.kind === "unverifiable") return untracked;
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
kind: "verified",
|
|
330
|
+
fingerprint: combineFingerprints([
|
|
331
|
+
["unstaged", validated.unstaged],
|
|
332
|
+
["staged", validated.staged],
|
|
333
|
+
["index", validated.indexEntries],
|
|
334
|
+
["untracked", untracked.fingerprint],
|
|
335
|
+
]),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface GitVisibleProcessOutputs {
|
|
340
|
+
readonly unstaged: BoundedProcessResult;
|
|
341
|
+
readonly unstagedRaw: BoundedProcessResult;
|
|
342
|
+
readonly staged: BoundedProcessResult;
|
|
343
|
+
readonly untrackedPaths: BoundedProcessResult;
|
|
344
|
+
readonly indexEntries: BoundedProcessResult;
|
|
345
|
+
readonly trackedFlags: BoundedProcessResult;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
type ValidatedGitVisibleOutputs =
|
|
349
|
+
| {
|
|
350
|
+
readonly ok: true;
|
|
351
|
+
readonly unstaged: string;
|
|
352
|
+
readonly staged: string;
|
|
353
|
+
readonly indexEntries: string;
|
|
354
|
+
readonly untrackedPaths: readonly string[];
|
|
355
|
+
}
|
|
356
|
+
| { readonly ok: false; readonly reason: string };
|
|
357
|
+
|
|
358
|
+
function validateGitVisibleOutputs(outputs: GitVisibleProcessOutputs): ValidatedGitVisibleOutputs {
|
|
359
|
+
const { unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags } = outputs;
|
|
360
|
+
if (!unstaged.ok) return { ok: false, reason: processFailureReason("git unstaged diff capture", unstaged) };
|
|
361
|
+
if (!unstagedRaw.ok) return { ok: false, reason: processFailureReason("git unstaged mode capture", unstagedRaw) };
|
|
362
|
+
if (!staged.ok) return { ok: false, reason: processFailureReason("git staged diff capture", staged) };
|
|
363
|
+
if (!untrackedPaths.ok) return { ok: false, reason: processFailureReason("git untracked path capture", untrackedPaths) };
|
|
364
|
+
if (!indexEntries.ok) return { ok: false, reason: processFailureReason("git index entry capture", indexEntries) };
|
|
365
|
+
if (!trackedFlags.ok) return { ok: false, reason: processFailureReason("git tracked flag capture", trackedFlags) };
|
|
319
366
|
|
|
320
367
|
const unsafeWorktreeMode = rawWorktreeModeFailure(unstagedRaw.stdout);
|
|
321
|
-
if (unsafeWorktreeMode) return {
|
|
368
|
+
if (unsafeWorktreeMode) return { ok: false, reason: unsafeWorktreeMode };
|
|
322
369
|
|
|
323
370
|
for (const record of parseNullTerminatedRecords(indexEntries.stdout, "git index entry capture")) {
|
|
324
371
|
const entry = parseIndexEntry(record);
|
|
325
372
|
if (entry.stage !== 0) {
|
|
326
|
-
return {
|
|
373
|
+
return { ok: false, reason: "git-visible repository state contains unmerged paths" };
|
|
327
374
|
}
|
|
328
375
|
const unsafe = unsafeTrackedModeReason(entry.mode, entry.path);
|
|
329
|
-
if (unsafe) return {
|
|
376
|
+
if (unsafe) return { ok: false, reason: unsafe };
|
|
330
377
|
}
|
|
331
378
|
|
|
332
379
|
const unsafeFlag = parseNullTerminatedRecords(trackedFlags.stdout, "git tracked flag capture")
|
|
333
380
|
.find((record) => record[0] !== "H");
|
|
334
381
|
if (unsafeFlag) {
|
|
335
382
|
return {
|
|
336
|
-
|
|
383
|
+
ok: false,
|
|
337
384
|
reason: `git-visible repository state contains an unsupported tracked-file flag: ${unsafeFlag[0] ?? "unknown"}`,
|
|
338
385
|
};
|
|
339
386
|
}
|
|
340
387
|
|
|
341
|
-
const untracked = await captureDeclaredInputFingerprint({
|
|
342
|
-
root: cwd,
|
|
343
|
-
inputs: parseNullTerminatedRecords(untrackedPaths.stdout, "git untracked path capture")
|
|
344
|
-
.filter((path) => !isFingerprintExcludedPath(path)),
|
|
345
|
-
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
346
|
-
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
347
|
-
maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
|
|
348
|
-
signal,
|
|
349
|
-
});
|
|
350
|
-
if (untracked.kind === "unverifiable") return untracked;
|
|
351
|
-
|
|
352
388
|
return {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
["untracked", untracked.fingerprint],
|
|
359
|
-
]),
|
|
389
|
+
ok: true,
|
|
390
|
+
unstaged: unstaged.stdout,
|
|
391
|
+
staged: staged.stdout,
|
|
392
|
+
indexEntries: indexEntries.stdout,
|
|
393
|
+
untrackedPaths: parseNullTerminatedRecords(untrackedPaths.stdout, "git untracked path capture"),
|
|
360
394
|
};
|
|
361
395
|
}
|
|
362
396
|
|
|
@@ -470,7 +504,11 @@ export function createAgentResumeContext(
|
|
|
470
504
|
};
|
|
471
505
|
}
|
|
472
506
|
|
|
473
|
-
export function resumeContextMismatchReason(
|
|
507
|
+
export function resumeContextMismatchReason(
|
|
508
|
+
stored: AgentResumeContext,
|
|
509
|
+
current: AgentResumeContext,
|
|
510
|
+
options: ResumeContextComparisonOptions = {},
|
|
511
|
+
): string | undefined {
|
|
474
512
|
if (stored.repository.state !== current.repository.state) return "repository state changed";
|
|
475
513
|
if (
|
|
476
514
|
(stored.repository.state === "git" || stored.repository.state === "isolated") &&
|
|
@@ -483,7 +521,10 @@ export function resumeContextMismatchReason(stored: AgentResumeContext, current:
|
|
|
483
521
|
return "working tree contents changed";
|
|
484
522
|
}
|
|
485
523
|
if (stored.workflow.name !== current.workflow.name) return "workflow name changed";
|
|
486
|
-
if (
|
|
524
|
+
if (
|
|
525
|
+
!options.allowWorkflowSourceMismatch
|
|
526
|
+
&& stored.workflow.sourceFingerprint !== current.workflow.sourceFingerprint
|
|
527
|
+
) return "workflow source changed";
|
|
487
528
|
if (stored.session.runtimeVersion !== current.session.runtimeVersion) return "coding-agent runtime changed";
|
|
488
529
|
if (stored.session.systemPromptFingerprint !== current.session.systemPromptFingerprint) return "effective system prompt changed";
|
|
489
530
|
if (
|
|
@@ -535,6 +576,54 @@ async function runGit(cwd: string, args: readonly string[], signal: AbortSignal
|
|
|
535
576
|
});
|
|
536
577
|
}
|
|
537
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Decide whether a `.git` entry found during the ancestor walk marks a repository
|
|
581
|
+
* that git may have failed to open, or a stray entry that git's own discovery
|
|
582
|
+
* also skips.
|
|
583
|
+
*
|
|
584
|
+
* The rule mirrors git's discovery loop so that "absent" means git itself would
|
|
585
|
+
* have walked past every `.git` entry and its "not a repository" answer is
|
|
586
|
+
* authoritative:
|
|
587
|
+
*
|
|
588
|
+
* - A regular file is a gitfile. Git halts on any regular `.git` file, even when
|
|
589
|
+
* its content is malformed or its `gitdir:` target is missing, so the entry is
|
|
590
|
+
* a marker regardless of content.
|
|
591
|
+
* - A directory is a repository only when `HEAD`, `objects/`, and `refs/` all
|
|
592
|
+
* exist. Git skips a directory that lacks any of them (an empty `.git` is the
|
|
593
|
+
* common case), so such a directory is not a marker.
|
|
594
|
+
* - A symbolic link is resolved first, as git uses `stat`. A dangling link is
|
|
595
|
+
* skipped by git and is therefore not a marker.
|
|
596
|
+
* - Any other entry type (socket, FIFO, device) is skipped by git.
|
|
597
|
+
*
|
|
598
|
+
* A missing path (`ENOENT`) is a negative answer, not a failure. Any other
|
|
599
|
+
* filesystem error propagates so the caller reports the probe as "unknown".
|
|
600
|
+
*/
|
|
601
|
+
async function isRepositoryMarker(entryPath: string, entry: Stats): Promise<boolean> {
|
|
602
|
+
let target = entry;
|
|
603
|
+
if (entry.isSymbolicLink()) {
|
|
604
|
+
const resolved = await statIfPresent(entryPath);
|
|
605
|
+
if (!resolved) return false;
|
|
606
|
+
target = resolved;
|
|
607
|
+
}
|
|
608
|
+
if (target.isFile()) return true;
|
|
609
|
+
if (!target.isDirectory()) return false;
|
|
610
|
+
for (const name of GIT_DIRECTORY_REQUIRED_ENTRIES) {
|
|
611
|
+
if (!(await statIfPresent(join(entryPath, name)))) return false;
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const GIT_DIRECTORY_REQUIRED_ENTRIES = ["HEAD", "objects", "refs"] as const;
|
|
617
|
+
|
|
618
|
+
async function statIfPresent(path: string): Promise<Stats | undefined> {
|
|
619
|
+
try {
|
|
620
|
+
return await stat(path);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (isMissingPathError(error)) return undefined;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
538
627
|
type GitControlPathResult =
|
|
539
628
|
| { readonly kind: "present" | "absent" }
|
|
540
629
|
| { readonly kind: "unknown"; readonly reason: string };
|
|
@@ -543,9 +632,10 @@ async function findGitControlPath(cwd: string, signal: AbortSignal | undefined):
|
|
|
543
632
|
let current = resolve(cwd);
|
|
544
633
|
while (true) {
|
|
545
634
|
throwIfAborted(signal);
|
|
635
|
+
const entryPath = join(current, ".git");
|
|
546
636
|
try {
|
|
547
|
-
await lstat(
|
|
548
|
-
return { kind: "present" };
|
|
637
|
+
const entry = await lstat(entryPath);
|
|
638
|
+
if (await isRepositoryMarker(entryPath, entry)) return { kind: "present" };
|
|
549
639
|
} catch (error) {
|
|
550
640
|
if (!isMissingPathError(error)) {
|
|
551
641
|
return { kind: "unknown", reason: `git control-path probe failed: ${unknownErrorMessage(error)}` };
|
|
@@ -619,10 +709,6 @@ function isEffectiveToolIdentity(value: unknown): value is EffectiveToolIdentity
|
|
|
619
709
|
);
|
|
620
710
|
}
|
|
621
711
|
|
|
622
|
-
function isMissingPathError(error: unknown): boolean {
|
|
623
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
624
|
-
}
|
|
625
|
-
|
|
626
712
|
function parseNullTerminatedRecords(output: string, operation: string): string[] {
|
|
627
713
|
if (output.length === 0) return [];
|
|
628
714
|
if (!output.endsWith("\0")) throw new Error(`${operation} returned unterminated data`);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const DIFF_EMBED_CAP = 60_000;
|
|
2
|
+
|
|
3
|
+
export function buildCodeReviewScopeBlock(input: {
|
|
4
|
+
readonly diffCommand: string;
|
|
5
|
+
readonly files: readonly string[];
|
|
6
|
+
readonly summary: string;
|
|
7
|
+
readonly conventions?: string;
|
|
8
|
+
readonly diffText: string;
|
|
9
|
+
readonly target: string;
|
|
10
|
+
}): string {
|
|
11
|
+
const diffBlock = input.diffText
|
|
12
|
+
? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
|
|
13
|
+
input.diffText.length > DIFF_EMBED_CAP
|
|
14
|
+
? `${input.diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${input.diffCommand}\` for the full diff)`
|
|
15
|
+
: input.diffText
|
|
16
|
+
}\n\`\`\`\n`
|
|
17
|
+
: "";
|
|
18
|
+
return (
|
|
19
|
+
`## Diff command\n${input.diffCommand}\n\n## Changed files\n${input.files.map((file) => `- ${file}`).join("\n")}\n\n` +
|
|
20
|
+
`## Summary\n${input.summary}\n\n## Conventions\n${input.conventions ?? "(none noted)"}\n` +
|
|
21
|
+
diffBlock +
|
|
22
|
+
(input.target ? `\n## User instructions (verbatim)\n${input.target}\n` : "")
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
4
|
+
import { runBoundedProcess, type BoundedProcessResult } from "../process-runner.ts";
|
|
5
|
+
import { WorktreeRegistry, type WorktreeBaseline } from "../worktree.ts";
|
|
6
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
7
|
+
import { fingerprintReviewWorktreeBaseline } from "./review-snapshot.ts";
|
|
8
|
+
|
|
9
|
+
export const PatchEvaluationSchema = Type.Object({
|
|
10
|
+
outcome: Type.Union([Type.Literal("accepted"), Type.Literal("rejected"), Type.Literal("blocked")]),
|
|
11
|
+
reason: Type.String(),
|
|
12
|
+
checks: Type.Array(Type.Object({
|
|
13
|
+
file: Type.String({ description: "Executable for a focused validation command, not a shell command string." }),
|
|
14
|
+
args: Type.Array(Type.String()),
|
|
15
|
+
required: Type.Boolean(),
|
|
16
|
+
regression: Type.Optional(Type.Object({
|
|
17
|
+
baselinePatch: Type.String({ description: "Test-only patch to apply to the original baseline to reproduce the bug; exclude the repair." }),
|
|
18
|
+
expectedFailure: Type.String({ description: "Specific failure text proving the intended defect, not a missing dependency or setup error." }),
|
|
19
|
+
})),
|
|
20
|
+
}), { maxItems: 6 }),
|
|
21
|
+
});
|
|
22
|
+
export type PatchEvaluation = Static<typeof PatchEvaluationSchema>;
|
|
23
|
+
export type PatchCandidateStatus = "verified" | "rejected" | "blocked" | "no-patch";
|
|
24
|
+
export interface PatchValidation {
|
|
25
|
+
status: PatchCandidateStatus;
|
|
26
|
+
baselineFingerprint: string;
|
|
27
|
+
baselineOid?: string;
|
|
28
|
+
patchHash: string;
|
|
29
|
+
checks: { file: string; args: string[]; required: boolean; stage: "candidate" | "baseline"; result: BoundedProcessResult }[];
|
|
30
|
+
evaluation?: PatchEvaluation;
|
|
31
|
+
reason: string;
|
|
32
|
+
cleanupError?: string;
|
|
33
|
+
}
|
|
34
|
+
export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid: string | undefined, patch: string): PatchValidation {
|
|
35
|
+
return { status: patch.trim() ? "blocked" : "no-patch", baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline), ...(baselineOid ? { baselineOid } : {}),
|
|
36
|
+
patchHash: createHash("sha256").update(patch).digest("hex"), checks: [], reason: patch.trim() ? "Independent evaluation has not completed." : "The implementer produced no patch; this does not establish that no change is needed." };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PatchValidationOptions {
|
|
40
|
+
cwd: string; baseline: WorktreeBaseline; expectedFingerprint: string; baselineOid: string; patch: string;
|
|
41
|
+
evaluation: PatchEvaluation; signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reconstruct the exact reviewed baseline and retain evidence even if cleanup fails. */
|
|
45
|
+
export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
|
|
46
|
+
const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
|
|
47
|
+
validation.evaluation = options.evaluation;
|
|
48
|
+
if (validation.baselineFingerprint !== options.expectedFingerprint) return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
|
|
49
|
+
if (!options.patch.trim()) return validation;
|
|
50
|
+
const worktrees = new WorktreeRegistry(options.cwd);
|
|
51
|
+
let result = validation;
|
|
52
|
+
try {
|
|
53
|
+
result = await evaluateCandidateInWorktrees(worktrees, options, validation);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throwIfAborted(options.signal);
|
|
56
|
+
result = { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
57
|
+
} finally {
|
|
58
|
+
try {
|
|
59
|
+
await worktrees.removeAll();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const cleanupError = unknownErrorMessage(error);
|
|
62
|
+
result = {
|
|
63
|
+
...result,
|
|
64
|
+
status: result.status === "verified" ? "blocked" : result.status,
|
|
65
|
+
cleanupError,
|
|
66
|
+
reason: `${result.reason}\nCleanup failed: ${cleanupError}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
throwIfAborted(options.signal);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function evaluateCandidateInWorktrees(
|
|
75
|
+
worktrees: WorktreeRegistry, options: PatchValidationOptions, validation: PatchValidation,
|
|
76
|
+
): Promise<PatchValidation> {
|
|
77
|
+
const candidate = await worktrees.add(options.signal, options.baseline);
|
|
78
|
+
if ("error" in candidate) return { ...validation, reason: candidate.error };
|
|
79
|
+
if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
|
|
80
|
+
const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
|
|
81
|
+
if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error ?? applied.stderr };
|
|
82
|
+
const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
|
|
83
|
+
const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
|
|
84
|
+
if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
|
|
85
|
+
for (const check of options.evaluation.checks) {
|
|
86
|
+
const result = await runCheck(check, candidate.path, options.signal);
|
|
87
|
+
validation.checks.push({ ...check, stage: "candidate", result });
|
|
88
|
+
throwIfAborted(options.signal);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
if (result.failure.kind === "exit") rejected.push(`${check.file}: ${result.failure.message}`);
|
|
91
|
+
else if (check.required) blocked.push(`${check.file}: ${result.failure.message}`);
|
|
92
|
+
}
|
|
93
|
+
if (check.regression) {
|
|
94
|
+
const original = await worktrees.add(options.signal, options.baseline);
|
|
95
|
+
if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
|
|
96
|
+
const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
|
|
97
|
+
if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error ?? testApplied.stderr}`); continue; }
|
|
98
|
+
const baselineResult = await runCheck(check, original.path, options.signal);
|
|
99
|
+
validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
|
|
100
|
+
throwIfAborted(options.signal);
|
|
101
|
+
if (baselineResult.ok || baselineResult.failure.kind !== "exit" || !check.regression.expectedFailure.trim() ||
|
|
102
|
+
!(baselineResult.stdout + baselineResult.stderr).includes(check.regression.expectedFailure)) {
|
|
103
|
+
blocked.push(`${check.file}: baseline did not reproduce the expected failure: ${check.regression.expectedFailure}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Tests must not silently replace the candidate under evaluation.
|
|
108
|
+
const after = await worktrees.capturePatch(candidate.path, candidate.baselineOid, options.signal);
|
|
109
|
+
if ("error" in after) blocked.push(`Candidate capture failed: ${after.error}`);
|
|
110
|
+
else if (after.patch !== options.patch) blocked.push("Validation checks changed the candidate patch.");
|
|
111
|
+
if (rejected.length > 0) return { ...validation, status: "rejected", reason: [...rejected, ...blocked].join("\n") };
|
|
112
|
+
if (blocked.length > 0) return { ...validation, status: "blocked", reason: blocked.join("\n") };
|
|
113
|
+
return { ...validation, status: "verified", reason: "Independent evaluation and all required checks passed." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runCheck(check: PatchEvaluation["checks"][number], cwd: string, signal?: AbortSignal): Promise<BoundedProcessResult> {
|
|
117
|
+
return runBoundedProcess({ file: check.file, args: check.args, cwd, signal, timeoutMs: 120_000, maxBufferBytes: 1 << 20,
|
|
118
|
+
abortError: "Validation aborted", timeoutError: "Validation timed out", maxBufferError: "Validation output exceeded its limit",
|
|
119
|
+
exitError: (stderr, code) => stderr.trim() || `Validation exited with ${code}` });
|
|
120
|
+
}
|