@dieulc/workflow 0.3.1 → 0.4.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/README.md +118 -1
- package/commit-policy.ts +358 -0
- package/index.ts +432 -9
- package/package.json +4 -2
- package/permissions.ts +98 -0
- package/review-providers.ts +406 -0
- package/review-runtime.ts +108 -5
- package/review-ui.ts +1 -0
- package/review.ts +219 -4
package/permissions.ts
CHANGED
|
@@ -824,6 +824,104 @@ function classifyGit(s: string): CommandClass {
|
|
|
824
824
|
return GIT_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
|
|
825
825
|
}
|
|
826
826
|
|
|
827
|
+
// ── Commit-policy classification ─────────────────────────────────
|
|
828
|
+
|
|
829
|
+
/** Git subcommands the commit policy gates (`workflow.commitPolicy`). */
|
|
830
|
+
export type CommitPolicyHit = "add" | "commit" | "push";
|
|
831
|
+
|
|
832
|
+
const COMMIT_POLICY_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
833
|
+
"add",
|
|
834
|
+
"commit",
|
|
835
|
+
"push",
|
|
836
|
+
]);
|
|
837
|
+
|
|
838
|
+
/** Global `git` options that take a separate value (`git -C path status`). */
|
|
839
|
+
const GIT_GLOBAL_VALUE_OPTIONS: ReadonlySet<string> = new Set([
|
|
840
|
+
"-C",
|
|
841
|
+
"--git-dir",
|
|
842
|
+
"--work-tree",
|
|
843
|
+
"--namespace",
|
|
844
|
+
"--super-prefix",
|
|
845
|
+
"--exec-path",
|
|
846
|
+
]);
|
|
847
|
+
|
|
848
|
+
/** Global `git` flags that take no value (`git --no-pager log`). */
|
|
849
|
+
const GIT_GLOBAL_FLAG_OPTIONS: ReadonlySet<string> = new Set([
|
|
850
|
+
"--no-pager",
|
|
851
|
+
"--paginate",
|
|
852
|
+
"--no-replace-objects",
|
|
853
|
+
"--bare",
|
|
854
|
+
"--no-optional-locks",
|
|
855
|
+
"--literal-pathspecs",
|
|
856
|
+
"--html-path",
|
|
857
|
+
"--man-path",
|
|
858
|
+
"--info-path",
|
|
859
|
+
"--version",
|
|
860
|
+
]);
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Extract the subcommand a `git …` invocation resolves to, skipping global
|
|
864
|
+
* options. `null` when no subcommand is found. Mirrors classifyGit's
|
|
865
|
+
* option-skipping, but deliberately parses *through* `-c key=value` pairs:
|
|
866
|
+
* a config pair cannot turn a read-only subcommand into a commit, and the
|
|
867
|
+
* policy classifier must see the real subcommand (`git -c x=y commit`).
|
|
868
|
+
*/
|
|
869
|
+
function gitSubcommandAt(words: string[], gitIndex: number): string | null {
|
|
870
|
+
let i = gitIndex + 1;
|
|
871
|
+
while (i < words.length) {
|
|
872
|
+
const w = unquote(words[i]);
|
|
873
|
+
if (!w) {
|
|
874
|
+
i += 1;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (w === "-c") {
|
|
878
|
+
i += 2; // -c key=value pair
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (w.startsWith("-c") && w.includes("=")) {
|
|
882
|
+
i += 1; // fused -ckey=value (defensive; git wants a space)
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (w.startsWith("--") && w.includes("=")) {
|
|
886
|
+
i += 1; // --git-dir=/x fused form
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (GIT_GLOBAL_VALUE_OPTIONS.has(w)) {
|
|
890
|
+
i += 2;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (GIT_GLOBAL_FLAG_OPTIONS.has(w)) {
|
|
894
|
+
i += 1;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
return w.toLowerCase();
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Commit-policy classifier: does this command invoke `git add`, `git commit`
|
|
904
|
+
* or `git push` — in any segment of a compound command, including wrapper
|
|
905
|
+
* forms (`env git commit`, `nice -n 5 git add`) and global-option forms
|
|
906
|
+
* (`git -C path add`, `git.exe push`)? Returns the first matched subcommand,
|
|
907
|
+
* or null. Fail-closed by design: false positives (an unquoted
|
|
908
|
+
* `echo git commit`) are acceptable; false negatives are not.
|
|
909
|
+
*/
|
|
910
|
+
export function gitCommitPolicyHit(command: string): CommitPolicyHit | null {
|
|
911
|
+
if (typeof command !== "string" || !command.trim()) return null;
|
|
912
|
+
for (const segment of splitCommandSegments(command)) {
|
|
913
|
+
const words = wordsOf(segment);
|
|
914
|
+
for (let g = 0; g < words.length; g++) {
|
|
915
|
+
if (normalizeBinary(words[g]) !== "git") continue;
|
|
916
|
+
const sub = gitSubcommandAt(words, g);
|
|
917
|
+
if (sub && COMMIT_POLICY_SUBCOMMANDS.has(sub)) {
|
|
918
|
+
return sub as CommitPolicyHit;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
|
|
827
925
|
function classifyDocker(s: string): CommandClass {
|
|
828
926
|
const sub = wordAt(s, 1)?.toLowerCase();
|
|
829
927
|
if (!sub) return "full";
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider seeding for Review Mode's reviewer session.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `createReviewSession()` builds the reviewer as an isolated
|
|
5
|
+
* child (`noExtensions: true`, its own `ModelRuntime`). That isolation is
|
|
6
|
+
* deliberate — the reviewer must not inherit ambient extensions — but it has
|
|
7
|
+
* one consequence: a provider that is registered ONLY by an extension in the
|
|
8
|
+
* parent session (e.g. `pi.registerProvider("synthetic", …)`) does not exist in
|
|
9
|
+
* the child's runtime, so the child's prompt preflight throws
|
|
10
|
+
* `No API key found for <provider>.` even though `auth.json` holds a valid
|
|
11
|
+
* credential for it.
|
|
12
|
+
*
|
|
13
|
+
* The fix is a *present-probe*, not layer names: the seeder asks the child
|
|
14
|
+
* runtime what it already composes (`getRegisteredProviderIds()`), asks the
|
|
15
|
+
* parent surface for the same list, and registers only the difference —
|
|
16
|
+
* native provider objects first, plain provider configs otherwise. Providers
|
|
17
|
+
* the child already composes by construction (Pi's provider catalog and
|
|
18
|
+
* `~/.pi/agent/models.json` — every `ModelRuntime` loads both) never appear in
|
|
19
|
+
* these id lists at all, so the probe is automatically a no-op for them and for
|
|
20
|
+
* any future provider source Pi composes globally. No code here mentions or
|
|
21
|
+
* branches on provider layers; grep this file for "built-in"/"extension" to
|
|
22
|
+
* confirm the decision logic stays structural.
|
|
23
|
+
*
|
|
24
|
+
* Everything is feature-detected and per-provider fault-isolated:
|
|
25
|
+
* - a missing method degrades to "nothing to seed / cannot verify" — never a
|
|
26
|
+
* crash (the dev-dependency pins a different Pi version than is installed);
|
|
27
|
+
* - a broken unrelated provider registration is recorded in `errors` and must
|
|
28
|
+
* never block a review.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A provider registration as the parent holds it, copied opaquely to the
|
|
33
|
+
* child. The seeder never interprets a field — the child runtime validates
|
|
34
|
+
* and composes it exactly as it would a registration of its own.
|
|
35
|
+
*/
|
|
36
|
+
interface ProviderRegistration {
|
|
37
|
+
/** Provider id (present on native provider objects). */
|
|
38
|
+
readonly id?: string;
|
|
39
|
+
readonly [field: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The parent-side provider-registration surface the seeder reads from.
|
|
44
|
+
*
|
|
45
|
+
* Structural, not nominal: any object exposing these three accessors
|
|
46
|
+
* qualifies. Both real Pi surfaces — the session's `ModelRegistry` facade
|
|
47
|
+
* (`ctx.modelRegistry`) and its underlying `ModelRuntime` (`ctx.modelRuntime`)
|
|
48
|
+
* — expose exactly this shape; a future surface is picked up by adding one
|
|
49
|
+
* entry to `PARENT_SURFACE_KEYS`.
|
|
50
|
+
*/
|
|
51
|
+
export interface ParentProviderSurface {
|
|
52
|
+
getRegisteredProviderIds(): readonly string[];
|
|
53
|
+
getRegisteredProviderConfig(providerId: string): ProviderRegistration | undefined;
|
|
54
|
+
getRegisteredNativeProvider(providerId: string): ProviderRegistration | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One provider that could not be seeded, with the reason. */
|
|
58
|
+
export interface ProviderSeedError {
|
|
59
|
+
name: string;
|
|
60
|
+
error: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What one seeding pass did (or did not) do. */
|
|
64
|
+
export interface ProviderSeedResult {
|
|
65
|
+
/** Ids that were newly registered on the child runtime. */
|
|
66
|
+
seeded: string[];
|
|
67
|
+
/** Parent ids the child already composes — untouched, proving the no-op. */
|
|
68
|
+
skippedPresent: string[];
|
|
69
|
+
/** Per-provider failures; the review is never blocked by these. */
|
|
70
|
+
errors: ProviderSeedError[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProviderGapScan {
|
|
74
|
+
/** Parent ids the child does not compose yet. */
|
|
75
|
+
missing: string[];
|
|
76
|
+
/** Parent ids the child already composes. */
|
|
77
|
+
present: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Junk-tolerant `getRegisteredProviderIds()` reader. Empty when unavailable. */
|
|
81
|
+
function registeredIdsOn(surface: unknown): string[] {
|
|
82
|
+
try {
|
|
83
|
+
const get = (surface as { getRegisteredProviderIds?: unknown } | undefined)
|
|
84
|
+
?.getRegisteredProviderIds;
|
|
85
|
+
if (typeof get !== "function") return [];
|
|
86
|
+
const ids = (get as (this: unknown) => unknown).call(surface);
|
|
87
|
+
if (!Array.isArray(ids)) return [];
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
for (const id of ids) {
|
|
90
|
+
if (typeof id === "string" && id.trim()) seen.add(id.trim());
|
|
91
|
+
}
|
|
92
|
+
return [...seen];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Structural probe: does this object expose the full provider-registration
|
|
100
|
+
* surface (id list + both getters)? Both real Pi surfaces — the parent
|
|
101
|
+
* runtime and its `ModelRegistry` facade — expose all three; anything less
|
|
102
|
+
* cannot seed anything, so it is treated as "no surface".
|
|
103
|
+
*/
|
|
104
|
+
function isProviderSurface(value: unknown): value is ParentProviderSurface {
|
|
105
|
+
if (typeof value !== "object" || value === null) return false;
|
|
106
|
+
const s = value as Partial<ParentProviderSurface>;
|
|
107
|
+
return (
|
|
108
|
+
typeof s.getRegisteredProviderIds === "function" &&
|
|
109
|
+
typeof s.getRegisteredProviderConfig === "function" &&
|
|
110
|
+
typeof s.getRegisteredNativeProvider === "function"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Public alias for the structural probe, so callers can distinguish a parent
|
|
116
|
+
* with no surface (worth reporting) from a surface that had nothing to give
|
|
117
|
+
* (a normal no-op).
|
|
118
|
+
*/
|
|
119
|
+
export function isParentProviderSurface(value: unknown): boolean {
|
|
120
|
+
return isProviderSurface(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Find a provider-registration surface on the parent context, trying the
|
|
125
|
+
* documented public surfaces in order. Returns `undefined` when none exists —
|
|
126
|
+
* callers must treat that as "nothing to seed", never as an error.
|
|
127
|
+
*
|
|
128
|
+
* The list is the extension point for future Pi versions: add one entry and
|
|
129
|
+
* the new surface is picked up without touching the seeder.
|
|
130
|
+
*/
|
|
131
|
+
const PARENT_SURFACE_KEYS = ["modelRegistry", "modelRuntime"] as const;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Find a provider-registration surface on the parent context, trying the
|
|
135
|
+
* documented public surfaces in order. Returns `undefined` when none exists —
|
|
136
|
+
* callers must treat that as "nothing to seed", never as an error.
|
|
137
|
+
*/
|
|
138
|
+
export function findParentProviderSurface(
|
|
139
|
+
ctxLike: unknown,
|
|
140
|
+
): ParentProviderSurface | undefined {
|
|
141
|
+
if (typeof ctxLike !== "object" || ctxLike === null) return undefined;
|
|
142
|
+
const holder = ctxLike as Record<string, unknown>;
|
|
143
|
+
for (const key of PARENT_SURFACE_KEYS) {
|
|
144
|
+
try {
|
|
145
|
+
const candidate = holder[key];
|
|
146
|
+
if (isProviderSurface(candidate)) return candidate;
|
|
147
|
+
} catch {
|
|
148
|
+
/* a throwing getter is just not a usable surface */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compare the parent surface's registrations with what the child already
|
|
156
|
+
* composes. Never throws; a feature-detection failure on either side degrades
|
|
157
|
+
* to the safest scan (empty child set ⇒ everything in the parent counts as
|
|
158
|
+
* missing — re-registering is a merge, not a clobber).
|
|
159
|
+
*/
|
|
160
|
+
export function collectMissingChildProviders(
|
|
161
|
+
childRuntimeLike: unknown,
|
|
162
|
+
parentSurfaceLike: unknown,
|
|
163
|
+
): ProviderGapScan {
|
|
164
|
+
const childIds = new Set(registeredIdsOn(childRuntimeLike));
|
|
165
|
+
const parentIds = registeredIdsOn(parentSurfaceLike);
|
|
166
|
+
const missing: string[] = [];
|
|
167
|
+
const present: string[] = [];
|
|
168
|
+
for (const id of parentIds) {
|
|
169
|
+
if (childIds.has(id)) present.push(id);
|
|
170
|
+
else missing.push(id);
|
|
171
|
+
}
|
|
172
|
+
return { missing, present };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Seed the child runtime with the parent surface's provider registrations.
|
|
177
|
+
*
|
|
178
|
+
* Per missing id: register the provider object when the parent has one, else
|
|
179
|
+
* the provider config. Every id is individually fault-isolated — a malformed
|
|
180
|
+
* registration is recorded in `errors` and the remaining ids are still seeded.
|
|
181
|
+
* Idempotent: ids the child already composes are skipped, so a repeated call
|
|
182
|
+
* (or a shared runtime) is a no-op for them.
|
|
183
|
+
*
|
|
184
|
+
* Never throws. On the real Pi runtime each registration also triggers a
|
|
185
|
+
* network-off, fire-and-forget catalog refresh — local composition only.
|
|
186
|
+
*/
|
|
187
|
+
export function seedChildRuntimeProviders(
|
|
188
|
+
modelRuntimeLike: unknown,
|
|
189
|
+
parentSurfaceLike: unknown,
|
|
190
|
+
): ProviderSeedResult {
|
|
191
|
+
const result: ProviderSeedResult = {
|
|
192
|
+
seeded: [],
|
|
193
|
+
skippedPresent: [],
|
|
194
|
+
errors: [],
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
if (!isProviderSurface(parentSurfaceLike)) return result;
|
|
198
|
+
const gap = collectMissingChildProviders(modelRuntimeLike, parentSurfaceLike);
|
|
199
|
+
result.skippedPresent = gap.present;
|
|
200
|
+
for (const id of gap.missing) {
|
|
201
|
+
try {
|
|
202
|
+
const getNative = parentSurfaceLike.getRegisteredNativeProvider as (
|
|
203
|
+
this: unknown,
|
|
204
|
+
providerId: string,
|
|
205
|
+
) => unknown;
|
|
206
|
+
const native =
|
|
207
|
+
typeof getNative === "function" ? getNative.call(parentSurfaceLike, id) : undefined;
|
|
208
|
+
if (native !== undefined && native !== null) {
|
|
209
|
+
const registerNative = (modelRuntimeLike as {
|
|
210
|
+
registerNativeProvider?: unknown;
|
|
211
|
+
})?.registerNativeProvider;
|
|
212
|
+
if (typeof registerNative !== "function") {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"the reviewer runtime does not expose registerNativeProvider",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
(registerNative as (this: unknown, p: unknown) => void).call(
|
|
218
|
+
modelRuntimeLike,
|
|
219
|
+
native,
|
|
220
|
+
);
|
|
221
|
+
} else {
|
|
222
|
+
const getConfig = parentSurfaceLike.getRegisteredProviderConfig as (
|
|
223
|
+
this: unknown,
|
|
224
|
+
providerId: string,
|
|
225
|
+
) => unknown;
|
|
226
|
+
const config =
|
|
227
|
+
typeof getConfig === "function"
|
|
228
|
+
? getConfig.call(parentSurfaceLike, id)
|
|
229
|
+
: undefined;
|
|
230
|
+
if (config === undefined || config === null) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
"the parent surface has no registration to copy for this id",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const registerProvider = (modelRuntimeLike as {
|
|
236
|
+
registerProvider?: unknown;
|
|
237
|
+
})?.registerProvider;
|
|
238
|
+
if (typeof registerProvider !== "function") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
"the reviewer runtime does not expose registerProvider",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
(registerProvider as (this: unknown, id: string, c: unknown) => void).call(
|
|
244
|
+
modelRuntimeLike,
|
|
245
|
+
id,
|
|
246
|
+
config,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
result.seeded.push(id);
|
|
250
|
+
} catch (e: unknown) {
|
|
251
|
+
result.errors.push({
|
|
252
|
+
name: id,
|
|
253
|
+
error: e instanceof Error ? e.message : String(e),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* the probe itself misbehaved: report "did nothing" rather than throw */
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Outcome of the reviewer's provider-auth preflight. */
|
|
264
|
+
export interface ReviewerAuthCheck {
|
|
265
|
+
configured: boolean;
|
|
266
|
+
/** `hasConfiguredAuth` said yes (snapshot). */
|
|
267
|
+
viaSnapshot: boolean;
|
|
268
|
+
/** `checkAuth` resolved to something (credential resolution). */
|
|
269
|
+
viaCheckAuth: boolean;
|
|
270
|
+
/** True when the runtime exposes neither accessor (cannot verify — trust). */
|
|
271
|
+
unverifiable: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Provider-agnostic reviewer auth preflight, mirroring the child session's own
|
|
276
|
+
* prompt gate (`hasConfiguredAuth(provider) || checkAuth(provider) !==
|
|
277
|
+
* undefined`) but tolerating a runtime that exposes neither accessor (old or
|
|
278
|
+
* stubbed SDKs): "cannot verify" proceeds rather than blocks. Feature-detected
|
|
279
|
+
* and never throws; a throwing `checkAuth` counts as "not configured".
|
|
280
|
+
*/
|
|
281
|
+
export async function checkReviewerProviderAuth(
|
|
282
|
+
runtimeLike: unknown,
|
|
283
|
+
providerId: string,
|
|
284
|
+
): Promise<ReviewerAuthCheck> {
|
|
285
|
+
let viaSnapshot = false;
|
|
286
|
+
let sawSnapshotAccessor = false;
|
|
287
|
+
try {
|
|
288
|
+
const has = (runtimeLike as { hasConfiguredAuth?: unknown })
|
|
289
|
+
?.hasConfiguredAuth;
|
|
290
|
+
if (typeof has === "function") {
|
|
291
|
+
sawSnapshotAccessor = true;
|
|
292
|
+
viaSnapshot =
|
|
293
|
+
(has as (this: unknown, id: string) => unknown).call(
|
|
294
|
+
runtimeLike,
|
|
295
|
+
providerId,
|
|
296
|
+
) === true;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
viaSnapshot = false;
|
|
300
|
+
}
|
|
301
|
+
if (viaSnapshot) {
|
|
302
|
+
return { configured: true, viaSnapshot: true, viaCheckAuth: false, unverifiable: false };
|
|
303
|
+
}
|
|
304
|
+
let viaCheckAuth = false;
|
|
305
|
+
let sawCheckAuthAccessor = false;
|
|
306
|
+
try {
|
|
307
|
+
const check = (runtimeLike as { checkAuth?: unknown })?.checkAuth;
|
|
308
|
+
if (typeof check === "function") {
|
|
309
|
+
sawCheckAuthAccessor = true;
|
|
310
|
+
const resolved = await (check as (this: unknown, id: string) => unknown).call(
|
|
311
|
+
runtimeLike,
|
|
312
|
+
providerId,
|
|
313
|
+
);
|
|
314
|
+
viaCheckAuth = resolved !== undefined && resolved !== null;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
viaCheckAuth = false;
|
|
318
|
+
}
|
|
319
|
+
const unverifiable = !sawSnapshotAccessor && !sawCheckAuthAccessor;
|
|
320
|
+
return {
|
|
321
|
+
configured: viaCheckAuth || unverifiable,
|
|
322
|
+
viaSnapshot: false,
|
|
323
|
+
viaCheckAuth,
|
|
324
|
+
unverifiable,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One-line summary of a seed result, for preflight/failure messages. */
|
|
329
|
+
export function describeSeedResult(result: ProviderSeedResult): string {
|
|
330
|
+
const parts: string[] = [];
|
|
331
|
+
parts.push(
|
|
332
|
+
result.seeded.length > 0
|
|
333
|
+
? `seeded into the reviewer runtime: ${result.seeded.join(", ")}`
|
|
334
|
+
: "nothing needed seeding",
|
|
335
|
+
);
|
|
336
|
+
parts.push(
|
|
337
|
+
result.skippedPresent.length > 0
|
|
338
|
+
? `already present: ${result.skippedPresent.join(", ")}`
|
|
339
|
+
: "none were already present",
|
|
340
|
+
);
|
|
341
|
+
parts.push(
|
|
342
|
+
result.errors.length > 0
|
|
343
|
+
? `failed: ${result.errors
|
|
344
|
+
.map((e) => `${e.name} (${e.error})`)
|
|
345
|
+
.join("; ")}`
|
|
346
|
+
: "no failures",
|
|
347
|
+
);
|
|
348
|
+
return parts.join("; ");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build the fail-fast error for a reviewer provider whose auth could not be
|
|
353
|
+
* resolved after seeding. Names the provider, reports what the seed found,
|
|
354
|
+
* and gives the generic escape hatch that works for ANY provider.
|
|
355
|
+
*/
|
|
356
|
+
export function unconfiguredReviewerProviderMessage(input: {
|
|
357
|
+
providerId: string;
|
|
358
|
+
seed: ProviderSeedResult;
|
|
359
|
+
/** Parent surface was absent entirely (no probe hit). */
|
|
360
|
+
parentSurfaceFound: boolean;
|
|
361
|
+
modelsJsonPath?: string;
|
|
362
|
+
}): string {
|
|
363
|
+
const modelsJson =
|
|
364
|
+
input.modelsJsonPath ?? "~/.pi/agent/models.json";
|
|
365
|
+
const seedSummary = input.parentSurfaceFound
|
|
366
|
+
? describeSeedResult(input.seed)
|
|
367
|
+
: "the parent session exposed no provider-registration surface, so nothing could be seeded";
|
|
368
|
+
const lines = [
|
|
369
|
+
`the reviewer session has no usable auth for provider "${input.providerId}".`,
|
|
370
|
+
`Provider seeding: ${seedSummary}.`,
|
|
371
|
+
`The reviewer runs in an isolated session without ambient extensions, so a provider registered only in the parent session must be seeded (above) AND its auth must be resolvable on its own.`,
|
|
372
|
+
`Fix any one of:`,
|
|
373
|
+
` 1. run /login ${input.providerId} — the stored credential is read by every runtime, including the reviewer's;`,
|
|
374
|
+
` 2. add a static provider entry to ${modelsJson} — that file is loaded by every runtime, including the reviewer's:`,
|
|
375
|
+
` { "providers": { "${input.providerId}": { "baseUrl": "…", "apiKey": "$MY_API_KEY", "api": "openai-completions", "models": [ … ] } } }`,
|
|
376
|
+
` 3. export MY_API_KEY in the environment when the provider config references "$MY_API_KEY".`,
|
|
377
|
+
];
|
|
378
|
+
return lines.join("\n");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Defensive hint appended to a mid-run pass-1 failure that still carries the
|
|
383
|
+
* bare provider-auth text. After seeding + preflight this path should be rare;
|
|
384
|
+
* it is the safety net for any future provider-source gap.
|
|
385
|
+
*
|
|
386
|
+
* Only fires when the error text matches the provider-auth failure shape AND
|
|
387
|
+
* the failing provider (extracted from the text) matches the reviewer's own
|
|
388
|
+
* provider id — an unrelated error must never gain a misleading hint.
|
|
389
|
+
*/
|
|
390
|
+
export function appendProviderAuthHint(
|
|
391
|
+
errorText: string,
|
|
392
|
+
providerId: string | undefined,
|
|
393
|
+
modelsJsonPath?: string,
|
|
394
|
+
): string {
|
|
395
|
+
if (!providerId || !errorText) return errorText;
|
|
396
|
+
const match = errorText.match(/No API key found for\s+([^\s.]+)/);
|
|
397
|
+
if (!match) return errorText;
|
|
398
|
+
const failingProvider = match[1];
|
|
399
|
+
if (failingProvider !== providerId) return errorText;
|
|
400
|
+
const modelsJson = modelsJsonPath ?? "~/.pi/agent/models.json";
|
|
401
|
+
return [
|
|
402
|
+
errorText,
|
|
403
|
+
`Hint: provider "${providerId}" may only be registered in the parent session; the reviewer session starts without ambient extensions.`,
|
|
404
|
+
`Run /login ${providerId}, or add a static provider entry to ${modelsJson} (loaded by every runtime, including the reviewer's).`,
|
|
405
|
+
].join("\n");
|
|
406
|
+
}
|