@dieulc/workflow 0.3.3 → 0.5.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/profiles.ts ADDED
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Workflow Extension — Model Profiles
3
+ *
4
+ * Named, switchable combinations of role model settings — a layer on top of
5
+ * Model Roles (roles.ts):
6
+ *
7
+ * planner 🧠 explorer 🔍 builder 🔨 reviewer 🧪
8
+ *
9
+ * - A profile stores exactly one {provider, id, thinking} per built-in role.
10
+ * Role identity (tools, systemPromptAddendum, description) is NOT part of a
11
+ * profile — it stays global in roles.json. A profile only overrides models.
12
+ * - Persistence: ~/.pi/agent/profiles.json (user) + <cwd>/.pi/profiles.json
13
+ * (project override, merged by profile name, project wins). roles.json is
14
+ * never touched by this module.
15
+ * - Activation is SESSION-scoped (index.ts keeps it in memory and mirrors it
16
+ * into the `workflow` session entry) so two concurrent sessions can run
17
+ * different profiles. The only global state here is the optional
18
+ * `workflow.defaultProfile` settings.json key, which seeds sessions that
19
+ * never made an explicit choice.
20
+ * - Non-destructive loader: a missing, unreadable or incompatible
21
+ * profiles.json is never rewritten. A MISSING file is the normal
22
+ * "feature unused" state (info); corrupt/incompatible files surface a
23
+ * warning while the in-memory config stays empty.
24
+ *
25
+ * Pure, I/O-light module — unit-testable without a Pi runtime. It depends on
26
+ * ./roles.ts only for shared types + display helpers (roles.ts has no import
27
+ * side effects), and on ./commit-policy.ts for the shared settings constants.
28
+ * Same discipline as commit-policy.ts / review.ts.
29
+ */
30
+
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
34
+
35
+ import type { Role, RoleModel, ThinkingLevel } from "./roles.ts";
36
+ import {
37
+ BUILT_IN_ROLES,
38
+ formatModelRef,
39
+ isValidThinkingLevel,
40
+ roleIcon,
41
+ } from "./roles.ts";
42
+ import { COMMIT_CONFIG_DIR, WORKFLOW_SETTINGS_KEY } from "./commit-policy.ts";
43
+
44
+ // ── Types ────────────────────────────────────────────────────────────
45
+
46
+ /** The role names a profile may override — exactly the built-ins in v1. */
47
+ export const PROFILE_ROLES = BUILT_IN_ROLES;
48
+ export type ProfileRoleName = (typeof PROFILE_ROLES)[number];
49
+
50
+ export interface Profile {
51
+ /** Stored as entered (trimmed); lookups are case-insensitive. */
52
+ name: string;
53
+ description: string;
54
+ /** Partial by design: hand-edited files may override a subset of roles. */
55
+ roles: Partial<Record<ProfileRoleName, RoleModel>>;
56
+ }
57
+
58
+ export interface ProfileConfig {
59
+ version: number;
60
+ profiles: Profile[];
61
+ }
62
+
63
+ export const PROFILE_CONFIG_VERSION = 1;
64
+
65
+ /** Profile names longer than this are rejected. */
66
+ export const MAX_PROFILE_NAME_LENGTH = 40;
67
+
68
+ /** Reserved words — they collide with `/profile use <name|none>` and `/role use` style keywords. */
69
+ export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto"];
70
+
71
+ /** Fallback thinking level when a sanitized model omits an invalid one. */
72
+ const DEFAULT_THINKING: ThinkingLevel = "medium";
73
+
74
+ export function defaultProfileConfig(): ProfileConfig {
75
+ return { version: PROFILE_CONFIG_VERSION, profiles: [] };
76
+ }
77
+
78
+ // ── Name validation ──────────────────────────────────────────────────
79
+
80
+ export type ProfileNameValidation =
81
+ | { ok: true; value: string }
82
+ | { ok: false; error: string };
83
+
84
+ /**
85
+ * Validate a profile name: trimmed, non-empty, ≤ MAX_PROFILE_NAME_LENGTH,
86
+ * no control characters, no whitespace (names must stay CLI-addressable —
87
+ * slash-command args split on whitespace), not a reserved word, and not a
88
+ * case-insensitive duplicate of an existing name. Pure.
89
+ */
90
+ export function validateProfileName(
91
+ name: string,
92
+ existing?: readonly string[],
93
+ ): ProfileNameValidation {
94
+ const trimmed = String(name ?? "").trim();
95
+ if (!trimmed) return { ok: false, error: "Profile name is empty." };
96
+ if (trimmed.length > MAX_PROFILE_NAME_LENGTH) {
97
+ return {
98
+ ok: false,
99
+ error: `Profile name is longer than ${MAX_PROFILE_NAME_LENGTH} characters.`,
100
+ };
101
+ }
102
+ for (const ch of trimmed) {
103
+ const code = ch.codePointAt(0) ?? 0;
104
+ if (code < 0x20 || code === 0x7f) {
105
+ return {
106
+ ok: false,
107
+ error: "Profile name contains control characters.",
108
+ };
109
+ }
110
+ if (/\s/.test(ch)) {
111
+ return {
112
+ ok: false,
113
+ error:
114
+ "Profile name must not contain whitespace — use dashes or underscores (e.g. cheap-synthetic).",
115
+ };
116
+ }
117
+ }
118
+ const lower = trimmed.toLowerCase();
119
+ if ((RESERVED_PROFILE_NAMES as readonly string[]).includes(lower)) {
120
+ return {
121
+ ok: false,
122
+ error: `"${lower}" is reserved (used by /profile use <name|none>).`,
123
+ };
124
+ }
125
+ if (existing) {
126
+ for (const e of existing) {
127
+ if (String(e).trim().toLowerCase() === lower) {
128
+ return {
129
+ ok: false,
130
+ error: `Profile "${e}" already exists (names are case-insensitive).`,
131
+ };
132
+ }
133
+ }
134
+ }
135
+ return { ok: true, value: trimmed };
136
+ }
137
+
138
+ // ── Paths & persistence ──────────────────────────────────────────────
139
+
140
+ export function userProfilesPath(): string {
141
+ return path.join(getAgentDir(), "profiles.json");
142
+ }
143
+
144
+ export function projectProfilesPath(cwd: string): string {
145
+ return path.join(cwd, CONFIG_DIR_NAME, "profiles.json");
146
+ }
147
+
148
+ /** Optional path overrides (tests); production reads the real agent/cwd files. */
149
+ export interface ProfileConfigPathOverrides {
150
+ userPath?: string;
151
+ projectPath?: string;
152
+ }
153
+
154
+ function fileFingerprint(fp: string): string {
155
+ try {
156
+ const st = fs.statSync(fp);
157
+ return `${st.mtimeMs}:${st.size}`;
158
+ } catch (e: unknown) {
159
+ const code = (e as { code?: string } | undefined)?.code;
160
+ return code === "ENOENT" ? "missing" : "unreadable";
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Cheap change detector for the two profiles files (existence + mtime + size),
166
+ * mirroring rolesFingerprint. Compare two fingerprints to decide whether a
167
+ * reload is needed. Never throws.
168
+ */
169
+ export function profilesFingerprint(
170
+ cwd?: string,
171
+ overrides?: ProfileConfigPathOverrides,
172
+ ): string {
173
+ const user = fileFingerprint(overrides?.userPath ?? userProfilesPath());
174
+ const projectPath =
175
+ overrides?.projectPath ?? (cwd ? projectProfilesPath(cwd) : undefined);
176
+ const project = projectPath ? fileFingerprint(projectPath) : "n/a";
177
+ return `user:${user}|project:${project}`;
178
+ }
179
+
180
+ interface JsonFileRead {
181
+ exists: boolean;
182
+ raw?: unknown;
183
+ error?: string;
184
+ }
185
+
186
+ function readJsonFileDetailed(fp: string): JsonFileRead {
187
+ if (!fs.existsSync(fp)) return { exists: false };
188
+ try {
189
+ return { exists: true, raw: JSON.parse(fs.readFileSync(fp, "utf8")) };
190
+ } catch (e: unknown) {
191
+ return {
192
+ exists: true,
193
+ error: e instanceof Error ? e.message : String(e),
194
+ };
195
+ }
196
+ }
197
+
198
+ function isCurrentVersion(raw: any): boolean {
199
+ return (
200
+ raw &&
201
+ typeof raw === "object" &&
202
+ (raw as any).version === PROFILE_CONFIG_VERSION
203
+ );
204
+ }
205
+
206
+ // ── Sanitizing (untrusted file content → Profile) ────────────────────
207
+
208
+ function sanitizeProfileModel(m: any): RoleModel | undefined {
209
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
210
+ return undefined;
211
+ if (!m.provider.trim() || !m.id.trim()) return undefined;
212
+ return {
213
+ provider: m.provider.trim(),
214
+ id: m.id.trim(),
215
+ thinking: isValidThinkingLevel(String(m.thinking))
216
+ ? (String(m.thinking) as ThinkingLevel)
217
+ : DEFAULT_THINKING,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Coerce one untrusted entry into a Profile, or undefined when unusable.
223
+ * Unknown role keys are dropped (v1 knows only the 4 built-ins); a profile
224
+ * with zero usable role overrides is a no-op and is dropped entirely.
225
+ * Never throws.
226
+ */
227
+ export function sanitizeProfile(p: any): Profile | undefined {
228
+ if (!p || typeof p.name !== "string") return undefined;
229
+ // Uniqueness is enforced at merge time; here we only check intrinsic rules.
230
+ const nameCheck = validateProfileName(p.name);
231
+ if (!nameCheck.ok) return undefined;
232
+ const rawRoles =
233
+ p.roles && typeof p.roles === "object" && !Array.isArray(p.roles)
234
+ ? p.roles
235
+ : {};
236
+ const roles: Partial<Record<ProfileRoleName, RoleModel>> = {};
237
+ for (const roleName of PROFILE_ROLES) {
238
+ const m = sanitizeProfileModel((rawRoles as any)[roleName]);
239
+ if (m) roles[roleName] = m;
240
+ }
241
+ if (Object.keys(roles).length === 0) return undefined;
242
+ return {
243
+ name: nameCheck.value,
244
+ description: typeof p.description === "string" ? p.description.trim() : "",
245
+ roles,
246
+ };
247
+ }
248
+
249
+ /** Role names a profile does not override (falls back to base roles at runtime). */
250
+ export function missingProfileRoles(p: Profile): ProfileRoleName[] {
251
+ return PROFILE_ROLES.filter((r) => !p.roles[r]);
252
+ }
253
+
254
+ function mergeProfiles(base: Profile[], rawProfiles: unknown): Profile[] {
255
+ if (!Array.isArray(rawProfiles)) return base;
256
+ const byName = new Map<string, Profile>();
257
+ for (const p of base) byName.set(p.name.toLowerCase(), p);
258
+ for (const raw of rawProfiles as any[]) {
259
+ const s = sanitizeProfile(raw);
260
+ if (s) byName.set(s.name.toLowerCase(), s); // last wins, like roles.ts
261
+ }
262
+ return [...byName.values()];
263
+ }
264
+
265
+ // ── Load & save ──────────────────────────────────────────────────────
266
+
267
+ /** Result of {@link loadProfilesDetailed}. */
268
+ export interface LoadedProfilesConfig {
269
+ config: ProfileConfig;
270
+ /** True when neither file supplied a usable profile list. Normal when the feature is unused. */
271
+ usedDefaults: boolean;
272
+ /** Why the user file was skipped (missing / unreadable / legacy). */
273
+ reason?: string;
274
+ /** A MISSING file is "info" (normal empty state); corrupt/incompatible is "warning". */
275
+ severity?: "info" | "warning";
276
+ /** Fingerprint of the profiles files at read time (see profilesFingerprint). */
277
+ fingerprint: string;
278
+ }
279
+
280
+ /**
281
+ * Load the merged profile config (user file + project override, per-profile
282
+ * replace). Non-destructive by design: a missing, unreadable, corrupt or
283
+ * legacy `profiles.json` is never rewritten — in-memory defaults are used and
284
+ * the reason is reported. Never throws.
285
+ */
286
+ export function loadProfilesDetailed(
287
+ cwd?: string,
288
+ overrides?: ProfileConfigPathOverrides,
289
+ ): LoadedProfilesConfig {
290
+ const base = defaultProfileConfig();
291
+ const fingerprint = profilesFingerprint(cwd, overrides);
292
+ let reason: string | undefined;
293
+ let severity: "info" | "warning" | undefined;
294
+ let userUsable = false;
295
+
296
+ const userPath = overrides?.userPath ?? userProfilesPath();
297
+ const userRead = readJsonFileDetailed(userPath);
298
+ if (!userRead.exists) {
299
+ reason = `no profiles.json at ${userPath} — no profiles defined (roles.json flow stays active)`;
300
+ severity = "info";
301
+ } else if (userRead.error) {
302
+ reason = `profiles.json is not valid JSON (${userRead.error}) — ignoring profiles`;
303
+ severity = "warning";
304
+ } else if (!isCurrentVersion(userRead.raw)) {
305
+ const v = (userRead.raw as { version?: unknown } | undefined)?.version;
306
+ reason = `incompatible profiles.json${
307
+ v === undefined ? "" : ` (version ${String(v)})`
308
+ } — ignoring profiles`;
309
+ severity = "warning";
310
+ } else {
311
+ base.profiles = mergeProfiles(
312
+ base.profiles,
313
+ (userRead.raw as any).profiles,
314
+ );
315
+ userUsable = true;
316
+ }
317
+
318
+ // Project override: per-profile replace. A bad or legacy project file is
319
+ // ignored and never rewritten (it is user-owned).
320
+ let projectUsable = false;
321
+ const projectPath =
322
+ overrides?.projectPath ?? (cwd ? projectProfilesPath(cwd) : undefined);
323
+ if (projectPath) {
324
+ const projectRead = readJsonFileDetailed(projectPath);
325
+ if (
326
+ projectRead.exists &&
327
+ !projectRead.error &&
328
+ isCurrentVersion(projectRead.raw)
329
+ ) {
330
+ base.profiles = mergeProfiles(
331
+ base.profiles,
332
+ (projectRead.raw as any).profiles,
333
+ );
334
+ projectUsable = true;
335
+ }
336
+ }
337
+
338
+ return {
339
+ config: base,
340
+ usedDefaults: !userUsable && !projectUsable,
341
+ ...(reason === undefined ? {} : { reason }),
342
+ ...(severity === undefined ? {} : { severity }),
343
+ fingerprint,
344
+ };
345
+ }
346
+
347
+ /** Convenience wrapper returning just the config. Never throws. */
348
+ export function loadProfiles(
349
+ cwd?: string,
350
+ overrides?: ProfileConfigPathOverrides,
351
+ ): ProfileConfig {
352
+ return loadProfilesDetailed(cwd, overrides).config;
353
+ }
354
+
355
+ export function saveProfiles(
356
+ config: ProfileConfig,
357
+ scope: "user" | "project",
358
+ cwd?: string,
359
+ ): void {
360
+ const payload: ProfileConfig = {
361
+ version: PROFILE_CONFIG_VERSION,
362
+ profiles: config.profiles,
363
+ };
364
+ const fp =
365
+ scope === "project" && cwd
366
+ ? projectProfilesPath(cwd)
367
+ : userProfilesPath();
368
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
369
+ fs.writeFileSync(fp, JSON.stringify(payload, null, 2), "utf8");
370
+ }
371
+
372
+ // ── Lookup ───────────────────────────────────────────────────────────
373
+
374
+ export function getProfile(
375
+ config: ProfileConfig,
376
+ name: string,
377
+ ): Profile | undefined {
378
+ const target = String(name).trim().toLowerCase();
379
+ if (!target) return undefined;
380
+ return config.profiles.find((p) => p.name.toLowerCase() === target);
381
+ }
382
+
383
+ // ── Effective-roles overlay ──────────────────────────────────────────
384
+
385
+ /**
386
+ * Apply a profile's model overrides to a base roles array. Pure: returns a
387
+ * NEW array of NEW role objects; the inputs are never mutated. For each of
388
+ * the 4 built-in roles present in the profile, the role's `model` is replaced
389
+ * (provider/id/thinking); role identity (tools, systemPromptAddendum,
390
+ * description, builtIn) always comes from the base. A missing/null profile
391
+ * yields a shallow copy that is behaviourally identical to the base — this is
392
+ * the "no profile active → roles.json flow" fallback.
393
+ */
394
+ export function applyProfileOverlay(
395
+ baseRoles: Role[],
396
+ profile: Profile | null | undefined,
397
+ ): Role[] {
398
+ const roles = baseRoles.map((r) => ({ ...r }));
399
+ if (!profile) return roles;
400
+ for (const roleName of PROFILE_ROLES) {
401
+ const override = profile.roles[roleName];
402
+ if (!override) continue;
403
+ const role = roles.find((r) => r.name.toLowerCase() === roleName);
404
+ if (!role) continue;
405
+ role.model = { ...override };
406
+ }
407
+ return roles;
408
+ }
409
+
410
+ // ── Display ──────────────────────────────────────────────────────────
411
+
412
+ /** One-line summary: `⚡ cheap — 🧠 a/b (high) · 🔍 …` (for picker rows). */
413
+ export function formatProfileOneLine(p: Profile): string {
414
+ const parts = PROFILE_ROLES.map((r) => {
415
+ const m = p.roles[r];
416
+ return m ? `${roleIcon(r)} ${formatModelRef(m)} (${m.thinking})` : `${roleIcon(r)} —`;
417
+ });
418
+ const desc = p.description ? ` — ${p.description}` : "";
419
+ return `⚡ ${p.name}${desc}: ${parts.join(" · ")}`;
420
+ }
421
+
422
+ /** Multi-line detail for `/profile show <name>`. */
423
+ export function describeProfile(p: Profile): string {
424
+ const lines: string[] = [];
425
+ lines.push(`⚡ Profile ${p.name}${p.description ? ` — ${p.description}` : ""}`);
426
+ for (const r of PROFILE_ROLES) {
427
+ const m = p.roles[r];
428
+ lines.push(
429
+ ` ${roleIcon(r)} ${r}: ${
430
+ m ? `${formatModelRef(m)} (${m.thinking})` : "(not set — falls back to base roles)"
431
+ }`,
432
+ );
433
+ }
434
+ return lines.join("\n");
435
+ }
436
+
437
+ export function formatProfilesForDisplay(
438
+ config: ProfileConfig,
439
+ activeName?: string | null,
440
+ defaultName?: string | null,
441
+ ): string {
442
+ const lines: string[] = [];
443
+ lines.push("Model Profiles (named role-model combinations):");
444
+ const activeLabel = activeName ? `⚡ ${activeName}` : "none (default roles)";
445
+ const defaultLabel = defaultName ? ` · default: ${defaultName}` : "";
446
+ lines.push(` Active: ${activeLabel}${defaultLabel}`);
447
+ lines.push("");
448
+ if (config.profiles.length === 0) {
449
+ lines.push(" (no profiles yet — create one with /profile create <name>)");
450
+ }
451
+ const activeLower = String(activeName ?? "").trim().toLowerCase();
452
+ const defaultLower = String(defaultName ?? "").trim().toLowerCase();
453
+ for (const p of config.profiles) {
454
+ const nameLower = p.name.toLowerCase();
455
+ const markers = [
456
+ activeLower && nameLower === activeLower ? "● active" : "",
457
+ defaultLower && nameLower === defaultLower ? "★ default" : "",
458
+ ].filter(Boolean);
459
+ const desc = p.description ? ` — ${p.description}` : "";
460
+ const missing = missingProfileRoles(p);
461
+ const partial =
462
+ missing.length > 0 && missing.length < PROFILE_ROLES.length
463
+ ? ` (partial: ${missing.join(", ")} fall back to base roles)`
464
+ : "";
465
+ lines.push(` ⚡ ${p.name}${markers.length ? ` (${markers.join(", ")})` : ""}${desc}${partial}`);
466
+ for (const r of PROFILE_ROLES) {
467
+ const m = p.roles[r];
468
+ lines.push(
469
+ ` ${roleIcon(r)} ${r}: ${
470
+ m ? `${formatModelRef(m)} (${m.thinking})` : "(base roles)"
471
+ }`,
472
+ );
473
+ }
474
+ }
475
+ lines.push("");
476
+ lines.push(
477
+ "Usage: /profile (picker) | /profile list | /profile use <name|none> | /profile create <name> | /profile edit <name> | /profile rename <old> <new> | /profile delete <name> | /profile default [name|none] [global|project]",
478
+ );
479
+ return lines.join("\n");
480
+ }
481
+
482
+ // ── workflow.defaultProfile setting ──────────────────────────────────
483
+ // Mirrors commit-policy.ts: settings live under `workflow` in
484
+ // ~/.pi/agent/settings.json (global) or <cwd>/.pi/settings.json (project);
485
+ // project wins. Writes preserve every other key in the file (Pi owns
486
+ // settings.json — we must not clobber it).
487
+
488
+ /** Nested key under WORKFLOW_SETTINGS_KEY. */
489
+ export const DEFAULT_PROFILE_KEY = "defaultProfile";
490
+
491
+ export interface DefaultProfileSettingPaths {
492
+ global: string;
493
+ project: string;
494
+ }
495
+
496
+ /**
497
+ * Settings file locations. `agentDir` is passed in rather than read from the
498
+ * Pi SDK so this stays pure and testable.
499
+ */
500
+ export function defaultProfileSettingPaths(
501
+ cwd: string,
502
+ agentDir: string,
503
+ ): DefaultProfileSettingPaths {
504
+ return {
505
+ global: path.join(agentDir, "settings.json"),
506
+ project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Extract `workflow.defaultProfile` (a profile name string) from a raw
512
+ * settings object. Anything else (absent, non-string, blank) → undefined.
513
+ */
514
+ export function extractDefaultProfile(settings: unknown): string | undefined {
515
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
516
+ return undefined;
517
+ const wf = (settings as Record<string, unknown>)[WORKFLOW_SETTINGS_KEY];
518
+ if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
519
+ const raw = (wf as Record<string, unknown>)[DEFAULT_PROFILE_KEY];
520
+ if (typeof raw !== "string") return undefined;
521
+ const trimmed = raw.trim();
522
+ return trimmed ? trimmed : undefined;
523
+ }
524
+
525
+ /** Which settings scope supplied the effective default profile. */
526
+ export type DefaultProfileSource = "default" | "global" | "project";
527
+
528
+ export interface ResolvedDefaultProfile {
529
+ /** The effective profile name, or undefined when none is configured. */
530
+ value: string | undefined;
531
+ /** Which files contributed (for /profile default status). */
532
+ sources: {
533
+ global: boolean;
534
+ project: boolean;
535
+ effective: DefaultProfileSource;
536
+ };
537
+ }
538
+
539
+ function readSettingsSafe(fp: string): Record<string, unknown> | undefined {
540
+ try {
541
+ if (!fs.existsSync(fp)) return undefined;
542
+ const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
543
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
544
+ return undefined;
545
+ return parsed as Record<string, unknown>;
546
+ } catch {
547
+ return undefined;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Resolve the effective `workflow.defaultProfile` from disk.
553
+ * Never throws — a corrupt or missing file falls back to "no default".
554
+ */
555
+ export function readDefaultProfileSetting(
556
+ cwd: string,
557
+ agentDir: string,
558
+ ): ResolvedDefaultProfile {
559
+ const paths = defaultProfileSettingPaths(cwd, agentDir);
560
+ const globalRaw = extractDefaultProfile(readSettingsSafe(paths.global));
561
+ const projectRaw = extractDefaultProfile(readSettingsSafe(paths.project));
562
+ // Project wins over global; global wins over unset.
563
+ const value = projectRaw ?? globalRaw;
564
+ return {
565
+ value,
566
+ sources: {
567
+ global: globalRaw !== undefined,
568
+ project: projectRaw !== undefined,
569
+ effective: projectRaw !== undefined ? "project" : globalRaw !== undefined ? "global" : "default",
570
+ },
571
+ };
572
+ }
573
+
574
+ /**
575
+ * Read-modify-write `workflow.defaultProfile` into a settings file, preserving
576
+ * every other key in that file (Pi owns settings.json — we must not clobber
577
+ * it). `value === null` clears the key; if the `workflow` object becomes empty
578
+ * it is removed entirely to keep the file tidy.
579
+ */
580
+ export function writeDefaultProfileSetting(
581
+ scope: "global" | "project",
582
+ cwd: string,
583
+ agentDir: string,
584
+ value: string | null,
585
+ ): { path: string; ok: boolean; error?: string } {
586
+ const paths = defaultProfileSettingPaths(cwd, agentDir);
587
+ const fp = scope === "project" ? paths.project : paths.global;
588
+ try {
589
+ const existing = readSettingsSafe(fp);
590
+ const root: Record<string, unknown> = existing ? { ...existing } : {};
591
+ const wfExisting = root[WORKFLOW_SETTINGS_KEY];
592
+ const wf: Record<string, unknown> =
593
+ wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
594
+ ? { ...(wfExisting as Record<string, unknown>) }
595
+ : {};
596
+ if (value === null) {
597
+ delete wf[DEFAULT_PROFILE_KEY];
598
+ } else {
599
+ const check = validateProfileName(value);
600
+ if (!check.ok) {
601
+ return { path: fp, ok: false, error: check.error };
602
+ }
603
+ wf[DEFAULT_PROFILE_KEY] = check.value;
604
+ }
605
+ if (Object.keys(wf).length === 0) delete root[WORKFLOW_SETTINGS_KEY];
606
+ else root[WORKFLOW_SETTINGS_KEY] = wf;
607
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
608
+ fs.writeFileSync(fp, JSON.stringify(root, null, 2) + "\n", "utf8");
609
+ return { path: fp, ok: true };
610
+ } catch (e: unknown) {
611
+ return {
612
+ path: fp,
613
+ ok: false,
614
+ error: e instanceof Error ? e.message : String(e),
615
+ };
616
+ }
617
+ }