@gtrabanco/pi-agentic-workflow 0.15.2 → 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.
Files changed (63) hide show
  1. package/README.md +49 -0
  2. package/dist/conductor/loop.js +23 -1
  3. package/dist/conductor/types.d.ts +6 -0
  4. package/dist/config/defaults.js +11 -0
  5. package/dist/config/merge.js +93 -1
  6. package/dist/config/profiles.d.ts +36 -0
  7. package/dist/config/profiles.js +102 -0
  8. package/dist/config/recommended.d.ts +14 -0
  9. package/dist/config/recommended.js +28 -0
  10. package/dist/config/schema.js +159 -3
  11. package/dist/config/types.d.ts +52 -0
  12. package/dist/config/types.js +10 -0
  13. package/dist/extension/conductor-command.d.ts +2 -5
  14. package/dist/extension/conductor-command.js +18 -6
  15. package/dist/extension/factory.d.ts +2 -0
  16. package/dist/extension/factory.js +10 -3
  17. package/dist/routing/dispatch.d.ts +9 -2
  18. package/dist/routing/dispatch.js +115 -46
  19. package/dist/routing/state.d.ts +26 -0
  20. package/dist/routing/state.js +67 -1
  21. package/dist/routing/types.d.ts +7 -0
  22. package/dist/settings/console.d.ts +7 -0
  23. package/dist/settings/console.js +214 -35
  24. package/dist/settings/view.d.ts +3 -1
  25. package/dist/settings/view.js +8 -1
  26. package/package.json +1 -1
  27. package/skills/audit-docs/SKILL.md +3 -3
  28. package/skills/audit-pr/SKILL.md +8 -8
  29. package/skills/audit-pr/references/01_MERGE_GATES.md +3 -3
  30. package/skills/audit-pr/references/02_CLOSURE_AND_SCOPE_GATES.md +4 -5
  31. package/skills/audit-pr/references/03_AUDIT_PROCESS.md +1 -1
  32. package/skills/audit-pr/references/04_VERDICT.md +3 -3
  33. package/skills/audit-pr/references/05_ROUTING_AND_GUARDRAILS.md +2 -2
  34. package/skills/discover-repository-state/SKILL.md +3 -4
  35. package/skills/execute-phase/SKILL.md +1 -1
  36. package/skills/execute-phase/references/DESCOPE.md +1 -1
  37. package/skills/fold-findings/SKILL.md +8 -8
  38. package/skills/fold-findings/references/FOLD_PROCESS.md +6 -5
  39. package/skills/orchestration-envelope/SKILL.md +4 -3
  40. package/skills/phase-contract/SKILL.md +3 -4
  41. package/skills/pre-execution-review/SKILL.md +10 -7
  42. package/skills/pre-execution-review/references/POLICY.md +17 -8
  43. package/skills/product-audit/SKILL.md +12 -13
  44. package/skills/product-audit/references/AUDIT_PROCESS.md +1 -1
  45. package/skills/resolve-repository-state/SKILL.md +2 -3
  46. package/skills/review-change/SKILL.md +3 -3
  47. package/skills/review-change/references/ADVERSARIAL_RECOMMENDATION.md +1 -1
  48. package/skills/review-change/references/ADVERSARIAL_SETUP.md +1 -1
  49. package/skills/review-change/references/OUTPUT_AND_GUARDRAILS.md +5 -5
  50. package/skills/review-change/references/PERSIST_AND_DECIDE.md +7 -6
  51. package/skills/review-implementation/SKILL.md +1 -1
  52. package/skills/review-implementation/references/CLASSIFY.md +13 -11
  53. package/skills/triage-issue/SKILL.md +11 -10
  54. package/skills/triage-issue/references/ISSUE_PROCESS.md +11 -10
  55. package/skills/triage-issue/references/LABELS.md +3 -3
  56. package/skills/triage-issue/references/REVIEW_FINDING_PROCESS.md +6 -6
  57. package/skills/unit-lane/SKILL.md +24 -9
  58. package/skills/workflow-status/SKILL.md +12 -13
  59. package/skills/workflow-status/references/CRASH_RECOVERY.md +6 -6
  60. package/skills/workflow-status/references/ENVELOPE_CORE.md +1 -4
  61. package/skills/workflow-status/references/ENVELOPE_FIELDS.md +7 -7
  62. package/skills/workflow-status/references/PRE_EXECUTION.md +5 -0
  63. package/skills/workflow-status/references/SENSOR_SIGNALS.md +1 -1
@@ -25,10 +25,38 @@ export type ModelChain = readonly ModelRef[];
25
25
  export type ModelSetting = "inherit" | ModelRef | ModelChain;
26
26
  /** Cap on chain length (issue deliberation: 8 was "almost certainly a mistake"; 4 keeps the view readable). */
27
27
  export declare const MAX_MODEL_CHAIN = 4;
28
+ export declare const PROFILE_FALLBACK_APPLY_TO: readonly ["flow", "command"];
29
+ export type ProfileFallbackApplyTo = (typeof PROFILE_FALLBACK_APPLY_TO)[number];
30
+ export declare const PROFILE_FALLBACK_RESUME: readonly ["continue", "restart"];
31
+ export type ProfileFallbackResume = (typeof PROFILE_FALLBACK_RESUME)[number];
32
+ export interface ProfileFile {
33
+ default?: RouteFile;
34
+ commands?: Record<string, RouteFile>;
35
+ }
36
+ export interface ProfileFallbackConfig {
37
+ applyTo: ProfileFallbackApplyTo;
38
+ resume: ProfileFallbackResume;
39
+ retryAfterSeconds: number;
40
+ }
41
+ export declare const DEFAULT_PROFILE_FALLBACK: Readonly<ProfileFallbackConfig>;
42
+ export interface ProfileCandidate {
43
+ profile: string;
44
+ builtIn: boolean;
45
+ route: Route;
46
+ declared: {
47
+ model: boolean;
48
+ thinking: boolean;
49
+ };
50
+ }
28
51
  export interface Route {
29
52
  model: ModelSetting;
30
53
  thinking: ThinkingSetting;
31
54
  }
55
+ /** Feature 63: which route keys a scope actually declared (vs. the shipped inherit). */
56
+ export interface RouteDeclaration {
57
+ model: boolean;
58
+ thinking: boolean;
59
+ }
32
60
  /** What one config file may declare (SPEC "Config schema"). */
33
61
  export interface RouteFile {
34
62
  model?: ModelSetting;
@@ -44,6 +72,16 @@ export interface ConfigFile {
44
72
  pathProtection?: PathProtectionOverride;
45
73
  /** Conductor (advance) knobs (feature 62). */
46
74
  advance?: AdvanceConfigFile;
75
+ /** When true (default), the built-in provider profiles are included in the
76
+ * resolution chain (feature 63, AC1). */
77
+ recommendedModels?: boolean;
78
+ /** Named user profiles keyed by profile name (feature 63, AC5). */
79
+ profiles?: Record<string, ProfileFile>;
80
+ /** Ordered list of profile names to probe; empty means ["default"] only
81
+ * (feature 63, AC6). */
82
+ profileOrder?: string[];
83
+ /** Partial override for the profile-fallback demotion policy (feature 63). */
84
+ profileFallback?: Partial<ProfileFallbackConfig>;
47
85
  }
48
86
  export type UnavailableRoutePolicy = "stop" | "inherit";
49
87
  /**
@@ -71,6 +109,20 @@ export interface EffectiveConfig {
71
109
  * mergeConfigs; the shipped DEFAULT_CONFIG predates it and leaves it
72
110
  * absent — consumers fall back to DEFAULT_ADVANCE_CONFIG. */
73
111
  advance?: AdvanceConfig;
112
+ /** Whether the built-in provider profiles (currently only "nan") are
113
+ * included in the resolution chain (feature 63, AC1). */
114
+ recommendedModels: boolean;
115
+ /** Named user profiles keyed by profile name (feature 63, AC5). */
116
+ profiles: Record<string, ProfileFile>;
117
+ /** Ordered list of profile names to probe (feature 63, AC6). */
118
+ profileOrder: string[];
119
+ /** The effective profile-fallback demotion policy (feature 63). */
120
+ profileFallback: ProfileFallbackConfig;
121
+ /** Feature 63: declaredness of the implicit `default` profile. */
122
+ declared: {
123
+ default: RouteDeclaration;
124
+ commands: Record<string, RouteDeclaration>;
125
+ };
74
126
  }
75
127
  export interface ConfigProblem {
76
128
  scope: "global" | "project";
@@ -11,6 +11,16 @@
11
11
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
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
+ // ---------------------------------------------------------------------------
15
+ // Model-profiles fields (feature 63)
16
+ // ---------------------------------------------------------------------------
17
+ export const PROFILE_FALLBACK_APPLY_TO = ["flow", "command"];
18
+ export const PROFILE_FALLBACK_RESUME = ["continue", "restart"];
19
+ export const DEFAULT_PROFILE_FALLBACK = {
20
+ applyTo: "flow",
21
+ resume: "continue",
22
+ retryAfterSeconds: 86400,
23
+ };
14
24
  export const UNAVAILABLE_ROUTE_POLICIES = ["stop", "inherit"];
15
25
  export const SETTLE_POLICIES = ["keep", "restore"];
16
26
  /** The shipped advance defaults (mirrors conductor DEFAULT_CONDUCTOR_CONFIG). */
@@ -11,13 +11,10 @@
11
11
  import type { LoadedConfig } from "../config/load.js";
12
12
  import type { CommandRegistrar } from "./factory.js";
13
13
  import type { InvocationContext, ModelRef } from "../routing/types.js";
14
+ import type { Router } from "../routing/dispatch.js";
14
15
  /** Register the native `advance` command on the shared registrar. */
15
16
  export declare function registerAdvanceCommand<M extends ModelRef = ModelRef>(registrar: CommandRegistrar<M>, deps: {
16
- surface: (ctx: InvocationContext<M>) => {
17
- sendUserMessage(content: string, options?: {
18
- expandPromptTemplates?: boolean;
19
- }): void;
20
- };
17
+ router: Router<M>;
21
18
  /** The same bound config reader the router uses (factory's `read`). */
22
19
  readConfig: (ctx: InvocationContext<M>) => LoadedConfig;
23
20
  }, commandNames: Set<string>): void;
@@ -56,16 +56,20 @@ export function registerAdvanceCommand(registrar, deps, commandNames) {
56
56
  // The effective advance knobs: the merged config when it carries the
57
57
  // key, the shipped defaults otherwise.
58
58
  let config = { ...DEFAULT_ADVANCE_CONFIG };
59
+ let resume = "continue";
59
60
  try {
60
61
  const loaded = deps.readConfig(ctx);
61
- if (loaded.ok && loaded.config.advance)
62
- config = loaded.config.advance;
62
+ if (loaded.ok) {
63
+ if (loaded.config.advance)
64
+ config = loaded.config.advance;
65
+ resume = loaded.config.profileFallback?.resume ?? "continue";
66
+ }
63
67
  }
64
68
  catch {
65
69
  // Config trouble falls back to the shipped advance defaults; the loop
66
70
  // itself is fail-closed on the sensor side anyway.
71
+ // `resume` stays "continue".
67
72
  }
68
- const surface = deps.surface(ctx);
69
73
  const result = await runConductorLoop({
70
74
  cwd: ctx.cwd,
71
75
  runtimeBin: runtimeBin(),
@@ -77,10 +81,18 @@ export function registerAdvanceCommand(registrar, deps, commandNames) {
77
81
  decide: decideFromEnvelope,
78
82
  invocation: invocationFromDecision,
79
83
  judgeUrgency,
80
- sendUserMessage: (invocation) => {
81
- surface.sendUserMessage(invocation, { expandPromptTemplates: true });
82
- return Promise.resolve({ ok: true });
84
+ sendUserMessage: async (invocation) => {
85
+ const match = /^\/skill:(\S+)(?:\s+([\s\S]*))?$/u.exec(invocation);
86
+ const verb = match?.[1] ?? invocation.replace(/^\/skill:/u, "").split(" ")[0];
87
+ const args = match?.[2] ?? "";
88
+ const dispatchResult = await deps.router.dispatch({ name: verb, skill: verb }, args, ctx, { onProfileSwitch: resume });
89
+ return {
90
+ ok: dispatchResult.status === "dispatched",
91
+ ...(dispatchResult.status === "dispatched" && dispatchResult.profileSwitched ? { profileSwitched: dispatchResult.profileSwitched } : {}),
92
+ ...(dispatchResult.status === "dispatched" && dispatchResult.deferred ? { deferred: true } : {}),
93
+ };
83
94
  },
95
+ profileResume: resume,
84
96
  gitProbe: () => gitProbe(ctx.cwd),
85
97
  appendRunLog: (line) => appendRunLog(config, ctx.cwd, line),
86
98
  parkInFlight: () => {
@@ -40,6 +40,8 @@ export interface ExtensionDeps<M extends ModelRef = ModelRef> {
40
40
  settings: SettingsHandler<M>;
41
41
  /** Overridable so a test can hand the router an in-memory configuration. */
42
42
  loadConfig?: (ctx: InvocationContext<M>) => LoadedConfig;
43
+ /** P4: profile demotion state store (optional — defaults to file-backed). */
44
+ profileState?: import("../routing/state.js").ProfileStateStore;
43
45
  }
44
46
  export interface ExtensionHandle<M extends ModelRef = ModelRef> {
45
47
  router: Router<M>;
@@ -4,6 +4,7 @@ import { createRouter } from "../routing/dispatch.js";
4
4
  import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
5
5
  import { ADVANCE_COMMAND, SETTINGS_COMMAND, SETTINGS_COMMAND_ALIAS } from "../routing/types.js";
6
6
  import { registerAdvanceCommand } from "./conductor-command.js";
7
+ import { createProfileStateStore, stateFilePath } from "../routing/state.js";
7
8
  export function createExtension(deps) {
8
9
  const { registrar, surface, skillsDir, agentDir, hint, settings } = deps;
9
10
  const catalogue = readCatalogue(skillsDir);
@@ -26,7 +27,9 @@ export function createExtension(deps) {
26
27
  SETTINGS_COMMAND,
27
28
  SETTINGS_COMMAND_ALIAS,
28
29
  ]);
29
- const router = createRouter({ surface, loadConfig: read, hint, settingsCommand: SETTINGS_COMMAND, knownCommands });
30
+ // P4: build the profile state store when not provided.
31
+ const profileState = deps.profileState ?? createProfileStateStore({ path: stateFilePath(agentDir) });
32
+ const router = createRouter({ surface, loadConfig: read, hint, settingsCommand: SETTINGS_COMMAND, knownCommands, profileState });
30
33
  for (const command of catalogue.commands) {
31
34
  registrar.registerCommand(command.name, {
32
35
  ...(command.description ? { description: command.description } : {}),
@@ -42,7 +45,11 @@ export function createExtension(deps) {
42
45
  reportCatalogueIssues(ctx);
43
46
  // Bound for the console: two verbs and the session they act on, so it cannot
44
47
  // reach for `settle` or `dispatch` by accident.
45
- const routing = { inFlight: () => router.inFlight(), undoInFlight: () => router.undoInFlight(ctx) };
48
+ const routing = {
49
+ inFlight: () => router.inFlight(),
50
+ undoInFlight: () => router.undoInFlight(ctx),
51
+ clearProfileDemotion: () => profileState.clear(),
52
+ };
46
53
  try {
47
54
  await settings({ catalogue, ctx, routing });
48
55
  }
@@ -53,7 +60,7 @@ export function createExtension(deps) {
53
60
  };
54
61
  // The native conductor command (feature 62): registered in code, not derived
55
62
  // from a skill dir, and able to invoke every catalogue command by name.
56
- registerAdvanceCommand(registrar, { surface, readConfig: read }, knownCommands);
63
+ registerAdvanceCommand(registrar, { router, readConfig: read }, knownCommands);
57
64
  registrar.registerCommand(SETTINGS_COMMAND, {
58
65
  description: "Show and configure per-command model routing",
59
66
  handler: settingsHandler,
@@ -1,6 +1,7 @@
1
1
  import type { LoadedConfig } from "../config/load.js";
2
2
  import type { ThinkingLevel } from "../config/types.js";
3
3
  import type { DispatchOutcome, ExtensionSurface, InvocationContext, ModelRef, WorkflowCommand } from "./types.js";
4
+ import type { ProfileStateStore } from "./state.js";
4
5
  export interface RouterDeps<M extends ModelRef = ModelRef> {
5
6
  /** Pi's API is session-bound, so the surface is resolved per call. */
6
7
  surface: (ctx: InvocationContext<M>) => ExtensionSurface<M>;
@@ -14,9 +15,15 @@ export interface RouterDeps<M extends ModelRef = ModelRef> {
14
15
  settingsCommand: string;
15
16
  /** Command names that actually exist — a route for anything else is a typo. */
16
17
  knownCommands: ReadonlySet<string>;
18
+ /** Profile demotion state store (P4, feature 63). */
19
+ profileState?: ProfileStateStore;
20
+ /** Wall-clock now (P4). Default: Date.now. */
21
+ now?: () => number;
17
22
  }
18
23
  export interface Router<M extends ModelRef = ModelRef> {
19
- dispatch(command: WorkflowCommand, args: string, ctx: InvocationContext<M>): Promise<DispatchOutcome>;
24
+ dispatch(command: WorkflowCommand, args: string, ctx: InvocationContext<M>, opts?: {
25
+ onProfileSwitch?: "continue" | "restart";
26
+ }): Promise<DispatchOutcome>;
20
27
  /** Pi `model_select` — distinguishes our own switch from the operator's. */
21
28
  noteModelSelect(model: M): void;
22
29
  /** Pi `thinking_level_select` — same distinction for the thinking level. */
@@ -31,4 +38,4 @@ export interface Router<M extends ModelRef = ModelRef> {
31
38
  */
32
39
  undoInFlight(ctx: InvocationContext<M>): Promise<boolean>;
33
40
  }
34
- export declare function createRouter<M extends ModelRef = ModelRef>({ surface, loadConfig, hint, settingsCommand, knownCommands, }: RouterDeps<M>): Router<M>;
41
+ export declare function createRouter<M extends ModelRef = ModelRef>({ surface, loadConfig, hint, settingsCommand, knownCommands, profileState, now, }: RouterDeps<M>): Router<M>;
@@ -1,4 +1,4 @@
1
- import { effectiveRoute } from "../config/merge.js";
1
+ import { resolveProfileChain, effectiveProfileOrder } from "../config/profiles.js";
2
2
  import { parseModelReference } from "../config/schema.js";
3
3
  import { modelRefKey } from "./types.js";
4
4
  /**
@@ -30,11 +30,18 @@ async function restore(turn, session, ctx, why) {
30
30
  if (session.getThinkingLevel() !== wanted)
31
31
  session.setThinkingLevel(wanted);
32
32
  }
33
- export function createRouter({ surface, loadConfig, hint, settingsCommand, knownCommands, }) {
33
+ export function createRouter({ surface, loadConfig, hint, settingsCommand, knownCommands, profileState, now, }) {
34
34
  let pending;
35
35
  // Reported once per session: an operator who ignores it once does not need it
36
36
  // on every command, and a route that matches nothing must not fail silently.
37
37
  let unknownRoutesReported = false;
38
+ // P4: in-memory no-op for profileState when not provided.
39
+ const ps = profileState ?? {
40
+ demotion: () => undefined,
41
+ record: () => true,
42
+ clear: () => true,
43
+ };
44
+ const nowFn = now ?? Date.now;
38
45
  const refuse = (ctx, reason, message) => {
39
46
  ctx.notify(message, "error");
40
47
  return { status: "refused", reason, message };
@@ -87,7 +94,7 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
87
94
  return;
88
95
  await restore(turn, surface(ctx), ctx, "finished");
89
96
  },
90
- async dispatch(command, args, ctx) {
97
+ async dispatch(command, args, ctx, opts) {
91
98
  if (pending) {
92
99
  // Refuse even when `ctx.isIdle()` reads true: idleness says the agent loop
93
100
  // is quiet, not that the routed turn is over, and guessing here is how a
@@ -115,39 +122,53 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
115
122
  }
116
123
  }
117
124
  const session = surface(ctx);
118
- const route = effectiveRoute(loaded.config, command.name);
125
+ // -----------------------------------------------------------------
126
+ // P4: Profile-chain probing (feature 63, AC7–AC9)
127
+ // -----------------------------------------------------------------
128
+ const fallbackCfg = loaded.config.profileFallback;
129
+ // Demotion window — only when applyTo is "flow".
130
+ const chainOpts = { providerAvailable: (provider) => {
131
+ const available = ctx.availableModels;
132
+ return available && available().some((m) => m.provider === provider);
133
+ } };
134
+ let demotion = fallbackCfg.applyTo === "flow" ? ps.demotion() : undefined;
135
+ if (demotion) {
136
+ const age = nowFn() - Date.parse(demotion.at);
137
+ const windowMs = fallbackCfg.retryAfterSeconds * 1000;
138
+ if (!Number.isFinite(age) || age >= windowMs) {
139
+ ps.clear();
140
+ demotion = undefined;
141
+ }
142
+ }
143
+ const order = effectiveProfileOrder(loaded.config, chainOpts);
144
+ const startIndex = demotion ? order.indexOf(demotion.profile) : -1;
145
+ let demotionStart = startIndex;
146
+ if (demotion && demotionStart < 0) {
147
+ // The demoted profile is no longer in the order — drop the stale record.
148
+ ps.clear();
149
+ demotion = undefined;
150
+ demotionStart = -1;
151
+ }
152
+ const candidates = resolveProfileChain(loaded.config, command.name, chainOpts);
153
+ const considered = demotionStart > 0 ? candidates.filter((c) => order.indexOf(c.profile) >= demotionStart) : candidates;
154
+ // Find the preferred candidate (first with a declared model).
155
+ const preferred = considered.find((c) => c.declared.model);
156
+ // Model resolution: first candidate with a declared, usable model.
157
+ let chosen;
119
158
  let target;
120
- /** The reference string behind `target`, for the select-failure message (a chain names the chosen entry). */
159
+ /** The reference string behind `target`, for the select-failure message. */
121
160
  let chosenRef;
122
- if (route.model === "inherit") {
123
- // inherit: no probe — run on the session model (OB-9).
124
- }
125
- else if (typeof route.model === "string") {
126
- // Single reference: behaviour and message shape unchanged (OB-9).
127
- const reference = parseModelReference(route.model);
128
- const found = reference ? ctx.find(reference.provider, reference.id) : undefined;
129
- const blocker = !found
130
- ? "is not in the model registry"
131
- : !ctx.hasConfiguredAuth(found)
132
- ? "has no configured credentials"
133
- : undefined;
134
- if (blocker) {
135
- if (loaded.config.onUnavailableRoute !== "inherit") {
136
- return refuse(ctx, "unavailable-route", `/${command.name} stopped: the configured model ${route.model} ${blocker}. ${configureHint}`);
137
- }
138
- ctx.notify(`/${command.name}: ${route.model} ${blocker}, so it runs on the current session model. ${configureHint}`, "warning");
139
- }
140
- else {
141
- target = found;
142
- chosenRef = route.model;
161
+ const reasons = [];
162
+ for (const c of considered) {
163
+ if (!c.declared.model)
164
+ continue;
165
+ if (c.route.model === "inherit") {
166
+ chosen = c;
167
+ break;
143
168
  }
144
- }
145
- else {
146
- // Chain: probe entries in order with only ctx.find + hasConfiguredAuth
147
- // (no session mutation), collecting one skip reason per entry, and apply
148
- // the first usable one (OB-8, OB-9, OB-10).
149
- const reasons = [];
150
- for (const reference of route.model) {
169
+ // Probe c.route.model (string or chain) — reuse the existing probe shape.
170
+ const refs = typeof c.route.model === "string" ? [c.route.model] : c.route.model;
171
+ for (const reference of refs) {
151
172
  const parsed = parseModelReference(reference);
152
173
  const found = parsed ? ctx.find(parsed.provider, parsed.id) : undefined;
153
174
  if (!found) {
@@ -158,19 +179,67 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
158
179
  reasons.push(`${reference} has no configured credentials`);
159
180
  continue;
160
181
  }
182
+ // First usable entry for this candidate.
183
+ chosen = c;
161
184
  target = found;
162
185
  chosenRef = reference;
163
186
  break;
164
187
  }
165
- if (!target) {
166
- const tried = route.model.join(", ");
167
- const detail = reasons.join("; ");
168
- if (loaded.config.onUnavailableRoute !== "inherit") {
169
- return refuse(ctx, "unavailable-route", `/${command.name} stopped: the configured model chain ${tried} is unavailable (${detail}). ${configureHint}`);
170
- }
171
- ctx.notify(`/${command.name}: the configured model chain ${tried} is unavailable (${detail}), so it runs on the current session model. ${configureHint}`, "warning");
188
+ if (chosen)
189
+ break;
190
+ // Every entry in this candidate's chain was unusable.
191
+ const tried = refs.join(", ");
192
+ reasons.push(`${c.profile}: all models in [${tried}] unavailable`);
193
+ }
194
+ // Thinking: the first candidate that declares thinking, else "inherit".
195
+ const thinkingCandidate = considered.find((c) => c.declared.thinking);
196
+ const routeThinking = thinkingCandidate?.route.thinking ?? "inherit";
197
+ // Handle no chosen candidate — fallback to onUnavailableRoute.
198
+ if (!chosen && preferred) {
199
+ // Build the chain detail for the notification, matching the old shape.
200
+ const triedRefs = [];
201
+ const detailParts = [];
202
+ for (const c of considered) {
203
+ if (!c.declared.model)
204
+ continue;
205
+ if (c.route.model === "inherit")
206
+ continue;
207
+ const refs = typeof c.route.model === "string" ? [c.route.model] : c.route.model;
208
+ triedRefs.push(...refs);
209
+ // Collect the first skip reason per entry for the detail.
210
+ const entryReasons = reasons.filter((r) => refs.some((ref) => r.startsWith(ref)));
211
+ detailParts.push(...entryReasons);
212
+ }
213
+ const tried = triedRefs.join(", ");
214
+ const detail = detailParts.join("; ");
215
+ if (loaded.config.onUnavailableRoute !== "inherit") {
216
+ return refuse(ctx, "unavailable-route", `/${command.name} stopped: the configured model chain ${tried} is unavailable (${detail}). ${configureHint}`);
172
217
  }
218
+ ctx.notify(`/${command.name}: the configured model chain ${tried} is unavailable (${detail}), so it runs on the current session model. ${configureHint}`, "warning");
219
+ }
220
+ let profileSwitched;
221
+ if (chosen && preferred && chosen.profile !== preferred.profile) {
222
+ profileSwitched = { from: preferred.profile, to: chosen.profile };
223
+ if (fallbackCfg.applyTo === "flow")
224
+ ps.record(chosen.profile);
225
+ }
226
+ // AC10 restart: when onProfileSwitch === "restart" AND applyTo === "flow",
227
+ // record the demotion but do NOT apply the model or send — return deferred
228
+ // so the loop re-runs the iteration under the fallback profile.
229
+ if (opts?.onProfileSwitch === "restart"
230
+ && fallbackCfg.applyTo === "flow"
231
+ && profileSwitched) {
232
+ return {
233
+ status: "dispatched",
234
+ routed: false,
235
+ hintShown: false,
236
+ profileSwitched,
237
+ deferred: true,
238
+ };
173
239
  }
240
+ // -----------------------------------------------------------------
241
+ // Legacy: model application (target + thinking)
242
+ // -----------------------------------------------------------------
174
243
  const snapshot = { model: ctx.model, thinking: session.getThinkingLevel() };
175
244
  const applied = { model: undefined, thinking: undefined };
176
245
  if (target) {
@@ -180,22 +249,22 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
180
249
  return refuse(ctx, "unavailable-route", `/${command.name} stopped: ${chosenRef} could not be selected. ${configureHint}`);
181
250
  }
182
251
  ctx.notify(`/${command.name}: ${chosenRef} could not be selected, so it runs on the current session model. ${configureHint}`, "warning");
252
+ // A failed select must not clear the demotion.
253
+ // Demotion is cleared only by expiry (top of dispatch), manual rotation/clear, or the console.
183
254
  }
184
255
  else {
185
256
  applied.model = target;
186
- // Pi re-derives thinking inside `setModel`; whatever level the session
187
- // holds now came from us, not from the operator.
188
257
  applied.modelThinking = session.getThinkingLevel();
189
258
  }
190
259
  }
191
- if (route.thinking !== "inherit") {
192
- session.setThinkingLevel(route.thinking);
260
+ if (routeThinking !== "inherit") {
261
+ session.setThinkingLevel(routeThinking);
193
262
  // The *effective* level, never the requested one: Pi clamps a level the model
194
263
  // cannot run (`_modelSupportsThinking` → `clampThinkingLevel`) and announces
195
264
  // that one a microtask later. Bookkeeping the request made our own clamped
196
265
  // write read as an operator move, so the restore preserved the clamp instead
197
266
  // of the operator's level (N-3).
198
- applied.thinking = session.getThinkingLevel() ?? route.thinking;
267
+ applied.thinking = session.getThinkingLevel() ?? routeThinking;
199
268
  }
200
269
  let hintShown = false;
201
270
  if (hint.pending()) {
@@ -243,7 +312,7 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
243
312
  }
244
313
  return refuse(ctx, "dispatch-failed", `/${command.name} was not dispatched: ${error.message}. Nothing was sent${rolledBack ? ", and the session model was put back" : ""}.`);
245
314
  }
246
- return { status: "dispatched", routed: Boolean(applied.model || applied.thinking), hintShown };
315
+ return { status: "dispatched", routed: Boolean(applied.model || applied.thinking), hintShown, ...(profileSwitched ? { profileSwitched } : {}) };
247
316
  },
248
317
  };
249
318
  }
@@ -11,3 +11,29 @@ export interface HintStoreOptions {
11
11
  writeFile?: (path: string, text: string) => void;
12
12
  }
13
13
  export declare function createHintStore({ path, readFile, writeFile, }: HintStoreOptions): HintStore;
14
+ /** A profile that was demoted because its model was unusable. */
15
+ export interface ProfileDemotion {
16
+ profile: string;
17
+ /** ISO-8601 timestamp of when the demotion was recorded. */
18
+ at: string;
19
+ }
20
+ export interface ProfileStateStore {
21
+ /** Returns the current demotion, or undefined if no demotion is active. */
22
+ demotion(): ProfileDemotion | undefined;
23
+ /**
24
+ * Record that the given profile was chosen (demoting it for a retry window).
25
+ * Returns false if the write failed.
26
+ */
27
+ record(profile: string, now?: Date): boolean;
28
+ /**
29
+ * Clear any demotion record without touching other keys in the state file.
30
+ * Returns false if the write failed.
31
+ */
32
+ clear(): boolean;
33
+ }
34
+ export interface ProfileStateStoreOptions {
35
+ path: string;
36
+ readFile?: (path: string) => string | null;
37
+ writeFile?: (path: string, text: string) => void;
38
+ }
39
+ export declare function createProfileStateStore({ path, readFile, writeFile, }: ProfileStateStoreOptions): ProfileStateStore;
@@ -54,7 +54,73 @@ export function createHintStore({ path, readFile = readOrNull, writeFile = write
54
54
  // next command in this same session.
55
55
  acknowledged = true;
56
56
  try {
57
- writeFile(path, `${JSON.stringify({ firstRunHintShownAt: now }, null, 2)}\n`);
57
+ // Read-merge-write: preserve any other keys already in the state file.
58
+ const existing = readFile(path);
59
+ let merged = {};
60
+ if (existing && existing.trim() !== "") {
61
+ try {
62
+ const parsed = JSON.parse(existing);
63
+ if (typeof parsed === "object" && parsed !== null)
64
+ merged = parsed;
65
+ }
66
+ catch {
67
+ // Corrupt state: discard and start fresh.
68
+ }
69
+ }
70
+ merged.firstRunHintShownAt = now;
71
+ writeFile(path, `${JSON.stringify(merged, null, 2)}\n`);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ },
78
+ };
79
+ }
80
+ export function createProfileStateStore({ path, readFile = readOrNull, writeFile = writeThrough, }) {
81
+ const readState = () => {
82
+ const text = readFile(path);
83
+ if (text === null || text.trim() === "")
84
+ return {};
85
+ try {
86
+ const parsed = JSON.parse(text);
87
+ if (typeof parsed === "object" && parsed !== null)
88
+ return parsed;
89
+ }
90
+ catch {
91
+ // Corrupt state: treat as empty.
92
+ }
93
+ return {};
94
+ };
95
+ return {
96
+ demotion() {
97
+ const state = readState();
98
+ const pd = state.profileDemotion;
99
+ if (pd && typeof pd === "object" && "profile" in pd && "at" in pd) {
100
+ const profile = pd.profile;
101
+ const at = pd.at;
102
+ if (typeof profile === "string" && typeof at === "string") {
103
+ return { profile, at };
104
+ }
105
+ }
106
+ return undefined;
107
+ },
108
+ record(profile, now = new Date()) {
109
+ const state = readState();
110
+ state.profileDemotion = { profile, at: now.toISOString() };
111
+ try {
112
+ writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ },
119
+ clear() {
120
+ const state = readState();
121
+ delete state.profileDemotion;
122
+ try {
123
+ writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
58
124
  return true;
59
125
  }
60
126
  catch {
@@ -84,6 +84,11 @@ export type DispatchOutcome = {
84
84
  status: "dispatched";
85
85
  routed: boolean;
86
86
  hintShown: boolean;
87
+ profileSwitched?: {
88
+ from: string;
89
+ to: string;
90
+ };
91
+ deferred?: boolean;
87
92
  } | {
88
93
  status: "refused";
89
94
  reason: RefusalReason;
@@ -104,4 +109,6 @@ export declare const SETTINGS_COMMAND_ALIAS = "aw-settings";
104
109
  export interface RoutingControls {
105
110
  inFlight(): boolean;
106
111
  undoInFlight(): Promise<boolean>;
112
+ /** Clear the recorded profile demotion (feature 63 rotate action). */
113
+ clearProfileDemotion?(): boolean;
107
114
  }
@@ -48,6 +48,13 @@ export declare const prompts: {
48
48
  readonly clearOverride: "Clear a command override";
49
49
  readonly bulkApply: "Apply one route to several commands";
50
50
  readonly bulkClear: "Clear several overrides";
51
+ readonly switchProfile: "Switch the profile being edited";
52
+ readonly rotateProfile: "Rotate the active profile";
53
+ readonly toggleRecommended: "Toggle recommended models";
54
+ readonly profile: "Which profile?";
55
+ readonly newProfile: "Name the new profile";
56
+ readonly profileBuiltIn: (name: string) => string;
57
+ readonly profileNamed: (name: string) => string;
51
58
  readonly addAnother: "Add another?";
52
59
  readonly policy: "Set the unavailable-route policy";
53
60
  readonly settle: "Set the post-command model keep/restore policy";