@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/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
+ }