@gtrabanco/pi-agentic-workflow 0.15.3 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,6 +97,55 @@ The first workflow command you run after install says once that routing is
97
97
  configurable, then never again. That acknowledgement is stored in
98
98
  `~/.pi/agent/pi-agentic-workflow-state.json`, not in your config.
99
99
 
100
+ ## Model profiles
101
+
102
+ The routing config above is the **`default` profile**. A profile is one coherent
103
+ set of routes, and you can define as many as you like under `profiles`:
104
+
105
+ ```json
106
+ {
107
+ "recommendedModels": true,
108
+ "profileOrder": ["work", "cheap"],
109
+ "profileFallback": { "applyTo": "flow", "resume": "continue", "retryAfterSeconds": 86400 },
110
+ "profiles": {
111
+ "work": { "default": { "model": "anthropic/claude-opus-4-5", "thinking": "high" } },
112
+ "cheap": { "default": { "model": "openai/gpt-5.2", "thinking": "medium" } }
113
+ },
114
+ "default": { "model": "inherit", "thinking": "inherit" },
115
+ "commands": { "review-change": { "thinking": "max" } }
116
+ }
117
+ ```
118
+
119
+ - **`profileOrder`** — the preference chain, first = most preferred/active. For each
120
+ command the router probes the profiles in order and the **first whose model is
121
+ usable** serves it; when the preferred profile's model is unavailable it falls
122
+ through to the next. A profile participates only for keys it actually declares.
123
+ - **`profiles`** — unlimited named profiles. The implicit **`default`** profile is
124
+ the top-level `default` + `commands` above, so a config with just those two keys
125
+ resolves exactly as before.
126
+ - **Built-in `nan` profile.** With `recommendedModels: true` (the default), when the
127
+ session has models from the `nan` provider, a code-only `nan` profile is appended
128
+ to the end of the chain (or used alone when you set no `profileOrder`). It carries
129
+ the per-command ladders from the README's NaN section. Any route you declare —
130
+ including an explicit `"inherit"` — wins over it. Set `recommendedModels: false`
131
+ to switch it off; the settings console writes the key explicitly on save when the
132
+ provider is available.
133
+ - **`profileFallback`** — what happens when the preferred profile's model is
134
+ unusable:
135
+ - `applyTo`: `"flow"` (default) records the switch so later commands keep using the
136
+ fallback profile; `"command"` falls back for this command only and the next
137
+ command retries the preferred profile.
138
+ - `resume`: `"continue"` (default) lets the in-flight stage run on the fallback
139
+ profile; `"restart"` makes the `advance` conductor defer the switch and re-run
140
+ the stage under the fallback profile instead.
141
+ - `retryAfterSeconds`: how long a `flow` demotion lasts before the preferred
142
+ profile is probed again (default `86400`, one day).
143
+ - **Rotation.** `/agentic-workflow-settings` is scope → profile → routes: pick the
144
+ scope, pick the profile to edit, then edit its routes. `Rotate the active profile`
145
+ moves a profile to the front of `profileOrder` and clears any recorded demotion.
146
+ The built-in `nan` profile is shown but not editable — create a profile to
147
+ override it.
148
+
100
149
  ## Path protection (optional)
101
150
 
102
151
  The Tier 2 preventive guard blocks a `write` / `edit` tool call to an existing
@@ -62,6 +62,7 @@ export async function runConductorLoop(deps) {
62
62
  const bin = deps.runtimeBin ?? "bun";
63
63
  let consecutivePartials = 0;
64
64
  let consecutiveSenses = 0;
65
+ let consecutiveDeferred = 0;
65
66
  let invoked = 0;
66
67
  for (let iteration = 1; iteration <= cap; iteration++) {
67
68
  // ── 1. Sensor ───────────────────────────────────────────────────────
@@ -186,7 +187,28 @@ export async function runConductorLoop(deps) {
186
187
  iterations: iteration - 1,
187
188
  };
188
189
  }
189
- await deps.sendUserMessage(invocation);
190
+ const sendResult = await deps.sendUserMessage(invocation);
191
+ if (sendResult.ok === false) {
192
+ deps.appendRunLog(formatLogLine("stop", "stop-dispatch-refused", invocation, iteration, cap));
193
+ return {
194
+ banner: "ADVANCE: STOPPED",
195
+ stopCode: "stop-dispatch-refused",
196
+ detail: "the stage dispatch was refused (the router already explained why)",
197
+ iterations: iteration - 1,
198
+ };
199
+ }
200
+ if (sendResult.deferred === true) {
201
+ // AC10 restart: the routing recorded the demotion instead of sending; re-run
202
+ // the iteration so the stage is decided and served under the fallback profile.
203
+ deps.appendRunLog(formatLogLine("switch", `${sendResult.profileSwitched?.from ?? "?"}->${sendResult.profileSwitched?.to ?? "?"}`, invocation, iteration, cap));
204
+ consecutiveDeferred++;
205
+ if (consecutiveDeferred >= 2) {
206
+ deps.appendRunLog(formatLogLine("stop", "stop-profile-switch-loop", "deferred-twice", iteration, cap));
207
+ return { banner: "ADVANCE: STOPPED", stopCode: "stop-profile-switch-loop", detail: "profile switch deferred twice without progress", iterations: iteration - 1 };
208
+ }
209
+ continue; // do NOT increment `invoked`
210
+ }
211
+ consecutiveDeferred = 0;
190
212
  deps.appendRunLog(formatLogLine("invoke", decision.reasonCode, invocation, iteration, cap));
191
213
  // One-stage-per-invocation wiring: the invoked skill must settle before
192
214
  // the next sensor run can see its effect, so the default wiring stops
@@ -71,7 +71,13 @@ export interface LoopDeps {
71
71
  invocation: (decision: ActionDecision, opts: InvocationOpts) => InvocationResult;
72
72
  sendUserMessage: (invocation: string) => Promise<{
73
73
  ok: boolean;
74
+ profileSwitched?: {
75
+ from: string;
76
+ to: string;
77
+ };
78
+ deferred?: boolean;
74
79
  }>;
80
+ profileResume?: "continue" | "restart";
75
81
  gitProbe: () => Promise<{
76
82
  porcelain: string;
77
83
  ahead: number;
@@ -1,4 +1,5 @@
1
1
  import { SHIPPED_PATH_POLICY, intersectPathPolicy } from "./path-policy.js";
2
+ import { DEFAULT_PROFILE_FALLBACK } from "./types.js";
2
3
  /**
3
4
  * The in-package default (SPEC S6, D-P6): a fresh install with no config file
4
5
  * anywhere routes every command on the session model, and the fail-closed
@@ -10,5 +11,15 @@ export const DEFAULT_CONFIG = {
10
11
  commands: {},
11
12
  onUnavailableRoute: "stop",
12
13
  onSettle: "keep",
14
+ // Feature 60 added the resolved path-protection policy to the effective
15
+ // config; the shipped floor with no override and no degradation.
13
16
  pathProtection: intersectPathPolicy(SHIPPED_PATH_POLICY, {}),
17
+ // Feature 63 — model profiles: recommended built-in profiles, empty
18
+ // named profiles, empty order (default-only), and default demotion policy.
19
+ recommendedModels: true,
20
+ profiles: {},
21
+ profileOrder: [],
22
+ profileFallback: { ...DEFAULT_PROFILE_FALLBACK },
23
+ // Feature 63 — model profiles: declared keys (none, zero-config).
24
+ declared: { default: { model: false, thinking: false }, commands: {} },
14
25
  };
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_CONFIG, DEFAULT_ROUTE } from "./defaults.js";
2
2
  import { SHIPPED_PATH_POLICY, intersectPathPolicy, mergePathProtectionOverrides } from "./path-policy.js";
3
- import { DEFAULT_ADVANCE_CONFIG } from "./types.js";
3
+ import { DEFAULT_ADVANCE_CONFIG, DEFAULT_PROFILE_FALLBACK } from "./types.js";
4
4
  /**
5
5
  * Project-over-global merge (SPEC S5, S6).
6
6
  *
@@ -19,6 +19,80 @@ function pick(fallback, ...values) {
19
19
  return value;
20
20
  return fallback;
21
21
  }
22
+ /** Merge two RouteFiles key-by-key (used for profile routes). */
23
+ function mergeRouteFiles(globalRoute, projectRoute) {
24
+ if (globalRoute === undefined && projectRoute === undefined)
25
+ return undefined;
26
+ const out = {};
27
+ if (globalRoute?.model !== undefined || projectRoute?.model !== undefined) {
28
+ // A chain declared only at global scope survives.
29
+ // Per-key: project chain wins, or global chain, or inherited string.
30
+ if (projectRoute?.model !== undefined) {
31
+ out.model = projectRoute.model;
32
+ }
33
+ else if (globalRoute?.model !== undefined) {
34
+ out.model = globalRoute.model;
35
+ }
36
+ }
37
+ if (globalRoute?.thinking !== undefined || projectRoute?.thinking !== undefined) {
38
+ out.thinking = pick(DEFAULT_ROUTE.thinking, projectRoute?.thinking, globalRoute?.thinking);
39
+ }
40
+ return Object.keys(out).length > 0 ? out : undefined;
41
+ }
42
+ /** Per-profile merge: each profile's default and commands merge key-by-key.
43
+ * A profile name may exist in one scope but not the other; the missing
44
+ * scope simply contributes nothing. */
45
+ function mergeProfiles(globalProfiles, projectProfiles) {
46
+ const allNames = new Set([
47
+ ...Object.keys(globalProfiles ?? {}),
48
+ ...Object.keys(projectProfiles ?? {}),
49
+ ]);
50
+ const result = {};
51
+ for (const name of allNames) {
52
+ const gDef = globalProfiles?.[name];
53
+ const pDef = projectProfiles?.[name];
54
+ const mergedDef = {};
55
+ // Merge default routes key-by-key.
56
+ if (gDef?.default !== undefined || pDef?.default !== undefined) {
57
+ const mergedDefault = {};
58
+ if (pDef?.default !== undefined && pDef.default.model !== undefined) {
59
+ mergedDefault.model = pDef.default.model;
60
+ }
61
+ else if (gDef?.default !== undefined && gDef.default.model !== undefined) {
62
+ mergedDefault.model = gDef.default.model;
63
+ }
64
+ if (gDef?.default?.thinking !== undefined || pDef?.default?.thinking !== undefined) {
65
+ mergedDefault.thinking = pick(DEFAULT_ROUTE.thinking, pDef?.default?.thinking, gDef?.default?.thinking);
66
+ }
67
+ mergedDef.default = mergedDefault;
68
+ }
69
+ // Merge commands key-by-key.
70
+ const gCmds = gDef?.commands ?? {};
71
+ const pCmds = pDef?.commands ?? {};
72
+ const allCmdNames = new Set([...Object.keys(gCmds), ...Object.keys(pCmds)]);
73
+ if (allCmdNames.size > 0) {
74
+ mergedDef.commands = {};
75
+ for (const cmd of allCmdNames) {
76
+ const mergedCmd = mergeRouteFiles(gCmds[cmd], pCmds[cmd]);
77
+ if (mergedCmd)
78
+ mergedDef.commands[cmd] = mergedCmd;
79
+ }
80
+ }
81
+ if (Object.keys(mergedDef).length > 0) {
82
+ result[name] = mergedDef;
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+ /** Per-key merge for profileFallback: each field independently
88
+ * takes project value, then global value, then the shipped default. */
89
+ function mergeProfileFallback(globalFb, projectFb) {
90
+ return {
91
+ applyTo: pick(DEFAULT_PROFILE_FALLBACK.applyTo, projectFb?.applyTo, globalFb?.applyTo),
92
+ resume: pick(DEFAULT_PROFILE_FALLBACK.resume, projectFb?.resume, globalFb?.resume),
93
+ retryAfterSeconds: pick(DEFAULT_PROFILE_FALLBACK.retryAfterSeconds, projectFb?.retryAfterSeconds, globalFb?.retryAfterSeconds),
94
+ };
95
+ }
22
96
  function resolveRoute(defaults, globalRoute, projectRoute) {
23
97
  return {
24
98
  model: pick(DEFAULT_ROUTE.model, projectRoute?.model, globalRoute?.model, defaults.model),
@@ -33,12 +107,30 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
33
107
  for (const name of new Set([...Object.keys(globalCommands), ...Object.keys(projectCommands)])) {
34
108
  commands[name] = resolveRoute(effectiveDefault, globalCommands[name], projectCommands[name]);
35
109
  }
110
+ // Feature 63 — model profiles: computed declaredness from raw files.
111
+ const declaredDefault = {
112
+ model: globalFile.default?.model !== undefined || projectFile.default?.model !== undefined,
113
+ thinking: globalFile.default?.thinking !== undefined || projectFile.default?.thinking !== undefined,
114
+ };
115
+ const declaredCommands = {};
116
+ for (const name of new Set([...Object.keys(globalCommands), ...Object.keys(projectCommands)])) {
117
+ declaredCommands[name] = {
118
+ model: declaredDefault.model || globalCommands[name]?.model !== undefined || projectCommands[name]?.model !== undefined,
119
+ thinking: declaredDefault.thinking || globalCommands[name]?.thinking !== undefined || projectCommands[name]?.thinking !== undefined,
120
+ };
121
+ }
36
122
  const merged = {
37
123
  default: effectiveDefault,
38
124
  commands,
39
125
  onUnavailableRoute: projectFile.onUnavailableRoute ?? globalFile.onUnavailableRoute ?? DEFAULT_CONFIG.onUnavailableRoute,
40
126
  onSettle: projectFile.onSettle ?? globalFile.onSettle ?? DEFAULT_CONFIG.onSettle,
41
127
  pathProtection: intersectPathPolicy(SHIPPED_PATH_POLICY, mergePathProtectionOverrides(globalFile.pathProtection, projectFile.pathProtection)),
128
+ // Feature 63 — model profiles.
129
+ recommendedModels: pick(true, projectFile.recommendedModels, globalFile.recommendedModels),
130
+ profiles: mergeProfiles(globalFile.profiles, projectFile.profiles),
131
+ profileOrder: pick([], projectFile.profileOrder, globalFile.profileOrder),
132
+ profileFallback: mergeProfileFallback(globalFile.profileFallback, projectFile.profileFallback),
133
+ declared: { default: declaredDefault, commands: declaredCommands },
42
134
  };
43
135
  // Advance knobs appear only when a scope declares them, so configs that
44
136
  // predate feature 62 keep their exact resolved shape (no undefined key).
@@ -0,0 +1,36 @@
1
+ import type { EffectiveConfig, ProfileCandidate } from "./types.js";
2
+ /** Options for recommended-profile resolution. */
3
+ export interface RecommendedOptions {
4
+ providerAvailable: (provider: string) => boolean;
5
+ }
6
+ /**
7
+ * Compute the ordered list of profile names to probe for a command.
8
+ *
9
+ * Rules:
10
+ * - Start from `config.profileOrder` if non-empty, else `["default"]`.
11
+ * - If `config.recommendedModels` is true and the `nan` provider is available,
12
+ * append `"nan"` (unless it is already present in the order).
13
+ * - Returns a fresh array so callers may mutate without side effects.
14
+ */
15
+ export declare function effectiveProfileOrder(config: EffectiveConfig, opts: RecommendedOptions): string[];
16
+ /**
17
+ * Resolve a profile chain for one command.
18
+ *
19
+ * Iterates the ordered profile names from `effectiveProfileOrder`. For each name:
20
+ * - `nan` → uses `config.profiles.nan` if the user overrode it, else
21
+ * the built-in `RECOMMENDED_PROFILES.nan`.
22
+ * - `default` → the implicit profile made from `config.default` and
23
+ * `config.commands` (top-level, already-merged).
24
+ * - Any other name → `config.profiles[name]` (user-defined profile).
25
+ *
26
+ * Skip names with no profile definition.
27
+ * For each candidate, compute the route file from the command's explicit
28
+ * command route or the profile's default route. Skip if neither is defined.
29
+ *
30
+ * Built-in nan profile (`RECOMMENDED_PROFILES.nan`, never user-overridden)
31
+ * always reports `declared = { model: true, thinking: true }`.
32
+ *
33
+ * Returns candidates in order, each with its route and per-key declaration
34
+ * status.
35
+ */
36
+ export declare function resolveProfileChain(config: EffectiveConfig, command: string, opts: RecommendedOptions): ProfileCandidate[];
@@ -0,0 +1,102 @@
1
+ import { RECOMMENDED_PROFILES } from "./recommended.js";
2
+ /**
3
+ * Compute the ordered list of profile names to probe for a command.
4
+ *
5
+ * Rules:
6
+ * - Start from `config.profileOrder` if non-empty, else `["default"]`.
7
+ * - If `config.recommendedModels` is true and the `nan` provider is available,
8
+ * append `"nan"` (unless it is already present in the order).
9
+ * - Returns a fresh array so callers may mutate without side effects.
10
+ */
11
+ export function effectiveProfileOrder(config, opts) {
12
+ const base = config.profileOrder.length > 0 ? config.profileOrder : ["default"];
13
+ const result = [...base];
14
+ if (config.recommendedModels && opts.providerAvailable("nan")) {
15
+ if (!result.includes("nan")) {
16
+ result.push("nan");
17
+ }
18
+ }
19
+ return result;
20
+ }
21
+ /**
22
+ * Resolve a profile chain for one command.
23
+ *
24
+ * Iterates the ordered profile names from `effectiveProfileOrder`. For each name:
25
+ * - `nan` → uses `config.profiles.nan` if the user overrode it, else
26
+ * the built-in `RECOMMENDED_PROFILES.nan`.
27
+ * - `default` → the implicit profile made from `config.default` and
28
+ * `config.commands` (top-level, already-merged).
29
+ * - Any other name → `config.profiles[name]` (user-defined profile).
30
+ *
31
+ * Skip names with no profile definition.
32
+ * For each candidate, compute the route file from the command's explicit
33
+ * command route or the profile's default route. Skip if neither is defined.
34
+ *
35
+ * Built-in nan profile (`RECOMMENDED_PROFILES.nan`, never user-overridden)
36
+ * always reports `declared = { model: true, thinking: true }`.
37
+ *
38
+ * Returns candidates in order, each with its route and per-key declaration
39
+ * status.
40
+ */
41
+ export function resolveProfileChain(config, command, opts) {
42
+ const profileNames = effectiveProfileOrder(config, opts);
43
+ const candidates = [];
44
+ for (const name of profileNames) {
45
+ let def;
46
+ if (name === "nan") {
47
+ def = config.profiles.nan ?? RECOMMENDED_PROFILES.nan;
48
+ }
49
+ else if (name === "default") {
50
+ def = { default: config.default, commands: config.commands };
51
+ }
52
+ else {
53
+ def = config.profiles[name];
54
+ }
55
+ if (!def) {
56
+ continue;
57
+ }
58
+ const routeFile = def.commands?.[command] ?? def.default;
59
+ if (routeFile === undefined) {
60
+ continue;
61
+ }
62
+ // Determine per-key declaredness from the raw file presence (not value).
63
+ // For the "default" profile the declared info comes from the merged
64
+ // EffectiveConfig.declared surface; for named profiles we check raw
65
+ // presence; the built-in nan always declares both.
66
+ let declared;
67
+ if (name === "nan" && config.profiles.nan === undefined) {
68
+ // Built-in nan always declares both keys.
69
+ declared = { model: true, thinking: true };
70
+ }
71
+ else if (name === "default") {
72
+ // Use the merged declared surface (carries raw-file presence).
73
+ declared = config.declared.commands[command] ?? config.declared.default;
74
+ }
75
+ else {
76
+ // User-defined named profile (including a user-defined "nan"): check raw presence.
77
+ const routeFileDef = config.profiles[name];
78
+ const rFile = routeFileDef?.commands?.[command] ?? routeFileDef?.default;
79
+ declared = {
80
+ model: rFile?.model !== undefined,
81
+ thinking: rFile?.thinking !== undefined,
82
+ };
83
+ }
84
+ // Skip candidates that declare nothing (covers both: truly empty
85
+ // routeFile, and the shipped inherit/inherit default).
86
+ if (!declared.model && !declared.thinking) {
87
+ continue;
88
+ }
89
+ const route = {
90
+ model: routeFile.model ?? "inherit",
91
+ thinking: routeFile.thinking ?? "inherit",
92
+ };
93
+ const builtIn = name === "nan" && config.profiles.nan === undefined;
94
+ candidates.push({
95
+ profile: name,
96
+ builtIn,
97
+ route,
98
+ declared,
99
+ });
100
+ }
101
+ return candidates;
102
+ }
@@ -0,0 +1,21 @@
1
+ import type { RouteFile } from "./types.js";
2
+ /** Provider identifiers shipped as built-in profiles. */
3
+ export declare const RECOMMENDED_PROVIDERS: readonly string[];
4
+ /** A built-in profile shape: optional default route + per-command overrides. */
5
+ export interface RecommendedProfile {
6
+ default?: RouteFile;
7
+ commands: Record<string, RouteFile>;
8
+ }
9
+ /**
10
+ * Built-in model profiles keyed by provider identifier.
11
+ * Only these four models are ever used: nan/glm5.3, nan/glm5.3-flash,
12
+ * nan/deepseek-v4-flash, nan/mimo-v2.6-flash.
13
+ *
14
+ * The judgment slots (audit-pr, product-audit, review-change, unit-lane) use
15
+ * the Xiaomi family for reviewer independence from the DeepSeek/GLM executors;
16
+ * mimo-v2.6-flash supersedes mimo-v2.5 there (same family, same 1.0B quota,
17
+ * OpenAI-`tools`-native, stronger on the shared coding/agentic benchmarks).
18
+ * Keep this list in step with the README's NaN guidance — there is no test
19
+ * asserting that parity, only the expectations in test/model-profiles.test.mjs.
20
+ */
21
+ export declare const RECOMMENDED_PROFILES: Readonly<Record<string, RecommendedProfile>>;
@@ -0,0 +1,35 @@
1
+ /** Provider identifiers shipped as built-in profiles. */
2
+ export const RECOMMENDED_PROVIDERS = ["nan"];
3
+ /**
4
+ * Built-in model profiles keyed by provider identifier.
5
+ * Only these four models are ever used: nan/glm5.3, nan/glm5.3-flash,
6
+ * nan/deepseek-v4-flash, nan/mimo-v2.6-flash.
7
+ *
8
+ * The judgment slots (audit-pr, product-audit, review-change, unit-lane) use
9
+ * the Xiaomi family for reviewer independence from the DeepSeek/GLM executors;
10
+ * mimo-v2.6-flash supersedes mimo-v2.5 there (same family, same 1.0B quota,
11
+ * OpenAI-`tools`-native, stronger on the shared coding/agentic benchmarks).
12
+ * Keep this list in step with the README's NaN guidance — there is no test
13
+ * asserting that parity, only the expectations in test/model-profiles.test.mjs.
14
+ */
15
+ export const RECOMMENDED_PROFILES = {
16
+ nan: {
17
+ default: { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
18
+ commands: {
19
+ "audit-pr": { model: ["nan/mimo-v2.6-flash", "nan/deepseek-v4-flash"], thinking: "high" },
20
+ "product-audit": { model: ["nan/glm5.3", "nan/mimo-v2.6-flash", "nan/deepseek-v4-flash"], thinking: "high" },
21
+ "review-change": { model: ["nan/mimo-v2.6-flash", "nan/glm5.3-flash", "nan/deepseek-v4-flash"], thinking: "high" },
22
+ "unit-lane": { model: ["nan/mimo-v2.6-flash", "nan/glm5.3-flash", "nan/deepseek-v4-flash"], thinking: "high" },
23
+ "triage-issue": { model: ["nan/glm5.3-flash", "nan/deepseek-v4-flash"], thinking: "inherit" },
24
+ "init-workspace": { model: ["nan/glm5.3-flash", "nan/deepseek-v4-flash"], thinking: "inherit" },
25
+ "execute-phase": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
26
+ "fold-findings": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
27
+ "log-session": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
28
+ "workflow-status": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
29
+ "audit-docs": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
30
+ "discover-repository-state": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
31
+ "resolve-repository-state": { model: ["nan/deepseek-v4-flash", "nan/glm5.3-flash"], thinking: "inherit" },
32
+ "advance": { model: ["nan/glm5.3-flash", "nan/deepseek-v4-flash"], thinking: "inherit" },
33
+ },
34
+ },
35
+ };
@@ -1,4 +1,4 @@
1
- import { MAX_MODEL_CHAIN, SETTLE_POLICIES, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
1
+ import { MAX_MODEL_CHAIN, SETTLE_POLICIES, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES, PROFILE_FALLBACK_APPLY_TO, PROFILE_FALLBACK_RESUME, } from "./types.js";
2
2
  import { OPERATIONS, PATH_GLOB_MAX_LENGTH, PHASE_STATES, REQUIREMENTS } from "./path-policy.js";
3
3
  /**
4
4
  * Strict validator for one config file (SPEC S5-S8, D-E5).
@@ -9,7 +9,18 @@ import { OPERATIONS, PATH_GLOB_MAX_LENGTH, PHASE_STATES, REQUIREMENTS } from "./
9
9
  * strong model ran when it did not. Only a *missing* file resolves to the
10
10
  * default, and that decision belongs to the loader.
11
11
  */
12
- const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute", "onSettle", "pathProtection", "advance"]);
12
+ const ROOT_KEYS = new Set([
13
+ "default",
14
+ "commands",
15
+ "onUnavailableRoute",
16
+ "onSettle",
17
+ "pathProtection",
18
+ "advance",
19
+ "recommendedModels",
20
+ "profiles",
21
+ "profileOrder",
22
+ "profileFallback",
23
+ ]);
13
24
  const ROUTE_KEYS = new Set(["model", "thinking"]);
14
25
  const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
15
26
  function isRecord(value) {
@@ -41,6 +52,112 @@ function isUnavailableRoutePolicy(value) {
41
52
  function isSettlePolicy(value) {
42
53
  return SETTLE_POLICIES.includes(value);
43
54
  }
55
+ /** Strict validator for `recommendedModels` (feature 63, AC1). */
56
+ function isRecommendedModels(value) {
57
+ return typeof value === "boolean";
58
+ }
59
+ /** Strict validator for the `profileOrder` array (feature 63, AC6). */
60
+ const PROFILE_ORDER_ENTRY = /^[a-z0-9][a-z0-9._-]*$/u;
61
+ function checkProfileOrder(value, issues) {
62
+ if (!Array.isArray(value)) {
63
+ issues.push({ path: "$.profileOrder", message: "must be an array of non-empty strings" });
64
+ return undefined;
65
+ }
66
+ if (value.length === 0) {
67
+ issues.push({ path: "$.profileOrder", message: "must be a non-empty array of non-empty strings" });
68
+ return undefined;
69
+ }
70
+ for (let i = 0; i < value.length; i++) {
71
+ const entry = value[i];
72
+ if (typeof entry !== "string" || !PROFILE_ORDER_ENTRY.test(entry)) {
73
+ issues.push({ path: "$.profileOrder", message: "must be a non-empty array of non-empty strings" });
74
+ return undefined;
75
+ }
76
+ }
77
+ return value;
78
+ }
79
+ /** Strict validator for the `profileFallback` object (feature 63). */
80
+ const PROFILE_FALLBACK_KEYS = new Set(["applyTo", "resume", "retryAfterSeconds"]);
81
+ ;
82
+ function checkProfileFallback(value, issues) {
83
+ if (!isRecord(value)) {
84
+ issues.push({ path: "$.profileFallback", message: "must be an object with applyTo, resume, retryAfterSeconds" });
85
+ return undefined;
86
+ }
87
+ const out = {};
88
+ for (const key of Object.keys(value)) {
89
+ if (!PROFILE_FALLBACK_KEYS.has(key)) {
90
+ issues.push({ path: `$.profileFallback.${key}`, message: `unknown profileFallback key "${key}" (allowed: applyTo, resume, retryAfterSeconds)` });
91
+ continue;
92
+ }
93
+ const v = value[key];
94
+ if (key === "applyTo") {
95
+ if (!PROFILE_FALLBACK_APPLY_TO.includes(v)) {
96
+ issues.push({ path: "$.profileFallback.applyTo", message: `must be one of ${PROFILE_FALLBACK_APPLY_TO.join(", ")}` });
97
+ }
98
+ else {
99
+ out.applyTo = v;
100
+ }
101
+ }
102
+ else if (key === "resume") {
103
+ if (!PROFILE_FALLBACK_RESUME.includes(v)) {
104
+ issues.push({ path: "$.profileFallback.resume", message: `must be one of ${PROFILE_FALLBACK_RESUME.join(", ")}` });
105
+ }
106
+ else {
107
+ out.resume = v;
108
+ }
109
+ }
110
+ else if (key === "retryAfterSeconds") {
111
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1) {
112
+ issues.push({ path: "$.profileFallback.retryAfterSeconds", message: "must be a positive integer" });
113
+ }
114
+ else {
115
+ out.retryAfterSeconds = v;
116
+ }
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ /** Strict validator for one profile file (feature 63, AC5). */
122
+ const PROFILE_FILE_KEYS = new Set(["default", "commands"]);
123
+ ;
124
+ function checkProfileFile(value, path, issues) {
125
+ if (!isRecord(value)) {
126
+ issues.push({ path, message: "profile must be an object with optional default/commands keys" });
127
+ return undefined;
128
+ }
129
+ const out = {};
130
+ for (const key of Object.keys(value)) {
131
+ if (!PROFILE_FILE_KEYS.has(key)) {
132
+ issues.push({ path: `${path}.${key}`, message: `unknown profile key "${key}" (allowed: default, commands)` });
133
+ continue;
134
+ }
135
+ if (key === "default") {
136
+ const route = checkRoute(value[key], `${path}.default`, issues);
137
+ if (route)
138
+ out.default = route;
139
+ }
140
+ else if (key === "commands") {
141
+ if (!isRecord(value[key])) {
142
+ issues.push({ path: `${path}.commands`, message: "commands must be an object mapping command names to routes" });
143
+ }
144
+ else {
145
+ const commands = {};
146
+ for (const [cmdName, raw] of Object.entries(value[key])) {
147
+ if (!COMMAND_NAME.test(cmdName)) {
148
+ issues.push({ path: `${path}.commands.${displayKey(cmdName)}`, message: "command name must be a single lowercase slug" });
149
+ continue;
150
+ }
151
+ const route = checkRoute(raw, `${path}.commands.${displayKey(cmdName)}`, issues);
152
+ if (route)
153
+ commands[cmdName] = route;
154
+ }
155
+ out.commands = commands;
156
+ }
157
+ }
158
+ }
159
+ return out;
160
+ }
44
161
  /** Strict validator for the optional `advance` conductor config (feature 62). */
45
162
  const ADVANCE_KEYS = new Set(["iterationsCap", "sensitivePaths", "securityPaths", "runLogPath"]);
46
163
  function checkAdvance(value, path, issues) {
@@ -214,7 +331,7 @@ function validateConfig(value) {
214
331
  if (!ROOT_KEYS.has(key)) {
215
332
  issues.push({
216
333
  path: `$.${displayKey(key)}`,
217
- message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance)`,
334
+ message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback)`,
218
335
  });
219
336
  }
220
337
  }
@@ -273,6 +390,45 @@ function validateConfig(value) {
273
390
  if (override)
274
391
  config.pathProtection = override;
275
392
  }
393
+ if (value.recommendedModels !== undefined) {
394
+ if (!isRecommendedModels(value.recommendedModels)) {
395
+ issues.push({
396
+ path: "$.recommendedModels",
397
+ message: `must be a boolean, got ${describe(value.recommendedModels)}`,
398
+ });
399
+ }
400
+ else {
401
+ config.recommendedModels = value.recommendedModels;
402
+ }
403
+ }
404
+ if (value.profiles !== undefined) {
405
+ if (!isRecord(value.profiles)) {
406
+ issues.push({ path: "$.profiles", message: "must be an object mapping profile names to profile definitions" });
407
+ }
408
+ else {
409
+ const profiles = {};
410
+ for (const [profileName, profileDef] of Object.entries(value.profiles)) {
411
+ if (!COMMAND_NAME.test(profileName)) {
412
+ issues.push({ path: `$.profiles.${displayKey(profileName)}`, message: "profile name must be a single lowercase slug" });
413
+ continue;
414
+ }
415
+ const profile = checkProfileFile(profileDef, `$.profiles.${displayKey(profileName)}`, issues);
416
+ if (profile)
417
+ profiles[profileName] = profile;
418
+ }
419
+ config.profiles = profiles;
420
+ }
421
+ }
422
+ if (value.profileOrder !== undefined) {
423
+ const order = checkProfileOrder(value.profileOrder, issues);
424
+ if (order)
425
+ config.profileOrder = order;
426
+ }
427
+ if (value.profileFallback !== undefined) {
428
+ const fb = checkProfileFallback(value.profileFallback, issues);
429
+ if (fb)
430
+ config.profileFallback = fb;
431
+ }
276
432
  return issues.length > 0 ? { ok: false, issues } : { ok: true, config };
277
433
  }
278
434
  /**