@dieulc/workflow 0.6.1 → 0.8.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 +126 -40
- package/commit-policy.ts +43 -0
- package/edit-policy.ts +193 -14
- package/index.ts +3090 -1148
- package/model-default.ts +535 -0
- package/package.json +10 -3
- package/picker.ts +246 -0
- package/profiles.ts +98 -1
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +260 -74
- package/review-ui.ts +27 -4
- package/review.ts +516 -16
- package/tab-editor.ts +147 -0
- package/tab-policy.ts +42 -0
- package/utils.ts +372 -69
package/model-default.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Persisted Default Model
|
|
3
|
+
*
|
|
4
|
+
* Pi keeps a "saved default model" (`defaultProvider` / `defaultModel` /
|
|
5
|
+
* `defaultThinkingLevel` in settings.json), but only an explicit gesture
|
|
6
|
+
* updates it: Ctrl+S in the model selector calls
|
|
7
|
+
* `session.setModel(model, { persist: true })`, which writes
|
|
8
|
+
* `setDefaultModelAndProvider` (see `dist/core/agent-session.js`). Picking a
|
|
9
|
+
* model any other way is session-only, and the extension's own
|
|
10
|
+
* `pi.setModel()` calls are documented as "without changing the configured
|
|
11
|
+
* default for new sessions".
|
|
12
|
+
*
|
|
13
|
+
* This module is the extension-owned record of what the NEXT session should
|
|
14
|
+
* start on, so the behaviour is identical in terminal Pi and Pi Desktop and
|
|
15
|
+
* survives Pi upgrades (it is our key, not Pi's schema):
|
|
16
|
+
*
|
|
17
|
+
* settings.json
|
|
18
|
+
* workflow.modelDefault = { provider, id, thinking? } ← authoritative
|
|
19
|
+
* defaultProvider / defaultModel / defaultThinkingLevel ← mirrored for Pi
|
|
20
|
+
*
|
|
21
|
+
* The mirror matters because Pi itself only reads its own keys, and because
|
|
22
|
+
* Pi's startup resolution prefers the FIRST `enabledModels` entry over a
|
|
23
|
+
* saved default (`findInitialModel` in `dist/core/model-resolver.js`). The
|
|
24
|
+
* mirror keeps terminal Pi, Pi Desktop and Pi's settings UI in agreement; the
|
|
25
|
+
* extension's `session_start` apply closes the `enabledModels` gap.
|
|
26
|
+
*
|
|
27
|
+
* Pure, I/O-light module — unit-testable without a Pi runtime, mirroring the
|
|
28
|
+
* settings discipline of commit-policy.ts / profiles.ts: read-modify-write
|
|
29
|
+
* that preserves every unrelated key (Pi owns settings.json — we must never
|
|
30
|
+
* clobber it), and a loader that never throws and never rewrites a file it
|
|
31
|
+
* could not understand.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
|
|
37
|
+
import type { ThinkingLevel } from "./roles.ts";
|
|
38
|
+
import { isValidThinkingLevel } from "./roles.ts";
|
|
39
|
+
import { COMMIT_CONFIG_DIR, WORKFLOW_SETTINGS_KEY } from "./commit-policy.ts";
|
|
40
|
+
|
|
41
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Nested key under WORKFLOW_SETTINGS_KEY holding the persisted default. */
|
|
44
|
+
export const MODEL_DEFAULT_KEY = "modelDefault";
|
|
45
|
+
|
|
46
|
+
/** A model reference plus the thinking level to restore with it. */
|
|
47
|
+
export interface PersistedModelDefault {
|
|
48
|
+
provider: string;
|
|
49
|
+
id: string;
|
|
50
|
+
thinking?: ThinkingLevel;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ModelDefaultSettingPaths {
|
|
54
|
+
global: string;
|
|
55
|
+
project: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Which settings scope supplied the effective value. */
|
|
59
|
+
export type ModelDefaultSource = "default" | "global" | "project";
|
|
60
|
+
|
|
61
|
+
export interface ResolvedModelDefault {
|
|
62
|
+
/** The effective persisted default, or undefined when none is configured. */
|
|
63
|
+
value: PersistedModelDefault | undefined;
|
|
64
|
+
/** Which files contributed (for /role list and /profile status). */
|
|
65
|
+
sources: {
|
|
66
|
+
global: boolean;
|
|
67
|
+
project: boolean;
|
|
68
|
+
effective: ModelDefaultSource;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minimal JSON object shape used by the read-modify-write helpers. */
|
|
73
|
+
type JsonObject = Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** Trim a candidate string; empty / non-string → undefined. */
|
|
78
|
+
function cleanString(value: unknown): string | undefined {
|
|
79
|
+
if (typeof value !== "string") return undefined;
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
return trimmed ? trimmed : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Coerce one model reference from untrusted JSON. Returns undefined unless
|
|
86
|
+
* `provider` and `id` are non-empty strings; a missing or unrecognised
|
|
87
|
+
* `thinking` is dropped rather than rejecting the whole record (a hand-edited
|
|
88
|
+
* file should still load its model).
|
|
89
|
+
*/
|
|
90
|
+
export function coerceModelDefault(
|
|
91
|
+
raw: unknown,
|
|
92
|
+
): PersistedModelDefault | undefined {
|
|
93
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
94
|
+
const rec = raw as JsonObject;
|
|
95
|
+
const provider = cleanString(rec.provider);
|
|
96
|
+
const id = cleanString(rec.id);
|
|
97
|
+
if (!provider || !id) return undefined;
|
|
98
|
+
const thinkingRaw = cleanString(rec.thinking);
|
|
99
|
+
const thinking =
|
|
100
|
+
thinkingRaw && isValidThinkingLevel(thinkingRaw.toLowerCase())
|
|
101
|
+
? (thinkingRaw.toLowerCase() as ThinkingLevel)
|
|
102
|
+
: undefined;
|
|
103
|
+
return thinking ? { provider, id, thinking } : { provider, id };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compare two model references. Provider + id must match; `thinking` is only
|
|
108
|
+
* compared when BOTH sides define one, so "same model, unknown thinking" does
|
|
109
|
+
* not fabricate a difference (and therefore does not trigger a rewrite).
|
|
110
|
+
*/
|
|
111
|
+
export function sameModelRef(
|
|
112
|
+
a: { provider: string; id: string; thinking?: string } | undefined,
|
|
113
|
+
b: { provider: string; id: string; thinking?: string } | undefined,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (!a || !b) return false;
|
|
116
|
+
if (a.provider !== b.provider || a.id !== b.id) return false;
|
|
117
|
+
if (a.thinking && b.thinking) return a.thinking === b.thinking;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Canonical `provider/id` label (no thinking suffix). */
|
|
122
|
+
export function formatPersistedModel(m: {
|
|
123
|
+
provider: string;
|
|
124
|
+
id: string;
|
|
125
|
+
}): string {
|
|
126
|
+
return `${m.provider}/${m.id}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Paths + reading ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Settings file locations. `agentDir` is passed in rather than read from the
|
|
133
|
+
* Pi SDK so this stays pure and testable (same contract as
|
|
134
|
+
* defaultProfileSettingPaths / commitConfigPaths).
|
|
135
|
+
*/
|
|
136
|
+
export function modelDefaultSettingPaths(
|
|
137
|
+
cwd: string,
|
|
138
|
+
agentDir: string,
|
|
139
|
+
): ModelDefaultSettingPaths {
|
|
140
|
+
return {
|
|
141
|
+
global: path.join(agentDir, "settings.json"),
|
|
142
|
+
project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readSettingsSafe(fp: string): JsonObject | undefined {
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(fp)) return undefined;
|
|
149
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
150
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
151
|
+
return undefined;
|
|
152
|
+
return parsed as JsonObject;
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extract `workflow.modelDefault` from a raw settings object. Anything that is
|
|
160
|
+
* not a valid `{provider, id}` record (absent, wrong shape, blank strings)
|
|
161
|
+
* reads as undefined.
|
|
162
|
+
*/
|
|
163
|
+
export function extractModelDefault(
|
|
164
|
+
settings: unknown,
|
|
165
|
+
): PersistedModelDefault | undefined {
|
|
166
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
167
|
+
return undefined;
|
|
168
|
+
const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
|
|
169
|
+
if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
|
|
170
|
+
return coerceModelDefault((wf as JsonObject)[MODEL_DEFAULT_KEY]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the effective persisted default from disk. Project wins over global;
|
|
175
|
+
* a corrupt or missing file reads as absent. Never throws.
|
|
176
|
+
*/
|
|
177
|
+
export function readModelDefault(
|
|
178
|
+
cwd: string,
|
|
179
|
+
agentDir: string,
|
|
180
|
+
): ResolvedModelDefault {
|
|
181
|
+
const paths = modelDefaultSettingPaths(cwd, agentDir);
|
|
182
|
+
const globalValue = extractModelDefault(readSettingsSafe(paths.global));
|
|
183
|
+
const projectValue = extractModelDefault(readSettingsSafe(paths.project));
|
|
184
|
+
const value = projectValue ?? globalValue;
|
|
185
|
+
return {
|
|
186
|
+
value,
|
|
187
|
+
sources: {
|
|
188
|
+
global: globalValue !== undefined,
|
|
189
|
+
project: projectValue !== undefined,
|
|
190
|
+
effective:
|
|
191
|
+
projectValue !== undefined
|
|
192
|
+
? "project"
|
|
193
|
+
: globalValue !== undefined
|
|
194
|
+
? "global"
|
|
195
|
+
: "default",
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read Pi's OWN project-level default (`<cwd>/.pi/settings.json` top-level
|
|
202
|
+
* `defaultProvider`/`defaultModel`), i.e. a default configured for this
|
|
203
|
+
* project rather than by this extension.
|
|
204
|
+
*
|
|
205
|
+
* Pi deep-merges the project settings file over the global one, so a project
|
|
206
|
+
* value wins when Pi resolves a startup model. The extension must agree with
|
|
207
|
+
* that instead of overwriting it — see
|
|
208
|
+
* `shouldApplyModelDefaultAtSessionStart`'s `project-default` branch.
|
|
209
|
+
*
|
|
210
|
+
* Only the project file is consulted: the global file is already covered by
|
|
211
|
+
* `pi.getSettings()`, and mixing the two here would make a global default look
|
|
212
|
+
* project-scoped. Missing/corrupt/half-specified settings read as absent and
|
|
213
|
+
* never throw.
|
|
214
|
+
*/
|
|
215
|
+
export function projectDefaultModel(
|
|
216
|
+
cwd: string,
|
|
217
|
+
agentDir: string,
|
|
218
|
+
): PersistedModelDefault | undefined {
|
|
219
|
+
const paths = modelDefaultSettingPaths(cwd, agentDir);
|
|
220
|
+
const root = readSettingsSafe(paths.project);
|
|
221
|
+
if (!root) return undefined;
|
|
222
|
+
return coerceModelDefault({
|
|
223
|
+
provider: root.defaultProvider,
|
|
224
|
+
id: root.defaultModel,
|
|
225
|
+
thinking: root.defaultThinkingLevel,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Which scope a write should target: the project file when it already defines
|
|
231
|
+
* `workflow.modelDefault` (so an existing project override stays effective),
|
|
232
|
+
* otherwise global. Mirrors the "write where the effective value lives" rule
|
|
233
|
+
* used by the app's model-scope writer.
|
|
234
|
+
*/
|
|
235
|
+
export function modelDefaultWriteScope(
|
|
236
|
+
cwd: string,
|
|
237
|
+
agentDir: string,
|
|
238
|
+
): "global" | "project" {
|
|
239
|
+
const paths = modelDefaultSettingPaths(cwd, agentDir);
|
|
240
|
+
return extractModelDefault(readSettingsSafe(paths.project)) !== undefined
|
|
241
|
+
? "project"
|
|
242
|
+
: "global";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Writing ──────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** Result of a settings write. `changed` distinguishes a no-op save. */
|
|
248
|
+
export interface ModelDefaultWriteResult {
|
|
249
|
+
path: string;
|
|
250
|
+
ok: boolean;
|
|
251
|
+
/** False when the file already held exactly this value (no write needed). */
|
|
252
|
+
changed: boolean;
|
|
253
|
+
error?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Read-modify-write the persisted default into a settings file.
|
|
258
|
+
*
|
|
259
|
+
* - `value === null` clears `workflow.modelDefault` AND the mirrored
|
|
260
|
+
* `defaultProvider` / `defaultModel` / `defaultThinkingLevel` keys.
|
|
261
|
+
* - A value writes the extension key and mirrors the three Pi keys, so Pi's
|
|
262
|
+
* own startup resolution and settings UI agree with us.
|
|
263
|
+
* - Every other key is preserved, including unrelated `workflow.*` entries
|
|
264
|
+
* (commitPolicy, writePolicy, defaultProfile, reviewMode, …). An empty
|
|
265
|
+
* `workflow` object is removed to keep the file tidy.
|
|
266
|
+
* - Idempotent: writing a value the file already holds performs no write and
|
|
267
|
+
* reports `changed: false`.
|
|
268
|
+
*/
|
|
269
|
+
export function writeModelDefault(
|
|
270
|
+
scope: "global" | "project",
|
|
271
|
+
cwd: string,
|
|
272
|
+
agentDir: string,
|
|
273
|
+
value: PersistedModelDefault | null,
|
|
274
|
+
): ModelDefaultWriteResult {
|
|
275
|
+
const paths = modelDefaultSettingPaths(cwd, agentDir);
|
|
276
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
277
|
+
try {
|
|
278
|
+
const existing = readSettingsSafe(fp);
|
|
279
|
+
const root: JsonObject = existing ? { ...existing } : {};
|
|
280
|
+
const wfExisting = root[WORKFLOW_SETTINGS_KEY];
|
|
281
|
+
const wf: JsonObject =
|
|
282
|
+
wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
|
|
283
|
+
? { ...(wfExisting as JsonObject) }
|
|
284
|
+
: {};
|
|
285
|
+
|
|
286
|
+
if (value === null) {
|
|
287
|
+
delete wf[MODEL_DEFAULT_KEY];
|
|
288
|
+
delete root.defaultProvider;
|
|
289
|
+
delete root.defaultModel;
|
|
290
|
+
delete root.defaultThinkingLevel;
|
|
291
|
+
} else {
|
|
292
|
+
const coerced = coerceModelDefault(value);
|
|
293
|
+
if (!coerced) {
|
|
294
|
+
return {
|
|
295
|
+
path: fp,
|
|
296
|
+
ok: false,
|
|
297
|
+
changed: false,
|
|
298
|
+
error: "modelDefault needs a non-empty provider and id",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
wf[MODEL_DEFAULT_KEY] = coerced.thinking
|
|
302
|
+
? {
|
|
303
|
+
provider: coerced.provider,
|
|
304
|
+
id: coerced.id,
|
|
305
|
+
thinking: coerced.thinking,
|
|
306
|
+
}
|
|
307
|
+
: { provider: coerced.provider, id: coerced.id };
|
|
308
|
+
// Mirror for Pi itself (native startup + settings UI).
|
|
309
|
+
root.defaultProvider = coerced.provider;
|
|
310
|
+
root.defaultModel = coerced.id;
|
|
311
|
+
if (coerced.thinking) root.defaultThinkingLevel = coerced.thinking;
|
|
312
|
+
else delete root.defaultThinkingLevel;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (Object.keys(wf).length === 0) delete root[WORKFLOW_SETTINGS_KEY];
|
|
316
|
+
else root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
317
|
+
|
|
318
|
+
const nextText = JSON.stringify(root, null, 2) + "\n";
|
|
319
|
+
const previousText = fs.existsSync(fp)
|
|
320
|
+
? fs.readFileSync(fp, "utf8")
|
|
321
|
+
: undefined;
|
|
322
|
+
if (previousText === nextText) {
|
|
323
|
+
return { path: fp, ok: true, changed: false };
|
|
324
|
+
}
|
|
325
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
326
|
+
fs.writeFileSync(fp, nextText, "utf8");
|
|
327
|
+
return { path: fp, ok: true, changed: true };
|
|
328
|
+
} catch (e: unknown) {
|
|
329
|
+
return {
|
|
330
|
+
path: fp,
|
|
331
|
+
ok: false,
|
|
332
|
+
changed: false,
|
|
333
|
+
error: e instanceof Error ? e.message : String(e),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Session-start apply rule ─────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/** Why a session did or did not adopt the persisted default. */
|
|
341
|
+
export type ModelDefaultApplyReason =
|
|
342
|
+
| "apply"
|
|
343
|
+
| "reason-not-new"
|
|
344
|
+
| "subagent"
|
|
345
|
+
| "no-persisted"
|
|
346
|
+
| "project-default"
|
|
347
|
+
| "live-is-persisted"
|
|
348
|
+
| "live-is-explicit";
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Who supplied the Default-mode model this session remembers.
|
|
352
|
+
*
|
|
353
|
+
* `entry` is a resumed session's own recorded model (Pi's precedence, and the
|
|
354
|
+
* user's chosen behaviour); `persisted` is `workflow.modelDefault`; `profile`
|
|
355
|
+
* is the legacy fallback (a profile's `defaultModel` when no record exists).
|
|
356
|
+
*/
|
|
357
|
+
export type DefaultModeModelSource = "entry" | "persisted" | "profile" | "none";
|
|
358
|
+
|
|
359
|
+
export interface DefaultModeModelResolution {
|
|
360
|
+
/** What `defaultModel` should hold in memory (source for mode restore). */
|
|
361
|
+
inMemory: PersistedModelDefault | undefined;
|
|
362
|
+
/**
|
|
363
|
+
* What a brand-new session should adopt. The session entry is deliberately
|
|
364
|
+
* excluded: a resumed session never re-applies, and a brand-new one has no
|
|
365
|
+
* entry — so including it could only ever produce a stale target.
|
|
366
|
+
*/
|
|
367
|
+
applyTarget: PersistedModelDefault | undefined;
|
|
368
|
+
/** Which input supplied `inMemory` (diagnostics / display). */
|
|
369
|
+
source: DefaultModeModelSource;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve the Default-mode model for a session start.
|
|
374
|
+
*
|
|
375
|
+
* Precedence (confirmed with the user):
|
|
376
|
+
* in-memory = session entry -> workflow.modelDefault -> profile seed
|
|
377
|
+
* apply = workflow.modelDefault -> profile seed
|
|
378
|
+
*
|
|
379
|
+
* The profile's `defaultModel` is a SEED consumed when the profile is
|
|
380
|
+
* activated, never live state re-applied over a newer explicit choice. Without
|
|
381
|
+
* this rule an active profile silently reset the user's last model pick on
|
|
382
|
+
* every new session.
|
|
383
|
+
*/
|
|
384
|
+
export function resolveDefaultModeModel(input: {
|
|
385
|
+
entryModel?: PersistedModelDefault;
|
|
386
|
+
persistedModel?: PersistedModelDefault;
|
|
387
|
+
profileModel?: PersistedModelDefault;
|
|
388
|
+
}): DefaultModeModelResolution {
|
|
389
|
+
const inMemory =
|
|
390
|
+
input.entryModel ?? input.persistedModel ?? input.profileModel;
|
|
391
|
+
const applyTarget = input.persistedModel ?? input.profileModel;
|
|
392
|
+
let source: DefaultModeModelSource = "none";
|
|
393
|
+
if (input.entryModel) source = "entry";
|
|
394
|
+
else if (input.persistedModel) source = "persisted";
|
|
395
|
+
else if (input.profileModel) source = "profile";
|
|
396
|
+
return { inMemory, applyTarget, source };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface ModelDefaultApplyInput {
|
|
400
|
+
/** `session_start` event reason: startup | new | resume | reload. */
|
|
401
|
+
reason: string | undefined;
|
|
402
|
+
/** `process.env.PI_WORKFLOW_ROLE` — set on workflow's spawned subagents. */
|
|
403
|
+
subagentRoleEnv: string | undefined;
|
|
404
|
+
/** The live model the session is starting with, if any. */
|
|
405
|
+
live: { provider: string; id: string } | undefined;
|
|
406
|
+
/** `ctx.scopedModels[0]`, i.e. Pi's "first enabled model" preference. */
|
|
407
|
+
scopedFirst: { provider: string; id: string } | undefined;
|
|
408
|
+
/** Pi's own saved default (`defaultProvider`/`defaultModel`). */
|
|
409
|
+
settingsDefault: { provider: string; id: string } | undefined;
|
|
410
|
+
/**
|
|
411
|
+
* Pi's PROJECT-level default (`<cwd>/.pi/settings.json`). An explicit
|
|
412
|
+
* project configuration wins inside its project (user decision).
|
|
413
|
+
*/
|
|
414
|
+
projectDefault?: PersistedModelDefault | undefined;
|
|
415
|
+
/** Our persisted record. */
|
|
416
|
+
persisted: PersistedModelDefault | undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export interface ModelDefaultApplyDecision {
|
|
420
|
+
apply: boolean;
|
|
421
|
+
reason: ModelDefaultApplyReason;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Decide whether a brand-new session should adopt the persisted default.
|
|
426
|
+
*
|
|
427
|
+
* Guarantees (each branch is unit-tested):
|
|
428
|
+
* - `resume` / `reload` never apply — a resumed session keeps its recorded
|
|
429
|
+
* model, which is Pi's own precedence.
|
|
430
|
+
* - Workflow's spawned subagents (`PI_WORKFLOW_ROLE`, e.g. `pi --mode json
|
|
431
|
+
* --model <role model>`) never apply — otherwise every explore/reviewer
|
|
432
|
+
* child would be re-pointed at the chat default.
|
|
433
|
+
* - An explicitly chosen live model never gets overwritten. We treat the live
|
|
434
|
+
* model as "explicit" when it differs from ALL of: Pi's first scoped model
|
|
435
|
+
* (the value Pi would have picked on its own), Pi's saved default, and our
|
|
436
|
+
* persisted record. Anything explicit (`--model`, another extension, a
|
|
437
|
+
* project default) therefore wins.
|
|
438
|
+
* - When the live model already IS the persisted default, nothing to do.
|
|
439
|
+
* - An explicit PROJECT-level default (`<cwd>/.pi/settings.json`) is never
|
|
440
|
+
* replaced: Pi merges that file over the global one, so the extension agrees
|
|
441
|
+
* with Pi rather than fighting it (user decision).
|
|
442
|
+
*/
|
|
443
|
+
export function shouldApplyModelDefaultAtSessionStart(
|
|
444
|
+
input: ModelDefaultApplyInput,
|
|
445
|
+
): ModelDefaultApplyDecision {
|
|
446
|
+
if (input.reason !== "startup" && input.reason !== "new") {
|
|
447
|
+
return { apply: false, reason: "reason-not-new" };
|
|
448
|
+
}
|
|
449
|
+
if (input.subagentRoleEnv && input.subagentRoleEnv.trim()) {
|
|
450
|
+
return { apply: false, reason: "subagent" };
|
|
451
|
+
}
|
|
452
|
+
const persisted = input.persisted;
|
|
453
|
+
if (!persisted) return { apply: false, reason: "no-persisted" };
|
|
454
|
+
// A project-level default outranks the extension's record inside its project.
|
|
455
|
+
// Checked before the live-model comparisons so a live model that happens to
|
|
456
|
+
// equal `scopedFirst` cannot be mistaken for Pi's own pick.
|
|
457
|
+
if (input.projectDefault) {
|
|
458
|
+
return { apply: false, reason: "project-default" };
|
|
459
|
+
}
|
|
460
|
+
const live = input.live;
|
|
461
|
+
if (!live) return { apply: true, reason: "apply" };
|
|
462
|
+
if (sameModelRef(live, persisted)) {
|
|
463
|
+
return { apply: false, reason: "live-is-persisted" };
|
|
464
|
+
}
|
|
465
|
+
const matchesScopedFirst = sameModelRef(live, input.scopedFirst);
|
|
466
|
+
const matchesSettingsDefault = sameModelRef(live, input.settingsDefault);
|
|
467
|
+
if (matchesScopedFirst || matchesSettingsDefault) {
|
|
468
|
+
return { apply: true, reason: "apply" };
|
|
469
|
+
}
|
|
470
|
+
return { apply: false, reason: "live-is-explicit" };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Event gating ────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Should a `model_select` event be recorded as the saved default?
|
|
477
|
+
*
|
|
478
|
+
* Only a user gesture counts: `source: "set"` (picker Enter/Ctrl+S, `/model`,
|
|
479
|
+
* Pi Desktop's picker) or `source: "cycle"` (Ctrl+P / Ctrl+Alt). `"restore"`
|
|
480
|
+
* is Pi re-applying a recorded model on resume/reload, and any switch made by
|
|
481
|
+
* the extension itself runs with `suppressed` set.
|
|
482
|
+
*
|
|
483
|
+
* This is the regression lock for "entering Plan/Build must not change the
|
|
484
|
+
* default model": the extension sets `suppressed` around its own switches.
|
|
485
|
+
*/
|
|
486
|
+
export function shouldPersistModelSelect(input: {
|
|
487
|
+
source: string | undefined;
|
|
488
|
+
suppressed: boolean;
|
|
489
|
+
}): boolean {
|
|
490
|
+
if (input.suppressed) return false;
|
|
491
|
+
return input.source === "set" || input.source === "cycle";
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Should a `thinking_level_select` event be recorded as the saved default's
|
|
496
|
+
* thinking level?
|
|
497
|
+
*
|
|
498
|
+
* - Never while the extension is switching (mode entry / restore / startup).
|
|
499
|
+
* - Only for the model the default refers to. A role model's thinking belongs
|
|
500
|
+
* to that role, so persisting it would corrupt the next session's default.
|
|
501
|
+
* - When no default is recorded yet, the current model is adopted (a user who
|
|
502
|
+
* changes only the thinking level has made an explicit choice).
|
|
503
|
+
*/
|
|
504
|
+
export function shouldPersistThinkingSelect(input: {
|
|
505
|
+
suppressed: boolean;
|
|
506
|
+
current: { provider: string; id: string } | undefined;
|
|
507
|
+
defaultModel: { provider: string; id: string } | undefined;
|
|
508
|
+
}): boolean {
|
|
509
|
+
if (input.suppressed) return false;
|
|
510
|
+
if (!input.current?.provider || !input.current.id) return false;
|
|
511
|
+
if (!input.defaultModel) return true;
|
|
512
|
+
return sameModelRef(input.current, input.defaultModel);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Short human explanation for a decision (warnings, diagnostics). */
|
|
516
|
+
export function describeModelDefaultApply(
|
|
517
|
+
decision: ModelDefaultApplyDecision,
|
|
518
|
+
): string {
|
|
519
|
+
switch (decision.reason) {
|
|
520
|
+
case "apply":
|
|
521
|
+
return "applying the saved default";
|
|
522
|
+
case "reason-not-new":
|
|
523
|
+
return "resumed/reloaded session — keeping its recorded model";
|
|
524
|
+
case "subagent":
|
|
525
|
+
return "workflow subagent — keeping its role model";
|
|
526
|
+
case "no-persisted":
|
|
527
|
+
return "no saved default";
|
|
528
|
+
case "project-default":
|
|
529
|
+
return "a project-level default is configured — keeping it";
|
|
530
|
+
case "live-is-persisted":
|
|
531
|
+
return "already on the saved default";
|
|
532
|
+
case "live-is-explicit":
|
|
533
|
+
return "an explicit model was selected — keeping it";
|
|
534
|
+
}
|
|
535
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
7
7
|
"description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts",
|
|
11
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
|
+
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
|
|
11
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
12
|
+
"check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs"
|
|
12
13
|
},
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
|
@@ -30,6 +31,12 @@
|
|
|
30
31
|
"edit-policy.ts",
|
|
31
32
|
"roles.ts",
|
|
32
33
|
"profiles.ts",
|
|
34
|
+
"picker.ts",
|
|
35
|
+
"tab-policy.ts",
|
|
36
|
+
"tab-editor.ts",
|
|
37
|
+
"questionnaire-layout.ts",
|
|
38
|
+
"questionnaire-view.ts",
|
|
39
|
+
"model-default.ts",
|
|
33
40
|
"permissions.ts",
|
|
34
41
|
"review.ts",
|
|
35
42
|
"review-providers.ts",
|