@jameslovespancakes/pi-plus 1.0.11 → 1.0.13
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/README.md +67 -22
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +9 -4
- package/src/core/accounts/oauth-pool.ts +137 -0
- package/src/core/accounts/registry.ts +12 -25
- package/src/core/accounts/routing.ts +144 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +232 -78
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +11 -10
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +46 -8
- package/src/core/codex/store.ts +29 -16
- package/src/core/config.ts +16 -17
- package/src/core/store.ts +13 -12
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +61 -40
- package/src/domains/subscriptions/accounts.ts +53 -36
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +79 -103
- package/src/domains/subscriptions/providers/anthropic.ts +42 -22
- package/src/domains/subscriptions/providers/codex.ts +146 -113
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +66 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +225 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +579 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +615 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
3
|
+
import { captureDiffTarget, type DiffCaptureFailure } from "../diff-capture.ts";
|
|
4
|
+
import {
|
|
5
|
+
reviewGitDiffBaseline,
|
|
6
|
+
type GitReviewDiffTarget,
|
|
7
|
+
type PullRequestReviewDiffTarget,
|
|
8
|
+
type ReviewDiffTarget,
|
|
9
|
+
} from "../review-diff-target.ts";
|
|
10
|
+
import { runBoundedProcess } from "../process-runner.ts";
|
|
11
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
12
|
+
import type { WorktreeBaseline } from "../worktree.ts";
|
|
13
|
+
import type { ReviewContext, ReviewSnapshotIdentity } from "./review-report.ts";
|
|
14
|
+
|
|
15
|
+
const REVIEW_SNAPSHOT_TIMEOUT_MS = 30_000;
|
|
16
|
+
const REVIEW_SNAPSHOT_MAX_BYTES = 16 << 20;
|
|
17
|
+
const REVIEW_COMMAND_MAX_BYTES = 1 << 20;
|
|
18
|
+
|
|
19
|
+
export interface VerifiedReviewSnapshot {
|
|
20
|
+
readonly status: "verified";
|
|
21
|
+
readonly identity: ReviewSnapshotIdentity;
|
|
22
|
+
readonly baseline: WorktreeBaseline;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UnavailableReviewSnapshot {
|
|
26
|
+
readonly status: "unavailable";
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ReviewSnapshotCapture = VerifiedReviewSnapshot | UnavailableReviewSnapshot;
|
|
31
|
+
|
|
32
|
+
export interface CapturedReviewMaterial {
|
|
33
|
+
readonly ok: true;
|
|
34
|
+
/** The already-captured diff used by the review, even when its baseline cannot be verified. */
|
|
35
|
+
readonly diff: string;
|
|
36
|
+
readonly snapshot: ReviewSnapshotCapture;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReviewMaterialCaptureFailure {
|
|
40
|
+
readonly ok: false;
|
|
41
|
+
readonly error: string;
|
|
42
|
+
readonly failure: DiffCaptureFailure;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ReviewMaterialCaptureResult = CapturedReviewMaterial | ReviewMaterialCaptureFailure;
|
|
46
|
+
|
|
47
|
+
export class ReviewSnapshotUnavailableError extends Error {
|
|
48
|
+
override readonly name = "ReviewSnapshotUnavailableError";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Capture one review diff and, when possible, an atomic identity for its exact
|
|
53
|
+
* reconstructable post-change state. A baseline failure never discards or
|
|
54
|
+
* recaptures a diff that was already captured successfully.
|
|
55
|
+
*/
|
|
56
|
+
export async function captureReviewMaterial(
|
|
57
|
+
target: ReviewDiffTarget,
|
|
58
|
+
cwd: string,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
): Promise<ReviewMaterialCaptureResult> {
|
|
61
|
+
let latestDiff: string | undefined;
|
|
62
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
63
|
+
const before = await captureReviewDiff(target, cwd, signal);
|
|
64
|
+
if (!before.ok) {
|
|
65
|
+
throwIfAborted(signal);
|
|
66
|
+
return latestDiff === undefined
|
|
67
|
+
? { ok: false, error: before.error, failure: before.failure }
|
|
68
|
+
: capturedWithoutSnapshot(latestDiff, `review diff could not be recaptured: ${before.error}`);
|
|
69
|
+
}
|
|
70
|
+
latestDiff = before.diff;
|
|
71
|
+
|
|
72
|
+
let baseline: WorktreeBaseline;
|
|
73
|
+
try {
|
|
74
|
+
baseline = await captureReviewWorktreeBaseline(target, cwd, signal);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throwIfAborted(signal);
|
|
77
|
+
return capturedWithoutSnapshot(before.diff, `review baseline could not be captured: ${unknownErrorMessage(error)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const after = await captureReviewDiff(target, cwd, signal);
|
|
81
|
+
if (!after.ok) {
|
|
82
|
+
throwIfAborted(signal);
|
|
83
|
+
return capturedWithoutSnapshot(before.diff, `review diff could not be recaptured: ${after.error}`);
|
|
84
|
+
}
|
|
85
|
+
latestDiff = after.diff;
|
|
86
|
+
|
|
87
|
+
const beforeFingerprint = fingerprintReviewDiff(before.diff);
|
|
88
|
+
const afterFingerprint = fingerprintReviewDiff(after.diff);
|
|
89
|
+
if (beforeFingerprint === afterFingerprint) {
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
diff: after.diff,
|
|
93
|
+
snapshot: {
|
|
94
|
+
status: "verified",
|
|
95
|
+
identity: {
|
|
96
|
+
diffFingerprint: afterFingerprint,
|
|
97
|
+
baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline),
|
|
98
|
+
},
|
|
99
|
+
baseline,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (latestDiff === undefined) throw new Error("review snapshot retry loop completed without a captured diff");
|
|
106
|
+
return capturedWithoutSnapshot(latestDiff, "review target changed while its snapshot was being captured");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Revalidate the reviewed diff and reconstruct its exact post-change snapshot. */
|
|
110
|
+
export async function resolveReviewWorktreeBaseline(
|
|
111
|
+
context: ReviewContext | undefined,
|
|
112
|
+
cwd: string,
|
|
113
|
+
signal?: AbortSignal,
|
|
114
|
+
): Promise<WorktreeBaseline> {
|
|
115
|
+
if (!context?.snapshot) {
|
|
116
|
+
throw new ReviewSnapshotUnavailableError("the review was not captured with a verifiable snapshot identity.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const material = await captureReviewMaterial(context.diffTarget, cwd, signal);
|
|
120
|
+
if (!material.ok) {
|
|
121
|
+
throw new ReviewSnapshotUnavailableError(`the review diff could not be captured: ${material.error}`);
|
|
122
|
+
}
|
|
123
|
+
if (material.snapshot.status === "unavailable") {
|
|
124
|
+
throw new ReviewSnapshotUnavailableError(`the reviewed snapshot could not be verified: ${material.snapshot.reason}`);
|
|
125
|
+
}
|
|
126
|
+
if (material.snapshot.identity.diffFingerprint !== context.snapshot.diffFingerprint) {
|
|
127
|
+
throw new ReviewSnapshotUnavailableError("the reviewed diff changed after the findings were generated.");
|
|
128
|
+
}
|
|
129
|
+
if (material.snapshot.identity.baselineFingerprint !== context.snapshot.baselineFingerprint) {
|
|
130
|
+
throw new ReviewSnapshotUnavailableError("the reviewed snapshot changed after the findings were generated.");
|
|
131
|
+
}
|
|
132
|
+
return material.snapshot.baseline;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function fingerprintReviewWorktreeBaseline(baseline: WorktreeBaseline): string {
|
|
136
|
+
return createHash("sha256")
|
|
137
|
+
.update(JSON.stringify({ ref: baseline.ref, patch: baseline.patch ?? null }))
|
|
138
|
+
.digest("hex");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function captureReviewDiff(
|
|
142
|
+
target: ReviewDiffTarget,
|
|
143
|
+
cwd: string,
|
|
144
|
+
signal: AbortSignal | undefined,
|
|
145
|
+
): Promise<{ readonly ok: true; readonly diff: string } | { readonly ok: false; readonly error: string; readonly failure: DiffCaptureFailure }> {
|
|
146
|
+
const captured = await captureDiffTarget(target, {
|
|
147
|
+
cwd,
|
|
148
|
+
signal,
|
|
149
|
+
timeoutMs: REVIEW_SNAPSHOT_TIMEOUT_MS,
|
|
150
|
+
maxBufferBytes: REVIEW_SNAPSHOT_MAX_BYTES,
|
|
151
|
+
});
|
|
152
|
+
return captured.ok
|
|
153
|
+
? { ok: true, diff: captured.stdout }
|
|
154
|
+
: { ok: false, error: captured.error, failure: captured.failure };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function captureReviewWorktreeBaseline(
|
|
158
|
+
target: ReviewDiffTarget,
|
|
159
|
+
cwd: string,
|
|
160
|
+
signal: AbortSignal | undefined,
|
|
161
|
+
): Promise<WorktreeBaseline> {
|
|
162
|
+
return target.kind === "pull-request"
|
|
163
|
+
? await resolvePullRequestBaseline(target, cwd, signal)
|
|
164
|
+
: await resolveGitDiffBaseline(target, cwd, signal);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolvePullRequestBaseline(
|
|
168
|
+
target: PullRequestReviewDiffTarget,
|
|
169
|
+
cwd: string,
|
|
170
|
+
signal: AbortSignal | undefined,
|
|
171
|
+
): Promise<WorktreeBaseline> {
|
|
172
|
+
const number = String(target.number);
|
|
173
|
+
const viewed = await runReviewCommand("gh", ["pr", "view", number, "--json", "headRefOid,headRefName,headRepository"], cwd, signal);
|
|
174
|
+
const details = viewed.ok ? parsePullRequestHead(viewed.stdout) : undefined;
|
|
175
|
+
if (!details) {
|
|
176
|
+
throw new Error(`pull request head could not be resolved: ${viewed.error ?? (viewed.stderr.trim() || "invalid head commit")}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!(await commitExists(details.head, cwd, signal))) {
|
|
180
|
+
const fetched = await runReviewCommand(
|
|
181
|
+
"git",
|
|
182
|
+
["fetch", "--no-tags", "--quiet", `https://github.com/${details.repository}.git`, details.branch],
|
|
183
|
+
cwd,
|
|
184
|
+
signal,
|
|
185
|
+
);
|
|
186
|
+
if (!fetched.ok || !(await commitExists(details.head, cwd, signal))) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`pull request ${number} head ${details.head} is not available locally: ${fetched.error ?? (fetched.stderr.trim() || "fetch did not provide the commit")}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { ref: details.head };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parsePullRequestHead(value: string): { readonly head: string; readonly branch: string; readonly repository: string } | undefined {
|
|
196
|
+
let parsed: unknown;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(value);
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
203
|
+
const candidate = parsed as {
|
|
204
|
+
readonly headRefOid?: unknown;
|
|
205
|
+
readonly headRefName?: unknown;
|
|
206
|
+
readonly headRepository?: { readonly nameWithOwner?: unknown } | null;
|
|
207
|
+
};
|
|
208
|
+
if (typeof candidate.headRefOid !== "string" || !/^[0-9a-f]{40,64}$/i.test(candidate.headRefOid)) return undefined;
|
|
209
|
+
if (typeof candidate.headRefName !== "string" || candidate.headRefName.length === 0 || candidate.headRefName.includes("\0")) return undefined;
|
|
210
|
+
const repository = candidate.headRepository?.nameWithOwner;
|
|
211
|
+
if (typeof repository !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
|
|
212
|
+
return { head: candidate.headRefOid, branch: candidate.headRefName, repository };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function resolveGitDiffBaseline(
|
|
216
|
+
target: GitReviewDiffTarget,
|
|
217
|
+
cwd: string,
|
|
218
|
+
signal: AbortSignal | undefined,
|
|
219
|
+
): Promise<WorktreeBaseline> {
|
|
220
|
+
const baseline = reviewGitDiffBaseline(target);
|
|
221
|
+
switch (baseline.kind) {
|
|
222
|
+
case "range":
|
|
223
|
+
return { ref: await resolveCommit(baseline.ref, cwd, signal) };
|
|
224
|
+
case "index":
|
|
225
|
+
return await captureMutableGitBaseline(true, cwd, signal);
|
|
226
|
+
case "working-tree":
|
|
227
|
+
return await captureMutableGitBaseline(false, cwd, signal);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function captureMutableGitBaseline(
|
|
232
|
+
staged: boolean,
|
|
233
|
+
cwd: string,
|
|
234
|
+
signal: AbortSignal | undefined,
|
|
235
|
+
): Promise<WorktreeBaseline> {
|
|
236
|
+
const head = await tryResolveCommit("HEAD", cwd, signal);
|
|
237
|
+
if (!head) throw new Error("the reviewed repository has no committed HEAD baseline");
|
|
238
|
+
|
|
239
|
+
const args = staged
|
|
240
|
+
? ["diff", "--no-ext-diff", "--binary", "--cached", "HEAD"]
|
|
241
|
+
: ["diff", "--no-ext-diff", "--binary", "HEAD"];
|
|
242
|
+
const snapshot = await runReviewCommand("git", args, cwd, signal, REVIEW_SNAPSHOT_MAX_BYTES);
|
|
243
|
+
if (!snapshot.ok) {
|
|
244
|
+
throw new Error(`the reviewed working state could not be captured: ${snapshot.error}`);
|
|
245
|
+
}
|
|
246
|
+
return { ref: head, patch: snapshot.stdout };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function resolveCommit(ref: string, cwd: string, signal: AbortSignal | undefined): Promise<string> {
|
|
250
|
+
const resolved = await tryResolveCommit(ref, cwd, signal);
|
|
251
|
+
if (!resolved) throw new Error(`review target ${ref} is not a local commit`);
|
|
252
|
+
return resolved;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function tryResolveCommit(ref: string, cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
|
|
256
|
+
const result = await runReviewCommand("git", ["rev-parse", "--verify", `${ref}^{commit}`], cwd, signal);
|
|
257
|
+
const commit = result.stdout.trim();
|
|
258
|
+
return result.ok && /^[0-9a-f]{40,64}$/i.test(commit) ? commit : undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function commitExists(ref: string, cwd: string, signal: AbortSignal | undefined): Promise<boolean> {
|
|
262
|
+
const result = await runReviewCommand("git", ["cat-file", "-e", `${ref}^{commit}`], cwd, signal);
|
|
263
|
+
return result.ok;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function runReviewCommand(
|
|
267
|
+
file: "git" | "gh",
|
|
268
|
+
args: readonly string[],
|
|
269
|
+
cwd: string,
|
|
270
|
+
signal: AbortSignal | undefined,
|
|
271
|
+
maxBufferBytes = REVIEW_COMMAND_MAX_BYTES,
|
|
272
|
+
) {
|
|
273
|
+
return await runBoundedProcess({
|
|
274
|
+
file,
|
|
275
|
+
args,
|
|
276
|
+
cwd,
|
|
277
|
+
env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
|
|
278
|
+
signal,
|
|
279
|
+
timeoutMs: REVIEW_SNAPSHOT_TIMEOUT_MS,
|
|
280
|
+
maxBufferBytes,
|
|
281
|
+
abortError: `${file} review snapshot command aborted`,
|
|
282
|
+
timeoutError: `${file} review snapshot command timed out after ${REVIEW_SNAPSHOT_TIMEOUT_MS}ms`,
|
|
283
|
+
maxBufferError: `${file} review snapshot command exceeded output limit`,
|
|
284
|
+
exitError: (stderr, code, processSignal) => stderr.trim() || `${file} exited with code ${code ?? `signal ${processSignal ?? "unknown"}`}`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function capturedWithoutSnapshot(diff: string, reason: string): CapturedReviewMaterial {
|
|
289
|
+
return { ok: true, diff, snapshot: { status: "unavailable", reason } };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function fingerprintReviewDiff(diff: string): string {
|
|
293
|
+
return createHash("sha256").update(diff).digest("hex");
|
|
294
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
export type GitDiffBaselineTarget =
|
|
5
|
+
| { readonly kind: "working-tree" }
|
|
6
|
+
| { readonly kind: "index" }
|
|
7
|
+
| { readonly kind: "range"; readonly ref: string };
|
|
8
|
+
|
|
9
|
+
export const GitReviewDiffTargetSchema = Type.Object({
|
|
10
|
+
kind: Type.Literal("git"),
|
|
11
|
+
args: Type.Array(Type.String()),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const PullRequestReviewDiffTargetSchema = Type.Object({
|
|
15
|
+
kind: Type.Literal("pull-request"),
|
|
16
|
+
number: Type.Integer({ minimum: 1 }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
|
|
20
|
+
|
|
21
|
+
export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
|
|
22
|
+
export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
|
|
23
|
+
|
|
24
|
+
/** Serializable semantic identity of the diff being reviewed. */
|
|
25
|
+
export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
|
|
26
|
+
|
|
27
|
+
const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,-]+$/;
|
|
28
|
+
const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
|
|
29
|
+
const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
|
|
30
|
+
|
|
31
|
+
export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
|
|
32
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
if (tokens.length < 2) return { error: "empty or incomplete diff command" };
|
|
34
|
+
if (tokens[0] === "git" && tokens[1] === "diff") {
|
|
35
|
+
const parsed = parseGitDiff(tokens);
|
|
36
|
+
return "error" in parsed ? parsed : parsed.target;
|
|
37
|
+
}
|
|
38
|
+
if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
|
|
39
|
+
const number = Number(tokens[3]);
|
|
40
|
+
if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
|
|
41
|
+
const flags = tokens.slice(4);
|
|
42
|
+
if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
|
|
43
|
+
const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
|
|
44
|
+
if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
|
|
45
|
+
return { kind: "pull-request", number };
|
|
46
|
+
}
|
|
47
|
+
return { error: "diff command is not in the git/gh allowlist" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseGitDiff(
|
|
51
|
+
tokens: readonly string[],
|
|
52
|
+
): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
|
|
53
|
+
const args = tokens.slice(2);
|
|
54
|
+
const safeArgs = ["diff", "--no-ext-diff"];
|
|
55
|
+
const operands: string[] = [];
|
|
56
|
+
let staged = false;
|
|
57
|
+
let pathMode = false;
|
|
58
|
+
|
|
59
|
+
for (const token of args) {
|
|
60
|
+
if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
|
|
61
|
+
if (token === "--") {
|
|
62
|
+
pathMode = true;
|
|
63
|
+
safeArgs.push(token);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!pathMode && token.startsWith("-")) {
|
|
67
|
+
if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
|
|
68
|
+
staged ||= token === "--cached" || token === "--staged";
|
|
69
|
+
if (token !== "--no-ext-diff") safeArgs.push(token);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!pathMode) operands.push(token);
|
|
73
|
+
safeArgs.push(token);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseline = classifyGitDiffBaseline(staged, operands);
|
|
77
|
+
return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
|
|
81
|
+
if (operands.length > 1) {
|
|
82
|
+
return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
|
|
83
|
+
}
|
|
84
|
+
if (staged) return { kind: "index" };
|
|
85
|
+
|
|
86
|
+
const range = operands.find((operand) => operand.includes(".."));
|
|
87
|
+
if (range) {
|
|
88
|
+
const separator = range.includes("...") ? "..." : "..";
|
|
89
|
+
return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { kind: "working-tree" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isAllowedGitDiffFlag(token: string): boolean {
|
|
96
|
+
return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
|
|
100
|
+
return target.kind === "pull-request"
|
|
101
|
+
? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
|
|
102
|
+
: { file: "git", args: target.args };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
|
|
106
|
+
export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
|
|
107
|
+
const command = reviewDiffCommand(target);
|
|
108
|
+
return [command.file, ...command.args].join(" ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
|
|
112
|
+
export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
|
|
113
|
+
const parsed = parseGitDiff(["git", ...target.args]);
|
|
114
|
+
if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
|
|
115
|
+
return parsed.baseline;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate persisted targets by round-tripping through the single command allowlist parser. */
|
|
119
|
+
export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
|
|
120
|
+
if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
|
|
121
|
+
const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
|
|
122
|
+
return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
|
|
126
|
+
if (left.kind !== right.kind) return false;
|
|
127
|
+
if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
|
|
128
|
+
if (right.kind !== "git" || left.args.length !== right.args.length) return false;
|
|
129
|
+
return left.args.every((arg, index) => arg === right.args[index]);
|
|
130
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Stable identity for state retained for the lifetime of a pi session. */
|
|
4
|
+
export function sessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
|
|
5
|
+
return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
|
|
6
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const MAX_SCHEMA_REPAIR_ATTEMPTS = 2;
|
|
2
|
+
export const STRUCTURED_OUTPUT_ERROR_CODE = "WORKFLOW_STRUCTURED_OUTPUT_NONCOMPLIANCE";
|
|
3
|
+
|
|
4
|
+
export interface StructuredOutputErrorDetails {
|
|
5
|
+
readonly agentLabel: string;
|
|
6
|
+
readonly promptAttempts: number;
|
|
7
|
+
readonly repairAttempts: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Recoverable failure raised when a schema agent never calls `final_answer`. */
|
|
11
|
+
export class WorkflowStructuredOutputError extends Error {
|
|
12
|
+
readonly name = "WorkflowStructuredOutputError";
|
|
13
|
+
readonly code = STRUCTURED_OUTPUT_ERROR_CODE;
|
|
14
|
+
readonly details: StructuredOutputErrorDetails;
|
|
15
|
+
|
|
16
|
+
constructor(agentLabel: string, repairAttempts: number) {
|
|
17
|
+
const promptAttempts = repairAttempts + 1;
|
|
18
|
+
super(
|
|
19
|
+
`Schema agent "${agentLabel}" did not call final_answer after ${promptAttempts} prompt attempts (${repairAttempts} repairs).`,
|
|
20
|
+
);
|
|
21
|
+
this.details = { agentLabel, promptAttempts, repairAttempts };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toJSON(): {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly details: StructuredOutputErrorDetails;
|
|
29
|
+
} {
|
|
30
|
+
return { name: this.name, message: this.message, code: this.code, details: this.details };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentToolHint } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Raised before prompting when required semantic tool capabilities are unavailable. */
|
|
5
|
+
export class WorkflowToolHintUnavailableError extends Error {
|
|
6
|
+
readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE";
|
|
7
|
+
readonly hints: readonly AgentToolHint[];
|
|
8
|
+
|
|
9
|
+
constructor(hints: readonly AgentToolHint[]) {
|
|
10
|
+
super(`Required workflow tool capability unavailable: ${hints.join(", ")}`);
|
|
11
|
+
this.name = "WorkflowToolHintUnavailableError";
|
|
12
|
+
this.hints = [...hints];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boolean {
|
|
17
|
+
return hint === "search" ? isSearchLikeTool(tool) : isExternalSearchLikeTool(tool);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isExternalSearchLikeTool(tool: ToolInfo): boolean {
|
|
21
|
+
if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false;
|
|
22
|
+
const name = tool.name.toLowerCase();
|
|
23
|
+
const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase();
|
|
24
|
+
const text = `${name} ${description}`;
|
|
25
|
+
const hasResearchAction = /\b(?:browse|browser|search|fetch|extract|open|read|get)\b/.test(text)
|
|
26
|
+
|| /(?:^|[-_])(?:browse|browser|search|fetch|extract)(?:$|[-_])/.test(name)
|
|
27
|
+
|| /(?:^|[^a-z0-9])web(?:$|[^a-z0-9])/.test(name);
|
|
28
|
+
const hasExternalMedium = /(?:^|[-_])(?:web|browser|url|http|https|tavily|exa|firecrawl|brave|serper|parallel|perplexity)(?:$|[-_])/.test(name)
|
|
29
|
+
|| /\b(?:search|browse)\s+(?:the\s+)?(?:web|internet)\b/.test(description)
|
|
30
|
+
|| /\b(?:internet|web) search(?: engine)?\b/.test(description)
|
|
31
|
+
|| /\b(?:fetch|extract|open|read|get)\s+(?:(?:the|a|an)\s+)?(?:url|web\s?page|website|https? page)\b/.test(description);
|
|
32
|
+
return hasResearchAction && hasExternalMedium;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isSearchLikeTool(tool: ToolInfo): boolean {
|
|
36
|
+
if (isMutationLikeToolName(tool.name)) return false;
|
|
37
|
+
const name = tool.name.toLowerCase();
|
|
38
|
+
if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
|
|
39
|
+
return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isMutationLikeToolName(name: string): boolean {
|
|
43
|
+
return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase());
|
|
44
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
4
|
+
import { hashIdentity } from "./identity-fingerprint.ts";
|
|
5
|
+
import { logicalWorkspacePath, portableRelativePath } from "./replay-path-identity.ts";
|
|
6
|
+
import { captureTreeFingerprint, isPathWithin, validateTreeFile, type FingerprintCapture } from "./tree-fingerprint.ts";
|
|
7
|
+
|
|
8
|
+
export interface EffectiveToolSourceInfoLike {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly source: string;
|
|
11
|
+
readonly scope: string;
|
|
12
|
+
readonly origin: string;
|
|
13
|
+
readonly baseDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EffectiveToolSourceIdentity {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly source: string;
|
|
19
|
+
readonly scope: string;
|
|
20
|
+
readonly origin: string;
|
|
21
|
+
readonly baseDir?: string;
|
|
22
|
+
readonly fingerprint: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ToolSourceFingerprintCache = Map<string, Promise<FingerprintCapture>>;
|
|
26
|
+
|
|
27
|
+
export interface ToolSourceIdentityOptions {
|
|
28
|
+
readonly runtimeVersion: string;
|
|
29
|
+
readonly sessionCwd: string;
|
|
30
|
+
readonly workspaceRoot: string;
|
|
31
|
+
readonly cache: ToolSourceFingerprintCache;
|
|
32
|
+
readonly signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SOURCE_FINGERPRINT_MAX_BYTES = 32 << 20;
|
|
36
|
+
const SOURCE_FINGERPRINT_MAX_FILES = 4096;
|
|
37
|
+
const SOURCE_FINGERPRINT_EXCLUSIONS = new Set([".git", ".pi/.workflow-runs"]);
|
|
38
|
+
|
|
39
|
+
export async function captureEffectiveToolSourceIdentity(
|
|
40
|
+
sourceInfo: EffectiveToolSourceInfoLike,
|
|
41
|
+
options: ToolSourceIdentityOptions,
|
|
42
|
+
): Promise<EffectiveToolSourceIdentity> {
|
|
43
|
+
const path = nonEmptyString(sourceInfo.path, "tool source path");
|
|
44
|
+
const source = nonEmptyString(sourceInfo.source, `source type for tool source "${path}"`);
|
|
45
|
+
const scope = nonEmptyString(sourceInfo.scope, `scope for tool source "${path}"`);
|
|
46
|
+
const origin = nonEmptyString(sourceInfo.origin, `origin for tool source "${path}"`);
|
|
47
|
+
const declaredBaseDir = sourceInfo.baseDir === undefined ? undefined : nonEmptyString(sourceInfo.baseDir, `base directory for tool source "${path}"`);
|
|
48
|
+
|
|
49
|
+
if (isSyntheticSource(path, source)) {
|
|
50
|
+
const baseDir =
|
|
51
|
+
declaredBaseDir === undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: normalizeUnresolvedBaseDir(declaredBaseDir, options.sessionCwd, options.workspaceRoot);
|
|
54
|
+
const synthetic = { path, source, scope, origin, ...(baseDir === undefined ? {} : { baseDir }) };
|
|
55
|
+
return {
|
|
56
|
+
...synthetic,
|
|
57
|
+
fingerprint: hashIdentity({ runtimeVersion: options.runtimeVersion, source: synthetic }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const baseDir = declaredBaseDir === undefined ? undefined : resolveDeclaredPath(declaredBaseDir, options.sessionCwd);
|
|
62
|
+
const sourcePath = await resolveExistingSourcePath(path, baseDir, options.sessionCwd, options.workspaceRoot);
|
|
63
|
+
const sourceRoot = baseDir && isPathWithin(baseDir, sourcePath) ? baseDir : dirname(sourcePath);
|
|
64
|
+
const validation = await validateTreeFile({
|
|
65
|
+
root: sourceRoot,
|
|
66
|
+
path: sourcePath,
|
|
67
|
+
excludedRelativePaths: SOURCE_FINGERPRINT_EXCLUSIONS,
|
|
68
|
+
signal: options.signal,
|
|
69
|
+
});
|
|
70
|
+
if (validation.kind === "unverifiable") throw new Error(`tool source "${path}" is not fingerprintable: ${validation.reason}`);
|
|
71
|
+
|
|
72
|
+
let capture = options.cache.get(sourceRoot);
|
|
73
|
+
if (!capture) {
|
|
74
|
+
capture = captureTreeFingerprint({
|
|
75
|
+
root: sourceRoot,
|
|
76
|
+
excludedRelativePaths: SOURCE_FINGERPRINT_EXCLUSIONS,
|
|
77
|
+
maxBytes: SOURCE_FINGERPRINT_MAX_BYTES,
|
|
78
|
+
maxFiles: SOURCE_FINGERPRINT_MAX_FILES,
|
|
79
|
+
signal: options.signal,
|
|
80
|
+
});
|
|
81
|
+
options.cache.set(sourceRoot, capture);
|
|
82
|
+
}
|
|
83
|
+
const fingerprint = await capture;
|
|
84
|
+
if (fingerprint.kind === "unverifiable") throw new Error(`tool source "${path}" is not fingerprintable: ${fingerprint.reason}`);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
path: logicalSourcePath(sourcePath, sourceRoot, options.sessionCwd, options.workspaceRoot),
|
|
88
|
+
source,
|
|
89
|
+
scope,
|
|
90
|
+
origin,
|
|
91
|
+
...(baseDir === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { baseDir: logicalSourcePath(baseDir, sourceRoot, options.sessionCwd, options.workspaceRoot) }),
|
|
94
|
+
fingerprint: fingerprint.fingerprint,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function resolveExistingSourcePath(
|
|
99
|
+
path: string,
|
|
100
|
+
baseDir: string | undefined,
|
|
101
|
+
sessionCwd: string,
|
|
102
|
+
workspaceRoot: string,
|
|
103
|
+
): Promise<string> {
|
|
104
|
+
const candidates = isAbsolute(path)
|
|
105
|
+
? [resolve(path)]
|
|
106
|
+
: uniquePaths([
|
|
107
|
+
...(baseDir ? [resolve(baseDir, path), resolve(baseDir, path.split(/[\\/]/).at(-1) ?? path)] : []),
|
|
108
|
+
resolve(sessionCwd, path),
|
|
109
|
+
resolve(workspaceRoot, path),
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
try {
|
|
114
|
+
if ((await lstat(candidate)).isFile()) return candidate;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!isMissingPathError(error)) throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`tool source path does not identify a regular file: ${path}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveDeclaredPath(path: string, sessionCwd: string): string {
|
|
123
|
+
return isAbsolute(path) ? resolve(path) : resolve(sessionCwd, path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeUnresolvedBaseDir(path: string, sessionCwd: string, workspaceRoot: string): string {
|
|
127
|
+
const resolvedPath = resolveDeclaredPath(path, sessionCwd);
|
|
128
|
+
return logicalWorkspacePath(resolvedPath, { sessionCwd, workspaceRoot }) ?? "source-root:.";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function logicalSourcePath(path: string, sourceRoot: string, sessionCwd: string, workspaceRoot: string): string {
|
|
132
|
+
const workspacePath = logicalWorkspacePath(path, { sessionCwd, workspaceRoot });
|
|
133
|
+
if (workspacePath) return workspacePath;
|
|
134
|
+
if (isPathWithin(sourceRoot, path)) return `source-root:${portableRelativePath(sourceRoot, path)}`;
|
|
135
|
+
throw new Error("tool source path is outside its fingerprint roots");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isSyntheticSource(path: string, source: string): boolean {
|
|
139
|
+
return (path.startsWith("<") && path.endsWith(">")) || source === "builtin" || source === "sdk" || /^(?:builtin|sdk):/.test(path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function uniquePaths(paths: readonly string[]): readonly string[] {
|
|
143
|
+
return [...new Set(paths)];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function nonEmptyString(value: string, label: string): string {
|
|
147
|
+
if (typeof value !== "string") throw new Error(`${label} is not a string`);
|
|
148
|
+
if (value.length === 0) throw new Error(`${label} is empty`);
|
|
149
|
+
return value;
|
|
150
|
+
}
|