@gtrabanco/pi-agentic-workflow 0.8.0 → 0.9.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.es.md CHANGED
@@ -77,7 +77,8 @@ Dos archivos JSON, ambos opcionales:
77
77
  "plan-feature": { "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"], "thinking": "medium" },
78
78
  "review-change": { "thinking": "max" }
79
79
  },
80
- "onUnavailableRoute": "stop"
80
+ "onUnavailableRoute": "stop",
81
+ "onSettle": "keep"
81
82
  }
82
83
  ```
83
84
 
@@ -97,6 +98,8 @@ que ya tuviera la sesión, porque la ruta por defecto del paquete es
97
98
  prueba `anthropic/claude-sonnet-4-5` primero y luego `openai/gpt-5.2`.
98
99
  - `thinking` es uno de `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
99
100
  o `"inherit"`.
101
+ - `` `onSettle` `` es uno de `"keep"` (el default) o `"restore"` — ver
102
+ [Tu sesión después de un comando](#tu-sesión-después-de-un-comando).
100
103
  - Claves desconocidas, `null` y referencias mal formadas se **rechazan**, no se
101
104
  ignoran: un error tipográfico que no hace nada en silencio es el bug que nunca
102
105
  encuentras.
@@ -116,14 +119,28 @@ aun así:
116
119
  { "onUnavailableRoute": "inherit" }
117
120
  ```
118
121
 
119
- ## Tu sesión vuelve
122
+ ## Tu sesión después de un comando
120
123
 
121
- El enrutamiento dura un comando. Cuando el turno termina, la sesión vuelve a
122
- como estaba — el modelo *y* el nivel de thinking, porque seleccionar un modelo
123
- puede mover el nivel. Si cambias el modelo tú mismo a mitad de turno, con
124
- `/model` por ejemplo, no se restaura nada: tu elección gana, y el comando lo
125
- dice. Si mueves solo el nivel de thinking, lo conservas mientras el modelo
126
- vuelve.
124
+ Por defecto (**`onSettle: "keep"`**), el modelo enrutado y el nivel de thinking
125
+ **se quedan** en la ventana de chat abierta cuando el comando termina. Si
126
+ `plan-feature` corre en `glm-5` y quieres retocar el plan que produjo, tu siguiente
127
+ prompt sigue corriendo en `glm-5` — igual con una pregunta posterior. Cuando no
128
+ necesitas el modelo pesado, lo cambias tú con `/model` (o Ctrl+P) a algo más
129
+ barato; nada restaura sobre tu elección.
130
+
131
+ Para volver a traer el modelo y el nivel de thinking previos al comando cuando este
132
+ termina, pon:
133
+
134
+ ```json
135
+ { "onSettle": "restore" }
136
+ ```
137
+
138
+ (El modo `restore` es el histórico contrato AC8: tras un comando enrutado la
139
+ sesión vuelve a como estaba — el modelo *y* el nivel de thinking, porque
140
+ seleccionar un modelo puede mover el nivel. Si cambias el modelo tú mismo a
141
+ mitad de turno, con `/model` por ejemplo, no se restaura nada: tu elección gana,
142
+ y el comando lo dice. Si mueves solo el nivel de thinking, lo conservas mientras
143
+ el modelo vuelve.)
127
144
 
128
145
  ## Consola de configuración
129
146
 
@@ -159,7 +176,7 @@ consola nunca se queda sin salida.
159
176
  | `could not be selected` | Pi rechazó el cambio. El comando se detiene con el motivo — salvo que `onUnavailableRoute` sea `inherit`, en cuyo caso avisa y se ejecuta con tu modelo actual. |
160
177
  | `refused: the agent is busy` | Hay un turno en ejecución. Espera a que termine. |
161
178
  | `is still routed` | El comando enrutado anterior no ha terminado. |
162
- | `leaving the model you chose in place` | Cambiaste el modelo durante el turno enrutado, así que no se restauró nada: tu elección ganó. |
179
+ | `leaving the model you chose in place` | (en modo `restore`) Cambiaste el modelo durante un turno enrutado, así que no se restauró nada: tu elección ganó. |
163
180
  | `these configured routes match no command` | Una clave de `commands` no nombra nada. Corrige la escritura o elimina la entrada. |
164
181
 
165
182
  ## Notas
package/README.md CHANGED
@@ -75,7 +75,8 @@ Two JSON files, both optional:
75
75
  "plan-feature": { "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"], "thinking": "medium" },
76
76
  "review-change": { "thinking": "max" }
77
77
  },
78
- "onUnavailableRoute": "stop"
78
+ "onUnavailableRoute": "stop",
79
+ "onSettle": "keep"
79
80
  }
80
81
  ```
81
82
 
@@ -95,6 +96,8 @@ whatever the session already had, because the shipped default route is
95
96
  then `openai/gpt-5.2`.
96
97
  - `thinking` is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
97
98
  or `"inherit"`.
99
+ - `` `onSettle` `` is one of `"keep"` (the default) or `"restore"` — see
100
+ [Your session after a command](#your-session-after-a-command).
98
101
  - Unknown keys, `null`s and malformed references are **rejected**, not ignored: a
99
102
  typo that silently did nothing is the bug you would never find.
100
103
 
@@ -112,13 +115,28 @@ nothing runs on a model you did not pick. To run anyway on the current model, se
112
115
  { "onUnavailableRoute": "inherit" }
113
116
  ```
114
117
 
115
- ## Your session comes back
118
+ ## Your session after a command
116
119
 
117
- Routing lasts one command. When the turn settles, the session is put back the way
118
- you had it — the model *and* the thinking level, because selecting a model can
119
- move the level. If you change the model yourself mid-turn, with `/model`, say,
120
- nothing is restored: your choice wins, and the command says so. Change only the
121
- thinking level and you keep it while the model still comes back.
120
+ By default (**`onSettle: "keep"`**), the routed model and thinking level **stay**
121
+ in the open chat window once the command settles. If `plan-feature` runs on
122
+ `glm-5` and you want to tweak the plan it produced, your next prompt keeps
123
+ running on `glm-5` — same for a follow-up question. When you don't need the
124
+ heavy model, switch it yourself with `/model` (or Ctrl+P) to something cheaper;
125
+ nothing restores over your choice.
126
+
127
+ To bring back the pre-command model and thinking level after a command settles,
128
+ set:
129
+
130
+ ```json
131
+ { "onSettle": "restore" }
132
+ ```
133
+
134
+ (The `restore` mode is the historical AC8 contract: after a routed command the
135
+ session is put back the way you had it — the model *and* the thinking level,
136
+ because selecting a model can move the level. If you change the model yourself
137
+ mid-turn, with `/model` say, nothing is restored: your choice wins, and the
138
+ command says so. Change only the thinking level and you keep it while the model
139
+ still comes back.)
122
140
 
123
141
  ## Settings console
124
142
 
@@ -152,7 +170,7 @@ fall back to a plain prompt, so the console never dead-ends.
152
170
  | `could not be selected` | Pi refused the switch. The command stops with the reason — unless `onUnavailableRoute` is `inherit`, in which case it warns and runs on your current model. |
153
171
  | `refused: the agent is busy` | A turn is running. Wait for it to settle. |
154
172
  | `is still routed` | The previous routed command has not settled yet. |
155
- | `leaving the model you chose in place` | You changed the model during a routed turn, so nothing was restored — your choice won. |
173
+ | `leaving the model you chose in place` | (in `restore` mode) You changed the model during a routed turn, so nothing was restored — your choice won. |
156
174
  | `these configured routes match no command` | A `commands` key names nothing. Fix the spelling or delete the entry. |
157
175
 
158
176
  ## Notes
@@ -8,4 +8,5 @@ export const DEFAULT_CONFIG = {
8
8
  default: { ...DEFAULT_ROUTE },
9
9
  commands: {},
10
10
  onUnavailableRoute: "stop",
11
+ onSettle: "keep",
11
12
  };
@@ -35,6 +35,7 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
35
35
  default: effectiveDefault,
36
36
  commands,
37
37
  onUnavailableRoute: projectFile.onUnavailableRoute ?? globalFile.onUnavailableRoute ?? DEFAULT_CONFIG.onUnavailableRoute,
38
+ onSettle: projectFile.onSettle ?? globalFile.onSettle ?? DEFAULT_CONFIG.onSettle,
38
39
  };
39
40
  }
40
41
  /** The route a command runs under: its own resolved override, else the default route. */
@@ -1,4 +1,4 @@
1
- import { MAX_MODEL_CHAIN, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
1
+ import { MAX_MODEL_CHAIN, SETTLE_POLICIES, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
2
2
  /**
3
3
  * Strict validator for one config file (SPEC S5-S8, D-E5).
4
4
  *
@@ -8,7 +8,7 @@ import { MAX_MODEL_CHAIN, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./
8
8
  * strong model ran when it did not. Only a *missing* file resolves to the
9
9
  * default, and that decision belongs to the loader.
10
10
  */
11
- const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute"]);
11
+ const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute", "onSettle"]);
12
12
  const ROUTE_KEYS = new Set(["model", "thinking"]);
13
13
  const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
14
14
  function isRecord(value) {
@@ -37,6 +37,9 @@ function isThinkingSetting(value) {
37
37
  function isUnavailableRoutePolicy(value) {
38
38
  return UNAVAILABLE_ROUTE_POLICIES.includes(value);
39
39
  }
40
+ function isSettlePolicy(value) {
41
+ return SETTLE_POLICIES.includes(value);
42
+ }
40
43
  function describe(value) {
41
44
  return typeof value === "string" ? `"${value}"` : JSON.stringify(value) ?? String(value);
42
45
  }
@@ -115,7 +118,7 @@ function validateConfig(value) {
115
118
  if (!ROOT_KEYS.has(key)) {
116
119
  issues.push({
117
120
  path: `$.${displayKey(key)}`,
118
- message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute)`,
121
+ message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle)`,
119
122
  });
120
123
  }
121
124
  }
@@ -153,6 +156,17 @@ function validateConfig(value) {
153
156
  config.onUnavailableRoute = value.onUnavailableRoute;
154
157
  }
155
158
  }
159
+ if (value.onSettle !== undefined) {
160
+ if (!isSettlePolicy(value.onSettle)) {
161
+ issues.push({
162
+ path: "$.onSettle",
163
+ message: `must be "keep" or "restore", got ${describe(value.onSettle)}`,
164
+ });
165
+ }
166
+ else {
167
+ config.onSettle = value.onSettle;
168
+ }
169
+ }
156
170
  return issues.length > 0 ? { ok: false, issues } : { ok: true, config };
157
171
  }
158
172
  /**
@@ -37,18 +37,29 @@ export interface ConfigFile {
37
37
  default?: RouteFile;
38
38
  commands?: Record<string, RouteFile>;
39
39
  onUnavailableRoute?: UnavailableRoutePolicy;
40
+ /** What happens to the routed model and thinking level after the command settles. */
41
+ onSettle?: SettlePolicy;
40
42
  }
41
43
  export type UnavailableRoutePolicy = "stop" | "inherit";
44
+ /**
45
+ * What the session holds after a routed command settles.
46
+ * - `"keep"` — the routed model and thinking level stay in the open chat window,
47
+ * so a follow-up edit or question keeps running on the model that planned it.
48
+ * - `"restore"` — the pre-dispatch model and thinking level come back (AC8).
49
+ */
50
+ export type SettlePolicy = "keep" | "restore";
42
51
  /** One rejected field inside one config file, addressed by a JSON-path-ish string. */
43
52
  export interface ConfigIssue {
44
53
  path: string;
45
54
  message: string;
46
55
  }
47
56
  export declare const UNAVAILABLE_ROUTE_POLICIES: readonly UnavailableRoutePolicy[];
57
+ export declare const SETTLE_POLICIES: readonly SettlePolicy[];
48
58
  export interface EffectiveConfig {
49
59
  default: Route;
50
60
  commands: Record<string, Route>;
51
61
  onUnavailableRoute: UnavailableRoutePolicy;
62
+ onSettle: SettlePolicy;
52
63
  }
53
64
  export interface ConfigProblem {
54
65
  scope: "global" | "project";
@@ -12,3 +12,4 @@ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhig
12
12
  /** Cap on chain length (issue deliberation: 8 was "almost certainly a mistake"; 4 keeps the view readable). */
13
13
  export const MAX_MODEL_CHAIN = 4;
14
14
  export const UNAVAILABLE_ROUTE_POLICIES = ["stop", "inherit"];
15
+ export const SETTLE_POLICIES = ["keep", "restore"];
@@ -79,6 +79,12 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
79
79
  // Cleared first: the restore fires its own select events, and a turn that is
80
80
  // already over must not read them as operator changes.
81
81
  pending = undefined;
82
+ // `keep`: leave the routed model and thinking level in the open chat window,
83
+ // so a follow-up edit or question keeps running on the model that planned it.
84
+ // The operator's own mid-turn choice is still untouched (nothing is restored,
85
+ // so it cannot be overwritten). Only "restore" puts the snapshot back (AC8).
86
+ if (turn.settlePolicy === "keep")
87
+ return;
82
88
  await restore(turn, surface(ctx), ctx, "finished");
83
89
  },
84
90
  async dispatch(command, args, ctx) {
@@ -198,7 +204,14 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
198
204
  ctx.notify(`/${command.name} is running. Per-command models are optional: configure them with /${settingsCommand} or the pi-agentic-workflow.json files. This hint appears once.`, "info");
199
205
  }
200
206
  if (applied.model || applied.thinking) {
201
- pending = { command: command.name, snapshot, applied, userChangedModel: false, userChangedThinking: false };
207
+ pending = {
208
+ command: command.name,
209
+ snapshot,
210
+ applied,
211
+ settlePolicy: loaded.config.onSettle,
212
+ userChangedModel: false,
213
+ userChangedThinking: false,
214
+ };
202
215
  }
203
216
  // Pi expands `/skill:<x>` by the skill's frontmatter `name:`, and passes an
204
217
  // unknown key through as literal text — so the name is the only correct
@@ -217,7 +230,16 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
217
230
  pending = undefined;
218
231
  const rolledBack = Boolean(applied.model || applied.thinking);
219
232
  if (rolledBack) {
220
- await restore({ command: command.name, snapshot, applied, userChangedModel: false, userChangedThinking: false }, session, ctx, `dispatch failed (${error.message}); the session was put back`);
233
+ await restore({
234
+ command: command.name,
235
+ snapshot,
236
+ applied,
237
+ // The turn never started, so the rollback always restores — it is not a
238
+ // settle, and `onSettle` does not apply. Explicit, not read from config.
239
+ settlePolicy: "restore",
240
+ userChangedModel: false,
241
+ userChangedThinking: false,
242
+ }, session, ctx, `dispatch failed (${error.message}); the session was put back`);
221
243
  }
222
244
  return refuse(ctx, "dispatch-failed", `/${command.name} was not dispatched: ${error.message}. Nothing was sent${rolledBack ? ", and the session model was put back" : ""}.`);
223
245
  }
@@ -40,6 +40,7 @@ export declare const prompts: {
40
40
  readonly undone: "The routing was undone and the session put back.";
41
41
  readonly command: "Which command?";
42
42
  readonly policyChoice: "What should happen when a configured model is unavailable?";
43
+ readonly settleChoice: "What should happen after a routed command settles?";
43
44
  readonly saveTo: (path: string) => string;
44
45
  readonly discard: "Discard the draft?";
45
46
  readonly setDefaultRoute: "Set the default route";
@@ -49,6 +50,7 @@ export declare const prompts: {
49
50
  readonly bulkClear: "Clear several overrides";
50
51
  readonly addAnother: "Add another?";
51
52
  readonly policy: "Set the unavailable-route policy";
53
+ readonly settle: "Set the post-command model keep/restore policy";
52
54
  readonly save: "Save";
53
55
  readonly cancel: "Cancel";
54
56
  readonly model: (target: string) => string;
@@ -10,7 +10,7 @@
10
10
  import { loadConfig, configFilePaths } from "../config/load.js";
11
11
  import { effectiveRoute } from "../config/merge.js";
12
12
  import { parseConfigFile, parseModelReference } from "../config/schema.js";
13
- import { MAX_MODEL_CHAIN, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "../config/types.js";
13
+ import { MAX_MODEL_CHAIN, SETTLE_POLICIES, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "../config/types.js";
14
14
  import { renderMergedConfig, routePath, DEFAULT_ROUTE } from "./view.js";
15
15
  /** The console's questions. `test/settings-console.test.mjs` drives the flow
16
16
  * through these strings, so renaming one fails the tests that use it rather than
@@ -23,6 +23,7 @@ export const prompts = {
23
23
  undone: "The routing was undone and the session put back.",
24
24
  command: "Which command?",
25
25
  policyChoice: "What should happen when a configured model is unavailable?",
26
+ settleChoice: "What should happen after a routed command settles?",
26
27
  saveTo: (path) => `Save the draft to ${path}?`,
27
28
  discard: "Discard the draft?",
28
29
  setDefaultRoute: "Set the default route",
@@ -32,6 +33,7 @@ export const prompts = {
32
33
  bulkClear: "Clear several overrides",
33
34
  addAnother: "Add another?",
34
35
  policy: "Set the unavailable-route policy",
36
+ settle: "Set the post-command model keep/restore policy",
35
37
  save: "Save",
36
38
  cancel: "Cancel",
37
39
  model: (target) => `Model for ${target}?`,
@@ -71,6 +73,7 @@ export async function runSettingsConsole(deps) {
71
73
  prompts.bulkApply,
72
74
  prompts.bulkClear,
73
75
  prompts.policy,
76
+ prompts.settle,
74
77
  ...(deps.routing?.inFlight() ? [prompts.undoInFlight] : []),
75
78
  prompts.save,
76
79
  prompts.cancel,
@@ -142,6 +145,12 @@ export async function runSettingsConsole(deps) {
142
145
  draft = { ...draft, onUnavailableRoute: picked };
143
146
  continue;
144
147
  }
148
+ if (choice === prompts.settle) {
149
+ const picked = await deps.ui.select(prompts.settleChoice, [...SETTLE_POLICIES]);
150
+ if (isSettle(picked))
151
+ draft = { ...draft, onSettle: picked };
152
+ continue;
153
+ }
145
154
  if (choice === prompts.save) {
146
155
  const saved = await saveScope(deps, scope, path, draft);
147
156
  if (saved)
@@ -405,6 +414,7 @@ function describeRouting(file) {
405
414
  file.default ? `default: ${file.default.model ?? "inherit"} / ${file.default.thinking ?? "inherit"}` : "default: inherit / inherit",
406
415
  ...Object.entries(file.commands ?? {}).map(([name, route]) => `${name}: ${route.model ?? "inherit"} / ${route.thinking ?? "inherit"}`),
407
416
  `unavailable: ${file.onUnavailableRoute ?? "stop"}`,
417
+ `settle: ${file.onSettle ?? "keep"}`,
408
418
  ];
409
419
  return routes.join(" · ");
410
420
  }
@@ -428,6 +438,8 @@ function clean(draft) {
428
438
  file.commands = commands;
429
439
  if (draft.onUnavailableRoute)
430
440
  file.onUnavailableRoute = draft.onUnavailableRoute;
441
+ if (draft.onSettle)
442
+ file.onSettle = draft.onSettle;
431
443
  return file;
432
444
  }
433
445
  function parseFile(text, scope) {
@@ -448,3 +460,6 @@ function isThinkingLevel(value) {
448
460
  function isPolicy(value) {
449
461
  return value === "stop" || value === "inherit";
450
462
  }
463
+ function isSettle(value) {
464
+ return value === "keep" || value === "restore";
465
+ }
@@ -31,6 +31,7 @@ function configLines(config, commands) {
31
31
  ` ${overrides.length} of ${total} commands override the default route`,
32
32
  ]),
33
33
  ` when a configured model is unavailable: ${config.onUnavailableRoute}`,
34
+ ` after a routed command settles: ${config.onSettle}`,
34
35
  ];
35
36
  }
36
37
  function route(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-agentic-workflow",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",