pi-workflow-engine 0.11.0 → 0.12.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 (73) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  11. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  12. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
  13. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  15. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  17. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  18. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
  19. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  22. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  23. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  24. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  25. package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
  26. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  27. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  28. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  29. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  30. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  31. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  32. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  33. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  34. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  35. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
  36. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  43. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  44. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  45. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  46. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  47. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  48. package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
  49. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  50. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  51. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  52. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
  53. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  54. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  55. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  56. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  57. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  58. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  59. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  60. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  61. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  64. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  65. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
  66. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
  67. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
  68. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
  69. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
  70. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  71. package/README.md +72 -141
  72. package/USAGE.md +211 -17
  73. 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,7 @@
1
1
  import { lstat } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { throwIfAborted } from "./cancellation.ts";
4
+ import { isMissingPathError } from "./filesystem-error.ts";
4
5
  import type {
5
6
  EffectiveAgentSessionIdentity,
6
7
  EffectiveToolIdentity,
@@ -66,6 +67,10 @@ export interface AgentResumeContext extends VerifiedAgentResumeBaseContext {
66
67
  readonly skills: readonly ResolvedSkillIdentity[];
67
68
  }
68
69
 
70
+ export interface ResumeContextComparisonOptions {
71
+ readonly allowWorkflowSourceMismatch?: boolean;
72
+ }
73
+
69
74
  type RepositoryRevision =
70
75
  | { readonly state: "git"; readonly head: string }
71
76
  | { readonly state: "unborn" };
@@ -306,57 +311,85 @@ async function captureGitVisibleStateUnchecked(
306
311
  ]);
307
312
  throwIfAborted(signal);
308
313
 
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
- }
314
+ const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
315
+ if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
316
+
317
+ const untracked = await captureDeclaredInputFingerprint({
318
+ root: cwd,
319
+ inputs: validated.untrackedPaths.filter((path) => !isFingerprintExcludedPath(path)),
320
+ excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
321
+ maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
322
+ maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
323
+ signal,
324
+ });
325
+ if (untracked.kind === "unverifiable") return untracked;
326
+
327
+ return {
328
+ kind: "verified",
329
+ fingerprint: combineFingerprints([
330
+ ["unstaged", validated.unstaged],
331
+ ["staged", validated.staged],
332
+ ["index", validated.indexEntries],
333
+ ["untracked", untracked.fingerprint],
334
+ ]),
335
+ };
336
+ }
337
+
338
+ interface GitVisibleProcessOutputs {
339
+ readonly unstaged: BoundedProcessResult;
340
+ readonly unstagedRaw: BoundedProcessResult;
341
+ readonly staged: BoundedProcessResult;
342
+ readonly untrackedPaths: BoundedProcessResult;
343
+ readonly indexEntries: BoundedProcessResult;
344
+ readonly trackedFlags: BoundedProcessResult;
345
+ }
346
+
347
+ type ValidatedGitVisibleOutputs =
348
+ | {
349
+ readonly ok: true;
350
+ readonly unstaged: string;
351
+ readonly staged: string;
352
+ readonly indexEntries: string;
353
+ readonly untrackedPaths: readonly string[];
354
+ }
355
+ | { readonly ok: false; readonly reason: string };
356
+
357
+ function validateGitVisibleOutputs(outputs: GitVisibleProcessOutputs): ValidatedGitVisibleOutputs {
358
+ const { unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags } = outputs;
359
+ if (!unstaged.ok) return { ok: false, reason: processFailureReason("git unstaged diff capture", unstaged) };
360
+ if (!unstagedRaw.ok) return { ok: false, reason: processFailureReason("git unstaged mode capture", unstagedRaw) };
361
+ if (!staged.ok) return { ok: false, reason: processFailureReason("git staged diff capture", staged) };
362
+ if (!untrackedPaths.ok) return { ok: false, reason: processFailureReason("git untracked path capture", untrackedPaths) };
363
+ if (!indexEntries.ok) return { ok: false, reason: processFailureReason("git index entry capture", indexEntries) };
364
+ if (!trackedFlags.ok) return { ok: false, reason: processFailureReason("git tracked flag capture", trackedFlags) };
319
365
 
320
366
  const unsafeWorktreeMode = rawWorktreeModeFailure(unstagedRaw.stdout);
321
- if (unsafeWorktreeMode) return { kind: "unverifiable", reason: unsafeWorktreeMode };
367
+ if (unsafeWorktreeMode) return { ok: false, reason: unsafeWorktreeMode };
322
368
 
323
369
  for (const record of parseNullTerminatedRecords(indexEntries.stdout, "git index entry capture")) {
324
370
  const entry = parseIndexEntry(record);
325
371
  if (entry.stage !== 0) {
326
- return { kind: "unverifiable", reason: "git-visible repository state contains unmerged paths" };
372
+ return { ok: false, reason: "git-visible repository state contains unmerged paths" };
327
373
  }
328
374
  const unsafe = unsafeTrackedModeReason(entry.mode, entry.path);
329
- if (unsafe) return { kind: "unverifiable", reason: unsafe };
375
+ if (unsafe) return { ok: false, reason: unsafe };
330
376
  }
331
377
 
332
378
  const unsafeFlag = parseNullTerminatedRecords(trackedFlags.stdout, "git tracked flag capture")
333
379
  .find((record) => record[0] !== "H");
334
380
  if (unsafeFlag) {
335
381
  return {
336
- kind: "unverifiable",
382
+ ok: false,
337
383
  reason: `git-visible repository state contains an unsupported tracked-file flag: ${unsafeFlag[0] ?? "unknown"}`,
338
384
  };
339
385
  }
340
386
 
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
387
  return {
353
- kind: "verified",
354
- fingerprint: combineFingerprints([
355
- ["unstaged", unstaged.stdout],
356
- ["staged", staged.stdout],
357
- ["index", indexEntries.stdout],
358
- ["untracked", untracked.fingerprint],
359
- ]),
388
+ ok: true,
389
+ unstaged: unstaged.stdout,
390
+ staged: staged.stdout,
391
+ indexEntries: indexEntries.stdout,
392
+ untrackedPaths: parseNullTerminatedRecords(untrackedPaths.stdout, "git untracked path capture"),
360
393
  };
361
394
  }
362
395
 
@@ -470,7 +503,11 @@ export function createAgentResumeContext(
470
503
  };
471
504
  }
472
505
 
473
- export function resumeContextMismatchReason(stored: AgentResumeContext, current: AgentResumeContext): string | undefined {
506
+ export function resumeContextMismatchReason(
507
+ stored: AgentResumeContext,
508
+ current: AgentResumeContext,
509
+ options: ResumeContextComparisonOptions = {},
510
+ ): string | undefined {
474
511
  if (stored.repository.state !== current.repository.state) return "repository state changed";
475
512
  if (
476
513
  (stored.repository.state === "git" || stored.repository.state === "isolated") &&
@@ -483,7 +520,10 @@ export function resumeContextMismatchReason(stored: AgentResumeContext, current:
483
520
  return "working tree contents changed";
484
521
  }
485
522
  if (stored.workflow.name !== current.workflow.name) return "workflow name changed";
486
- if (stored.workflow.sourceFingerprint !== current.workflow.sourceFingerprint) return "workflow source changed";
523
+ if (
524
+ !options.allowWorkflowSourceMismatch
525
+ && stored.workflow.sourceFingerprint !== current.workflow.sourceFingerprint
526
+ ) return "workflow source changed";
487
527
  if (stored.session.runtimeVersion !== current.session.runtimeVersion) return "coding-agent runtime changed";
488
528
  if (stored.session.systemPromptFingerprint !== current.session.systemPromptFingerprint) return "effective system prompt changed";
489
529
  if (
@@ -619,10 +659,6 @@ function isEffectiveToolIdentity(value: unknown): value is EffectiveToolIdentity
619
659
  );
620
660
  }
621
661
 
622
- function isMissingPathError(error: unknown): boolean {
623
- return error instanceof Error && "code" in error && error.code === "ENOENT";
624
- }
625
-
626
662
  function parseNullTerminatedRecords(output: string, operation: string): string[] {
627
663
  if (output.length === 0) return [];
628
664
  if (!output.endsWith("\0")) throw new Error(`${operation} returned unterminated data`);
@@ -0,0 +1,49 @@
1
+ import type { AdvisoryCandidate } from "../advisory-schema.ts";
2
+ import { normalizePath, primaryLocation } from "../workflow-advisory-utils.ts";
3
+
4
+ const DIFF_EMBED_CAP = 60_000;
5
+
6
+ export function buildCodeReviewScopeBlock(input: {
7
+ readonly diffCommand: string;
8
+ readonly files: readonly string[];
9
+ readonly summary: string;
10
+ readonly conventions?: string;
11
+ readonly diffText: string;
12
+ readonly target: string;
13
+ }): string {
14
+ const diffBlock = input.diffText
15
+ ? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
16
+ input.diffText.length > DIFF_EMBED_CAP
17
+ ? `${input.diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${input.diffCommand}\` for the full diff)`
18
+ : input.diffText
19
+ }\n\`\`\`\n`
20
+ : "";
21
+ return (
22
+ `## Diff command\n${input.diffCommand}\n\n## Changed files\n${input.files.map((file) => `- ${file}`).join("\n")}\n\n` +
23
+ `## Summary\n${input.summary}\n\n## Conventions\n${input.conventions ?? "(none noted)"}\n` +
24
+ diffBlock +
25
+ (input.target ? `\n## User instructions (verbatim)\n${input.target}\n` : "")
26
+ );
27
+ }
28
+
29
+ export function dedupeCodeReviewCandidates<Context>(
30
+ groups: readonly { readonly angle: Context; readonly candidates: readonly AdvisoryCandidate[] }[],
31
+ ): Array<{ readonly angle: Context; readonly candidate: AdvisoryCandidate }> {
32
+ const seen = new Set<string>();
33
+ return groups.flatMap(({ angle, candidates }) =>
34
+ candidates
35
+ .filter((candidate) => {
36
+ const key = codeReviewDedupKey(candidate);
37
+ if (seen.has(key)) return false;
38
+ seen.add(key);
39
+ return true;
40
+ })
41
+ .map((candidate) => ({ angle, candidate })),
42
+ );
43
+ }
44
+
45
+ function codeReviewDedupKey(candidate: AdvisoryCandidate): string {
46
+ const location = primaryLocation(candidate);
47
+ const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase();
48
+ return `${normalizePath(location.file)}:${lineKey}`;
49
+ }
@@ -1,7 +1,7 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { AdvisoryReportWithStatsSchema } from "../advisory-schema.ts";
4
- import { formatReviewDiffTarget, isReviewDiffTarget, ReviewDiffTargetSchema } from "../diff-capture.ts";
4
+ import { formatReviewDiffTarget, isReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
5
5
 
6
6
  /** Atomic identity of the exact diff and post-change snapshot that were reviewed. */
7
7
  export const ReviewSnapshotIdentitySchema = Type.Object({
@@ -3,6 +3,7 @@ import type { WorkflowRunOptions } from "../types.ts";
3
3
  import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
4
4
  import { isReviewReport, type ReviewReport } from "./review-report.ts";
5
5
  import { ReviewResultsViewer } from "./review-results-viewer.ts";
6
+ import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "../ui/workflow-viewer-layout.ts";
6
7
 
7
8
  export type ReviewResultsPresentationDecision =
8
9
  | {
@@ -46,6 +47,6 @@ export async function showReviewResultsViewer(
46
47
  ): Promise<ReviewIssueSelection> {
47
48
  return await ctx.ui.custom<ReviewIssueSelection>(
48
49
  (tui, theme, _keybindings, done) => new ReviewResultsViewer(issues, "code-review", tui, theme, done),
49
- { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 } },
50
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
50
51
  );
51
52
  }
@@ -1,11 +1,10 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
2
+ import { matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
3
3
  import { renderIssueDetailLines } from "./review-format.ts";
4
4
  import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
5
5
  import { truncateDisplay } from "../ui/workflow-format.ts";
6
+ import { fitWorkflowViewerRow, fitWorkflowViewerRows, workflowViewerHeight } from "../ui/workflow-viewer-layout.ts";
6
7
 
7
- const VIEWPORT_HEIGHT_RATIO = 0.8;
8
- const VIEWPORT_MARGIN_ROWS = 2;
9
8
  const SPLIT_WIDTH = 96;
10
9
  const LIST_MIN_WIDTH = 34;
11
10
  const LIST_MAX_WIDTH = 48;
@@ -34,8 +33,8 @@ export class ReviewResultsViewer implements Component {
34
33
  render(width: number): string[] {
35
34
  const outerWidth = Math.max(4, width);
36
35
  const contentWidth = Math.max(1, outerWidth - 4);
37
- const innerHeight = Math.max(1, this.modalHeight() - 2);
38
- const body = fitRows(this.renderBody(contentWidth, innerHeight), innerHeight);
36
+ const innerHeight = Math.max(1, workflowViewerHeight(this.tui.terminal.rows) - 2);
37
+ const body = fitWorkflowViewerRows(this.renderBody(contentWidth, innerHeight), innerHeight);
39
38
  return [
40
39
  this.theme.fg("borderAccent", `╭${"─".repeat(Math.max(0, outerWidth - 2))}╮`),
41
40
  ...body.map((line) => this.borderedRow(line, contentWidth)),
@@ -94,20 +93,13 @@ export class ReviewResultsViewer implements Component {
94
93
  }
95
94
  }
96
95
 
97
- private modalHeight(): number {
98
- const terminalRows = Math.max(1, this.tui.terminal.rows);
99
- const availableRows = Math.max(1, terminalRows - VIEWPORT_MARGIN_ROWS);
100
- const proportionalRows = Math.floor(terminalRows * VIEWPORT_HEIGHT_RATIO);
101
- return Math.min(availableRows, Math.max(3, proportionalRows));
102
- }
103
-
104
96
  private renderBody(width: number, height: number): string[] {
105
97
  const warning = this.warning ? this.theme.fg("warning", this.warning) : "";
106
98
  const chromeRows = 5;
107
99
  const contentHeight = Math.max(1, height - chromeRows);
108
100
  let content: string[];
109
101
  if (this.issues.length === 0) {
110
- content = fitRows([this.theme.fg("success", "No findings.")], contentHeight);
102
+ content = fitWorkflowViewerRows([this.theme.fg("success", "No findings.")], contentHeight);
111
103
  } else if (width >= SPLIT_WIDTH) {
112
104
  content = this.renderSplit(width, contentHeight);
113
105
  } else {
@@ -118,7 +110,7 @@ export class ReviewResultsViewer implements Component {
118
110
  this.headerLine(width),
119
111
  warning,
120
112
  this.theme.fg("borderMuted", "─".repeat(width)),
121
- ...fitRows(content, contentHeight),
113
+ ...fitWorkflowViewerRows(content, contentHeight),
122
114
  this.theme.fg("borderMuted", "─".repeat(width)),
123
115
  this.helpLine(width),
124
116
  ];
@@ -131,7 +123,7 @@ export class ReviewResultsViewer implements Component {
131
123
  const details = this.renderDetailPane(detailWidth, height);
132
124
  const divider = this.theme.fg("borderMuted", "│");
133
125
  return Array.from({ length: height }, (_value, index) =>
134
- `${padAnsi(list[index] ?? "", listWidth)} ${divider} ${padAnsi(details[index] ?? "", detailWidth)}`,
126
+ `${fitWorkflowViewerRow(list[index] ?? "", listWidth)} ${divider} ${fitWorkflowViewerRow(details[index] ?? "", detailWidth)}`,
135
127
  );
136
128
  }
137
129
 
@@ -171,7 +163,7 @@ export class ReviewResultsViewer implements Component {
171
163
  width,
172
164
  );
173
165
  const indicator = truncateDisplay(this.theme.fg("dim", `${range}${arrows ? ` ${arrows}` : ""}`), width);
174
- return [heading, ...fitRows(visible, itemRows), indicator].slice(0, height);
166
+ return [heading, ...fitWorkflowViewerRows(visible, itemRows), indicator].slice(0, height);
175
167
  }
176
168
 
177
169
  private renderIssueRow(issue: ReviewIssue, index: number, width: number): string {
@@ -185,7 +177,7 @@ export class ReviewResultsViewer implements Component {
185
177
  private renderDetailPane(width: number, height: number): string[] {
186
178
  if (height <= 0) return [];
187
179
  const issue = this.issues[this.cursor];
188
- if (!issue) return fitRows([this.theme.fg("dim", "No finding selected.")], height);
180
+ if (!issue) return fitWorkflowViewerRows([this.theme.fg("dim", "No finding selected.")], height);
189
181
 
190
182
  const bodyHeight = Math.max(0, height - 2);
191
183
  const heading = truncateDisplay(
@@ -206,7 +198,7 @@ export class ReviewResultsViewer implements Component {
206
198
  this.theme.fg("dim", `${this.detailsExpanded ? "Lines" : "Collapsed"} ${range}${arrows ? ` ${arrows}` : ""}`),
207
199
  width,
208
200
  );
209
- return [heading, ...fitRows(visible, bodyHeight), indicator].slice(0, height);
201
+ return [heading, ...fitWorkflowViewerRows(visible, bodyHeight), indicator].slice(0, height);
210
202
  }
211
203
 
212
204
  private headerLine(width: number): string {
@@ -229,7 +221,7 @@ export class ReviewResultsViewer implements Component {
229
221
  }
230
222
 
231
223
  private borderedRow(content: string, width: number): string {
232
- return `${this.theme.fg("border", "│")} ${padAnsi(content, width)} ${this.theme.fg("border", "│")}`;
224
+ return `${this.theme.fg("border", "│")} ${fitWorkflowViewerRow(content, width)} ${this.theme.fg("border", "│")}`;
233
225
  }
234
226
 
235
227
  private requestRender(): void {
@@ -300,17 +292,6 @@ export class ReviewResultsViewer implements Component {
300
292
  }
301
293
  }
302
294
 
303
- function fitRows(lines: readonly string[], height: number): string[] {
304
- const visible = lines.slice(0, Math.max(0, height));
305
- return [...visible, ...Array.from({ length: Math.max(0, height - visible.length) }, () => "")];
306
- }
307
-
308
- function padAnsi(text: string, width: number): string {
309
- const truncated = truncateDisplay(text, width);
310
- const padding = Math.max(0, width - visibleWidth(truncated));
311
- return `${truncated}${" ".repeat(padding)}`;
312
- }
313
-
314
295
  function scrollArrows(hasAbove: boolean, hasBelow: boolean): string {
315
296
  return `${hasAbove ? "↑" : ""}${hasBelow ? "↓" : ""}`;
316
297
  }