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.
Files changed (79) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  8. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  10. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  17. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  18. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  19. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  20. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
  21. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  22. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  31. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  32. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  33. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  34. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  35. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  36. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  37. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
  38. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  48. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  49. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  50. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  51. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  52. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  53. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  54. package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
  55. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  56. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  57. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  58. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
  59. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  61. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  64. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  65. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  66. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  67. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  68. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  70. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  71. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
  72. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
  73. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
  74. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
  75. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
  76. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  77. package/README.md +72 -141
  78. package/USAGE.md +270 -17
  79. 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 { lstat } from "node:fs/promises";
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
- for (const [operation, result] of [
310
- ["unstaged diff capture", unstaged],
311
- ["unstaged mode capture", unstagedRaw],
312
- ["staged diff capture", staged],
313
- ["untracked path capture", untrackedPaths],
314
- ["index entry capture", indexEntries],
315
- ["tracked flag capture", trackedFlags],
316
- ] as const) {
317
- if (!result.ok) return { kind: "unverifiable", reason: processFailureReason(`git ${operation}`, result) };
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 { kind: "unverifiable", reason: unsafeWorktreeMode };
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 { kind: "unverifiable", reason: "git-visible repository state contains unmerged paths" };
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 { kind: "unverifiable", reason: unsafe };
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
- kind: "unverifiable",
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
- kind: "verified",
354
- fingerprint: combineFingerprints([
355
- ["unstaged", unstaged.stdout],
356
- ["staged", staged.stdout],
357
- ["index", indexEntries.stdout],
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(stored: AgentResumeContext, current: AgentResumeContext): string | undefined {
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 (stored.workflow.sourceFingerprint !== current.workflow.sourceFingerprint) return "workflow source changed";
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(join(current, ".git"));
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
+ }