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.
- package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
- 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 +11 -3
- 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 +70 -4
- 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 +12 -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 +74 -38
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- 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 +49 -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 +2 -1
- 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 +23 -3
- 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 +40 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +211 -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,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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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(
|
|
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 (
|
|
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-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
38
|
-
const body =
|
|
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 =
|
|
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
|
-
...
|
|
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
|
-
`${
|
|
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, ...
|
|
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
|
|
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, ...
|
|
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", "│")} ${
|
|
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
|
}
|