@gtrabanco/pi-agentic-workflow 0.15.3 → 0.16.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.
@@ -7,6 +7,11 @@
7
7
  // a scope whose file does not parse cannot be overwritten (that file is evidence
8
8
  // the operator must fix, not noise to clobber), and the project scope is refused
9
9
  // while the project is untrusted — the same gate the loader applies (AC13).
10
+ //
11
+ // P5 (feature 63): the flow is scope → profile → routes. Every command operates
12
+ // on the active profile's route (for "default" this is top-level; for a named
13
+ // profile it is `profiles.<name>`). The profile defaults to "default", so
14
+ // existing tests that never answer a profile prompt keep working unchanged.
10
15
  import { loadConfig, configFilePaths } from "../config/load.js";
11
16
  import { effectiveRoute } from "../config/merge.js";
12
17
  import { parseConfigFile, parseModelReference } from "../config/schema.js";
@@ -32,6 +37,13 @@ export const prompts = {
32
37
  clearOverride: "Clear a command override",
33
38
  bulkApply: "Apply one route to several commands",
34
39
  bulkClear: "Clear several overrides",
40
+ switchProfile: "Switch the profile being edited",
41
+ rotateProfile: "Rotate the active profile",
42
+ toggleRecommended: "Toggle recommended models",
43
+ profile: "Which profile?",
44
+ newProfile: "Name the new profile",
45
+ profileBuiltIn: (name) => `The "${name}" profile is built into the package and cannot be edited — create a profile to override it.`,
46
+ profileNamed: (name) => `Editing profile "${name}"`,
35
47
  addAnother: "Add another?",
36
48
  policy: "Set the unavailable-route policy",
37
49
  settle: "Set the post-command model keep/restore policy",
@@ -57,23 +69,27 @@ const INHERIT = "inherit";
57
69
  export async function runSettingsConsole(deps) {
58
70
  const paths = configFilePaths(deps.agentDir, deps.cwd);
59
71
  const merged = loadConfig({ agentDir: deps.agentDir, cwd: deps.cwd, projectTrusted: deps.projectTrusted, readFile: deps.readFile });
60
- deps.ui.notify(renderMergedConfig(merged, deps.commands).join("\n"), "info");
61
- /** The value in force for a target (the merged route, or the default when the target is "the default route"). */
62
- const currentFor = (target) => effectiveRoute(merged.config, target);
72
+ deps.ui.notify(renderMergedConfig(merged, deps.commands, merged.config.profiles?.nan !== undefined || (deps.models ?? []).some((m) => m.startsWith("nan/"))
73
+ ? { providerAvailable: (provider) => (deps.models ?? []).some((m) => m.startsWith(`${provider}/`)) }
74
+ : undefined).join("\n"), "info");
63
75
  const opened = await openAScope(deps, paths.global, paths.project);
64
76
  if (!opened)
65
77
  return { status: "cancelled", edited: false };
66
78
  const { scope, path, original } = opened;
67
79
  let draft = original;
80
+ let active = "default";
68
81
  for (;;) {
69
82
  // Only offered while a turn actually holds the latch: an option that does
70
83
  // nothing is how a console becomes noise.
71
84
  const choice = await deps.ui.select(prompts.menu, [
85
+ prompts.switchProfile,
72
86
  prompts.setDefaultRoute,
73
87
  prompts.setOverride,
74
88
  prompts.clearOverride,
75
89
  prompts.bulkApply,
76
90
  prompts.bulkClear,
91
+ prompts.rotateProfile,
92
+ prompts.toggleRecommended,
77
93
  prompts.policy,
78
94
  prompts.settle,
79
95
  ...(deps.routing?.inFlight() ? [prompts.undoInFlight] : []),
@@ -88,57 +104,172 @@ export async function runSettingsConsole(deps) {
88
104
  if (choice === undefined || choice === prompts.cancel) {
89
105
  return { status: "cancelled", edited: dirty(draft, original) ? await discard(deps.ui) : false };
90
106
  }
107
+ // Profile: switch to another profile (scope → profile → routes)
108
+ if (choice === prompts.switchProfile) {
109
+ const picked = await pickProfile(deps, draft, { allowNew: true });
110
+ if (picked === undefined)
111
+ continue;
112
+ // P5: the built-in "nan" profile cannot be edited — create a profile
113
+ // to override it.
114
+ if (picked === "nan" && draft.profiles?.nan === undefined) {
115
+ deps.ui.notify(prompts.profileBuiltIn("nan"), "warning");
116
+ continue;
117
+ }
118
+ if (picked !== "default" && !Object.prototype.hasOwnProperty.call(draft.profiles ?? {}, picked)) {
119
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [picked]: {} } };
120
+ }
121
+ active = picked;
122
+ deps.ui.notify(prompts.profileNamed(active), "info");
123
+ continue;
124
+ }
125
+ // Rotate: move a profile to the front of profileOrder, clear demotion
126
+ if (choice === prompts.rotateProfile) {
127
+ const picked = await pickProfile(deps, draft, { allowNew: false });
128
+ if (picked === undefined)
129
+ continue;
130
+ const base = draft.profileOrder && draft.profileOrder.length > 0 ? draft.profileOrder : ["default"];
131
+ draft = { ...draft, profileOrder: [picked, ...base.filter((n) => n !== picked)] };
132
+ deps.routing?.clearProfileDemotion?.();
133
+ continue;
134
+ }
135
+ // Toggle recommended models
136
+ if (choice === prompts.toggleRecommended) {
137
+ const current = draft.recommendedModels ?? merged.config.recommendedModels;
138
+ draft = { ...draft, recommendedModels: !current };
139
+ continue;
140
+ }
141
+ // Set default route on the active profile
91
142
  if (choice === prompts.setDefaultRoute) {
92
- const edited = await editRoute(deps, DEFAULT_ROUTE, currentFor(DEFAULT_ROUTE));
93
- if (edited)
94
- draft = { ...draft, default: edited };
143
+ const target = active === "default" ? DEFAULT_ROUTE : `profile ${active} default`;
144
+ const routeFile = active === "default"
145
+ ? (draft.default ?? {})
146
+ : (draft.profiles?.[active]?.default ?? {});
147
+ const currentRoute = {
148
+ model: routeFile.model ?? "inherit",
149
+ thinking: routeFile.thinking ?? "inherit",
150
+ };
151
+ const edited = await editRoute(deps, target, currentRoute);
152
+ if (edited) {
153
+ if (active === "default") {
154
+ draft = { ...draft, default: edited };
155
+ }
156
+ else {
157
+ draft = {
158
+ ...draft,
159
+ profiles: { ...(draft.profiles ?? {}), [active]: { ...(draft.profiles?.[active] ?? {}), default: edited } },
160
+ };
161
+ }
162
+ }
95
163
  continue;
96
164
  }
165
+ // Set override on active profile's commands
97
166
  if (choice === prompts.setOverride) {
98
- const name = await pickCommand(deps, commandChoices(deps, draft));
167
+ // Use the full command registry so operators can add new overrides (AC4/OB-3).
168
+ const cmdNames = [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
169
+ const name = await pickCommand(deps, cmdNames);
99
170
  if (name === undefined)
100
171
  continue;
101
- const route = await editRoute(deps, name, currentFor(name));
102
- if (route)
103
- draft = { ...draft, commands: { ...draft.commands, [name]: route } };
172
+ // The initial value comes from the merged config so the picker shows the
173
+ // value in force, matching the original `currentFor(name)` behavior.
174
+ const effective = effectiveRoute(merged.config, name);
175
+ const routeFile = active === "default"
176
+ ? (draft.commands?.[name] ?? {})
177
+ : (draft.profiles?.[active]?.commands?.[name] ?? {});
178
+ const currentRoute = {
179
+ model: typeof routeFile?.model !== "undefined" ? routeFile.model : effective.model,
180
+ thinking: typeof routeFile?.thinking !== "undefined" ? routeFile.thinking : effective.thinking,
181
+ };
182
+ const route = await editRoute(deps, name, currentRoute);
183
+ if (route) {
184
+ if (active === "default") {
185
+ draft = { ...draft, commands: { ...draft.commands, [name]: route } };
186
+ }
187
+ else {
188
+ draft = {
189
+ ...draft,
190
+ profiles: {
191
+ ...(draft.profiles ?? {}),
192
+ [active]: { ...(draft.profiles?.[active] ?? {}), commands: { ...(draft.profiles?.[active]?.commands ?? {}), [name]: route } },
193
+ },
194
+ };
195
+ }
196
+ }
104
197
  continue;
105
198
  }
199
+ // Clear override on active profile's commands
106
200
  if (choice === prompts.clearOverride) {
107
- const name = await pickCommand(deps, Object.keys(draft.commands ?? {}));
201
+ const cmdNames = Object.keys(profileOf(draft, active).commands ?? {});
202
+ const name = await pickCommand(deps, cmdNames);
108
203
  if (name === undefined)
109
204
  continue;
110
- const rest = { ...draft.commands };
111
- delete rest[name];
112
- draft = { ...draft, commands: rest };
205
+ if (active === "default") {
206
+ const rest = { ...draft.commands };
207
+ delete rest[name];
208
+ draft = { ...draft, commands: rest };
209
+ }
210
+ else {
211
+ const profile = draft.profiles?.[active] ?? {};
212
+ const rest = { ...(profile.commands ?? {}) };
213
+ delete rest[name];
214
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [active]: { ...profile, commands: rest } } };
215
+ }
113
216
  continue;
114
217
  }
218
+ // Bulk apply on active profile's commands
115
219
  if (choice === prompts.bulkApply) {
116
- // One field pass (model via the chain builder + thinking), applied to every
117
- // selected command. A reference missing from the live registry warns per
118
- // command but never blocks the write (OB-4 — dispatch's probe stays the
119
- // authoritative availability gate).
120
- const selected = await pickCommandsMulti(deps, commandChoices(deps, draft));
220
+ // Use the full command registry so operators can apply to new commands (AC4/OB-3).
221
+ const cmdNames = [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
222
+ const selected = await pickCommandsMulti(deps, cmdNames);
121
223
  if (selected === undefined || selected.length === 0)
122
224
  continue;
123
- const route = await editRoute(deps, selected[0], currentFor(selected[0]));
225
+ const routeFile = active === "default"
226
+ ? (draft.commands?.[selected[0]] ?? {})
227
+ : (draft.profiles?.[active]?.commands?.[selected[0]] ?? {});
228
+ const currentRoute = {
229
+ model: routeFile.model ?? "inherit",
230
+ thinking: routeFile.thinking ?? "inherit",
231
+ };
232
+ const route = await editRoute(deps, selected[0], currentRoute);
124
233
  if (route === undefined)
125
234
  continue;
126
- const nextCommands = { ...draft.commands };
127
- for (const name of selected) {
128
- nextCommands[name] = route;
129
- warnMissingModel(deps, name, route.model ?? INHERIT);
235
+ if (active === "default") {
236
+ const nextCommands = { ...draft.commands };
237
+ for (const name of selected) {
238
+ nextCommands[name] = route;
239
+ warnMissingModel(deps, name, route.model ?? INHERIT);
240
+ }
241
+ draft = { ...draft, commands: nextCommands };
242
+ }
243
+ else {
244
+ const profile = draft.profiles?.[active] ?? {};
245
+ const nextCommands = { ...profile.commands };
246
+ for (const name of selected) {
247
+ nextCommands[name] = route;
248
+ warnMissingModel(deps, name, route.model ?? INHERIT);
249
+ }
250
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [active]: { ...profile, commands: nextCommands } } };
130
251
  }
131
- draft = { ...draft, commands: nextCommands };
132
252
  continue;
133
253
  }
254
+ // Bulk clear on active profile's commands
134
255
  if (choice === prompts.bulkClear) {
135
- const names = await pickCommandsMulti(deps, Object.keys(draft.commands ?? {}));
256
+ const cmdNames = Object.keys(profileOf(draft, active).commands ?? {});
257
+ const names = await pickCommandsMulti(deps, cmdNames);
136
258
  if (names === undefined || names.length === 0)
137
259
  continue;
138
- const rest = { ...draft.commands };
139
- for (const name of names)
140
- delete rest[name];
141
- draft = { ...draft, commands: rest };
260
+ if (active === "default") {
261
+ const rest = { ...draft.commands };
262
+ for (const name of names)
263
+ delete rest[name];
264
+ draft = { ...draft, commands: rest };
265
+ }
266
+ else {
267
+ const profile = draft.profiles?.[active] ?? {};
268
+ const rest = { ...(profile.commands ?? {}) };
269
+ for (const name of names)
270
+ delete rest[name];
271
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [active]: { ...profile, commands: rest } } };
272
+ }
142
273
  continue;
143
274
  }
144
275
  if (choice === prompts.policy) {
@@ -154,7 +285,7 @@ export async function runSettingsConsole(deps) {
154
285
  continue;
155
286
  }
156
287
  if (choice === prompts.save) {
157
- const saved = await saveScope(deps, scope, path, draft);
288
+ const saved = await saveScope(deps, scope, path, draft, merged);
158
289
  if (saved)
159
290
  return { status: "saved", scope, path, file: clean(draft) };
160
291
  continue;
@@ -167,6 +298,38 @@ export async function runSettingsConsole(deps) {
167
298
  * without being read, and a scope whose file does not parse is refused so the
168
299
  * operator's own file survives (AC10, AC13).
169
300
  */
301
+ // ---------------------------------------------------------------------------
302
+ // Profile helpers (P5, feature 63)
303
+ // ---------------------------------------------------------------------------
304
+ /** The profile file for a named profile or the default. */
305
+ function profileOf(draft, name) {
306
+ if (name === "default")
307
+ return { ...(draft.default ? { default: draft.default } : {}), ...(draft.commands ? { commands: draft.commands } : {}) };
308
+ return draft.profiles?.[name] ?? {};
309
+ }
310
+ /**
311
+ * Pick a profile from the list. `allowNew` adds "Name the new profile" to the
312
+ * options; the input must match a lowercase slug pattern.
313
+ */
314
+ async function pickProfile(deps, draft, { allowNew }) {
315
+ const names = ["default", ...Object.keys(draft.profiles ?? {}).filter((n) => n !== "default")];
316
+ // "nan" is always listed (built-in unless user-defined)
317
+ if (!names.includes("nan"))
318
+ names.push("nan");
319
+ const options = allowNew ? [...names, prompts.newProfile] : names;
320
+ const picked = await pagedSelect((title, list) => deps.ui.select(title, list), prompts.profile, options);
321
+ if (picked === undefined)
322
+ return undefined;
323
+ if (picked === prompts.newProfile) {
324
+ const name = await deps.ui.input(prompts.newProfile, "lowercase slug");
325
+ if (name === undefined || !/^[a-z0-9][a-z0-9._-]*$/u.test(name.trim())) {
326
+ deps.ui.notify("Rejected: a profile name must be a lowercase slug.", "error");
327
+ return undefined;
328
+ }
329
+ return name.trim();
330
+ }
331
+ return picked;
332
+ }
170
333
  async function openAScope(deps, globalPath, projectPath) {
171
334
  for (;;) {
172
335
  const label = await deps.ui.select(prompts.scope, [GLOBAL_LABEL, PROJECT_LABEL]);
@@ -439,11 +602,14 @@ function warnMissingModel(deps, target, model) {
439
602
  }
440
603
  }
441
604
  }
442
- function commandChoices(deps, draft) {
443
- return [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
444
- }
445
- async function saveScope(deps, scope, path, draft) {
605
+ async function saveScope(deps, scope, path, draft, merged) {
446
606
  const file = clean(draft);
607
+ // P5: materialize recommendedModels when the nan provider is available but
608
+ // the key is absent — the save writes the effective value (AC11).
609
+ const nanAvailable = (deps.models ?? []).some((m) => m.startsWith("nan/"));
610
+ if (file.recommendedModels === undefined && nanAvailable) {
611
+ file.recommendedModels = merged.config.recommendedModels;
612
+ }
447
613
  const text = `${JSON.stringify(file, null, 2)}\n`;
448
614
  const problems = problemsFor(file, scope);
449
615
  if (problems.length > 0) {
@@ -477,6 +643,8 @@ function describeRouting(file) {
477
643
  const routes = [
478
644
  file.default ? `default: ${file.default.model ?? "inherit"} / ${file.default.thinking ?? "inherit"}` : "default: inherit / inherit",
479
645
  ...Object.entries(file.commands ?? {}).map(([name, route]) => `${name}: ${route.model ?? "inherit"} / ${route.thinking ?? "inherit"}`),
646
+ ...(file.profileOrder && file.profileOrder.length > 0 ? [`profiles: ${file.profileOrder.join(" → ")}`] : []),
647
+ ...(file.recommendedModels !== undefined ? [`recommended models: ${file.recommendedModels}`] : []),
480
648
  `unavailable: ${file.onUnavailableRoute ?? "stop"}`,
481
649
  `settle: ${file.onSettle ?? "keep"}`,
482
650
  ];
@@ -504,6 +672,17 @@ function clean(draft) {
504
672
  file.onUnavailableRoute = draft.onUnavailableRoute;
505
673
  if (draft.onSettle)
506
674
  file.onSettle = draft.onSettle;
675
+ if (draft.recommendedModels !== undefined)
676
+ file.recommendedModels = draft.recommendedModels;
677
+ if (draft.profiles && Object.keys(draft.profiles).length > 0) {
678
+ file.profiles = structuredClone(draft.profiles);
679
+ }
680
+ if (draft.profileOrder && draft.profileOrder.length > 0) {
681
+ file.profileOrder = [...draft.profileOrder];
682
+ }
683
+ if (draft.profileFallback && Object.keys(draft.profileFallback).length > 0) {
684
+ file.profileFallback = { ...draft.profileFallback };
685
+ }
507
686
  return file;
508
687
  }
509
688
  function parseFile(text, scope) {
@@ -8,4 +8,6 @@ export declare const DEFAULT_ROUTE = "the default route";
8
8
  */
9
9
  export declare function routePath(target: string): string;
10
10
  /** What the operator reads when the console opens. */
11
- export declare function renderMergedConfig(loaded: LoadedConfig, commands: readonly string[]): string[];
11
+ export declare function renderMergedConfig(loaded: LoadedConfig, commands: readonly string[], opts?: {
12
+ providerAvailable?: (provider: string) => boolean;
13
+ }): string[];
@@ -1,6 +1,7 @@
1
1
  // src/settings/view.ts
2
2
  // The settings console's display half (AC10, SPEC S4): what is in effect right
3
3
  // now, and which files the values came from.
4
+ import { effectiveProfileOrder } from "../config/profiles.js";
4
5
  /** The label for the unqualified route, so `default` is never mistaken for a command name. */
5
6
  export const DEFAULT_ROUTE = "the default route";
6
7
  /**
@@ -12,9 +13,15 @@ export function routePath(target) {
12
13
  return target === DEFAULT_ROUTE ? "$.default" : `$.commands.${target}`;
13
14
  }
14
15
  /** What the operator reads when the console opens. */
15
- export function renderMergedConfig(loaded, commands) {
16
+ export function renderMergedConfig(loaded, commands, opts) {
17
+ const order = effectiveProfileOrder(loaded.config, {
18
+ providerAvailable: opts?.providerAvailable ?? (() => false),
19
+ });
16
20
  return [
17
21
  "agentic-workflow routing — what each command runs on right now",
22
+ ` profiles: ${order.join(" → ")}`,
23
+ ` active profile: ${order[0] ?? "default"}`,
24
+ ` recommended models: ${loaded.config.recommendedModels ? "on" : "off"}${loaded.config.recommendedModels && opts?.providerAvailable?.("nan") ? " (built-in nan profile in the chain)" : ""}`,
18
25
  ...configLines(loaded.config, commands),
19
26
  ...problems(loaded.problems),
20
27
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-agentic-workflow",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "Pi package: canonical agentic-workflow skills, friendly slash commands, and per-command model routing.",
5
5
  "keywords": [
6
6
  "pi-package",