@mjasnikovs/pi-task 0.38.30 → 0.38.32

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 (73) hide show
  1. package/README.md +2 -1
  2. package/dist/config/config.d.ts +16 -2
  3. package/dist/config/config.js +7 -2
  4. package/dist/config/group-args.d.ts +52 -0
  5. package/dist/config/group-args.js +110 -0
  6. package/dist/config/group-models.d.ts +88 -0
  7. package/dist/config/group-models.js +117 -0
  8. package/dist/config/groups.d.ts +76 -0
  9. package/dist/config/groups.js +110 -0
  10. package/dist/config/option-picker.d.ts +42 -0
  11. package/dist/config/option-picker.js +73 -0
  12. package/dist/config/reasoning.d.ts +22 -63
  13. package/dist/config/reasoning.js +37 -108
  14. package/dist/config/register.d.ts +98 -12
  15. package/dist/config/register.js +228 -23
  16. package/dist/index.js +4 -0
  17. package/dist/remote/push.js +1 -7
  18. package/dist/shared/command-watchdog.d.ts +63 -0
  19. package/dist/shared/command-watchdog.js +87 -0
  20. package/dist/shared/data-home.d.ts +8 -0
  21. package/dist/shared/data-home.js +14 -0
  22. package/dist/shared/model-endpoint.d.ts +53 -0
  23. package/dist/shared/model-endpoint.js +98 -2
  24. package/dist/shared/reasoning-capability.d.ts +25 -5
  25. package/dist/shared/reasoning-capability.js +18 -9
  26. package/dist/task/auto-orchestrator.js +14 -3
  27. package/dist/task/child-runner.d.ts +92 -15
  28. package/dist/task/child-runner.js +303 -66
  29. package/dist/task/context-usage.d.ts +46 -0
  30. package/dist/task/context-usage.js +41 -0
  31. package/dist/task/failure-classifier.js +24 -1
  32. package/dist/task/gate-child.d.ts +15 -4
  33. package/dist/task/gate-child.js +2 -2
  34. package/dist/task/gate-deps.js +7 -2
  35. package/dist/task/implementation-guards.d.ts +26 -0
  36. package/dist/task/implementation-guards.js +177 -0
  37. package/dist/task/implementation-hold.d.ts +118 -0
  38. package/dist/task/implementation-hold.js +165 -0
  39. package/dist/task/implementation-turn.d.ts +5 -0
  40. package/dist/task/implementation-turn.js +12 -1
  41. package/dist/task/loop-detector.d.ts +18 -0
  42. package/dist/task/loop-detector.js +22 -2
  43. package/dist/task/model-hold-stash.d.ts +43 -0
  44. package/dist/task/model-hold-stash.js +70 -0
  45. package/dist/task/orchestrator.d.ts +18 -5
  46. package/dist/task/orchestrator.js +63 -6
  47. package/dist/task/phases.js +18 -5
  48. package/dist/task/research-worker.d.ts +2 -2
  49. package/dist/task/research-worker.js +1 -1
  50. package/dist/workers/docs-core.js +2 -2
  51. package/dist/workers/docs-lookup.d.ts +4 -3
  52. package/dist/workers/docs-lookup.js +1 -1
  53. package/dist/workers/fetch-core.js +2 -2
  54. package/dist/workers/focused-extractor.d.ts +6 -4
  55. package/dist/workers/focused-extractor.js +17 -5
  56. package/dist/workers/index.js +2 -0
  57. package/dist/workers/model-warning.d.ts +69 -0
  58. package/dist/workers/model-warning.js +113 -0
  59. package/dist/workers/pi-worker-core.d.ts +9 -38
  60. package/dist/workers/pi-worker-core.js +8 -86
  61. package/dist/workers/pi-worker-docs.js +2 -2
  62. package/dist/workers/pi-worker.js +4 -4
  63. package/dist/workers/reasoning-warning.d.ts +17 -9
  64. package/dist/workers/reasoning-warning.js +69 -22
  65. package/dist/workers/single-read-guard.d.ts +6 -6
  66. package/dist/workers/single-read-guard.js +8 -8
  67. package/dist/workers/worker-profiles.d.ts +11 -3
  68. package/dist/workers/worker-profiles.js +33 -1
  69. package/package.json +1 -1
  70. package/dist/config/reasoning-args.d.ts +0 -23
  71. package/dist/config/reasoning-args.js +0 -28
  72. package/dist/task/implementation-thinking.d.ts +0 -56
  73. package/dist/task/implementation-thinking.js +0 -32
package/README.md CHANGED
@@ -214,7 +214,8 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
214
214
  | **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
215
215
  | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
216
216
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
217
- | **reasoning** | default | How much the helper sessions think before answering. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. The seven **think:** rows below set one level per group of steps research workers, spec phases, /task-auto planning, /task-plan, the checking gates, the small extraction children, and the implementation turn itself. **default** uses the table pi-task has measured, **on** and **off** force one answer everywhere, and **custom** is whatever the think: rows say (changing any of them switches to custom). A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to which is what every step did before this setting existed, and is what every cell of the measured table currently holds. The think: rows always show what a step *actually* runs at, not what the stored table says. Three tiers of model exist and pi-task tells them apart: full levels (`reasoning_effort` in the chat template, e.g. Qwen3.8), on/off only (e.g. Qwen3.6, Gemma 4), and none at all. pi silently CLAMPS a level a model does not declare a level you set can be erased, and an `off` can be clamped back up to `medium`so a startup warning names the model and the steps it will not honour. It never fires while every step is on inherit. |
217
+ | **model: …** | all inherit | One row per group of steps, choosing which model that group's children run on research and planning on a strong model, the rest on a cheap one. Offered from `pi.modelRegistry.getAvailable()`, stored as the canonical `provider/id` that pi's own `--model` takes, and picked from a filterable list rather than cycled. **inherit** emits no flag at all, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. Choosing a model NARROWS that group's **think:** row to the levels it declares, and re-clamps a level already stored pi erases a level a model cannot do, silently, so a cell offering one would be a lie. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the cell. Two rows deserve care: a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **model: implementation** is not freeit is *your* session, switched for the turn and switched back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
218
+ | **reasoning** | default | How much the helper sessions think before answering. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. The eleven **think:** rows below set one level per group of steps — the four research workers and their shared fallback, spec phases, /task-auto planning, /task-plan, the checking gates, the small extraction children, and the implementation turn itself. **default** uses the table pi-task has measured, **on** and **off** force one answer everywhere, and **custom** is whatever the think: rows say (changing any of them switches to custom). A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to — which is what every step did before this setting existed, and is what every cell of the measured table currently holds. The think: rows always show what a step *actually* runs at, not what the stored table says. Three tiers of model exist and pi-task tells them apart: full levels (`reasoning_effort` in the chat template, e.g. Qwen3.8), on/off only (e.g. Qwen3.6, Gemma 4), and none at all. pi silently CLAMPS a level a model does not declare — a level you set can be erased, and an `off` can be clamped back up to `medium` — so a startup warning names each step, the model IT runs on, and the level that model will not honour. It never fires while every step is on inherit. |
218
219
  | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
219
220
  | **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
220
221
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
@@ -1,5 +1,5 @@
1
1
  import { type SearchProvider } from '../workers/search-types.js';
2
- import { type GroupSetting, type ReasoningGroup, type ReasoningMode } from './reasoning.js';
2
+ import { type GroupSetting, type ChildGroup, type ReasoningMode } from './reasoning.js';
3
3
  export interface PiTaskConfig {
4
4
  remote: boolean;
5
5
  autoCommit: boolean;
@@ -155,7 +155,21 @@ export interface PiTaskConfig {
155
155
  * survives the watchdog being turned off. Its sanitizer always returns a
156
156
  * complete record, so no consumer needs a per-key fallback.
157
157
  */
158
- reasoningLevels: Record<ReasoningGroup, GroupSetting>;
158
+ reasoningLevels: Record<ChildGroup, GroupSetting>;
159
+ /**
160
+ * The per-group MODEL, as a `provider/id` string, or `inherit` to emit no
161
+ * `--model` and let the child resolve pi's saved default as it always has.
162
+ *
163
+ * DEFAULT: every cell `inherit`, which makes every child's argv
164
+ * byte-identical to a build without this field. There is no mode enum and no
165
+ * shipped table — a model id is machine-local, so there is nothing to ship.
166
+ *
167
+ * Its sanitizer checks SHAPE, never existence: a spec naming a model this
168
+ * machine cannot resolve survives, because the alternative is deleting a
169
+ * setting the user made on their other machine. The once-per-session
170
+ * resolution check names it instead.
171
+ */
172
+ groupModels: Record<ChildGroup, string>;
159
173
  }
160
174
  /** How verbose the `.pi-tasks/*-debug.log` trail is. See {@link PiTaskConfig.debugLogs}. */
161
175
  export type DebugLogLevel = 'off' | 'events' | 'full';
@@ -4,6 +4,7 @@ import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import { isSearchProvider } from '../workers/search-types.js';
6
6
  import { DEFAULT_REASONING_TABLE, sanitizeReasoningLevels, sanitizeReasoningMode } from './reasoning.js';
7
+ import { DEFAULT_GROUP_MODELS, sanitizeGroupModels } from './group-models.js';
7
8
  import { DEFAULT_STREAM_INACTIVITY_MS } from '../shared/stream-watchdog.js';
8
9
  /**
9
10
  * The debug-log choices offered by /task-config, in cycle order (quietest →
@@ -108,7 +109,10 @@ export const DEFAULT_CONFIG = {
108
109
  // DEFAULT: the shipped per-group table. A cell left at `inherit` emits no
109
110
  // --thinking flag, so that child keeps the host's own level.
110
111
  reasoningMode: 'default',
111
- reasoningLevels: { ...DEFAULT_REASONING_TABLE }
112
+ reasoningLevels: { ...DEFAULT_REASONING_TABLE },
113
+ // INHERIT everywhere. Nothing is measured here and nothing can be: which
114
+ // models exist is a property of the machine, not of pi-task.
115
+ groupModels: { ...DEFAULT_GROUP_MODELS }
112
116
  };
113
117
  /**
114
118
  * A hand-edited config can hold anything; keep only string entries so a stray
@@ -154,7 +158,8 @@ export const CONFIG_LOADERS = {
154
158
  streamInactivityMs: sanitizeStreamInactivityMs,
155
159
  debugLogs: sanitizeDebugLogs,
156
160
  reasoningMode: sanitizeReasoningMode,
157
- reasoningLevels: sanitizeReasoningLevels
161
+ reasoningLevels: sanitizeReasoningLevels,
162
+ groupModels: sanitizeGroupModels
158
163
  };
159
164
  /**
160
165
  * Turn parsed config JSON into a `PiTaskConfig`. Pure, so every hostile-value
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The live-config bridge for per-group child settings: group in, argv fragment out.
3
+ *
4
+ * Separate from reasoning.ts and group-models.ts because those must take no
5
+ * import with a runtime side effect — see their headers. The `getConfig()` read
6
+ * lives here instead: this file imports them and nothing in config/ imports it
7
+ * back, so the graph stays a tree.
8
+ *
9
+ * Read PER CALL, never cached at module scope, so a /task-config change lands on
10
+ * the next child without a restart. Same contract `childBaseArgs` keeps.
11
+ */
12
+ import { type PiTaskConfig } from './config.js';
13
+ import type { ChildGroup } from './groups.js';
14
+ export declare function setUnusableSpecs(specs: Iterable<string>): void;
15
+ export declare function isSpecUsable(spec: string): boolean;
16
+ export declare function setGroupWindows(windows: Readonly<Partial<Record<ChildGroup, number>>>): void;
17
+ /** The group's own window, or `undefined` for "caller keeps its fallback". */
18
+ export declare function groupWindow(group: ChildGroup): number | undefined;
19
+ /**
20
+ * The `['--model', spec]` fragment for a group, or `[]` for `inherit` and for a
21
+ * spec this session proved unresolvable.
22
+ *
23
+ * Dropping the flag is not the same failure class as passing it. A spec naming a
24
+ * model that is gone, whose PROVIDER still has other models, does not make pi
25
+ * exit — `buildFallbackModel` invents a synthetic model id, forces
26
+ * `reasoning: true` onto it, inherits the provider's default baseUrl and answers
27
+ * at exit 0. Dropping the flag runs the same child the user got last week and
28
+ * says so out loud; passing it runs a model nobody chose and says nothing.
29
+ */
30
+ export declare function groupModelArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
31
+ /**
32
+ * The `['--thinking', level]` fragment for a group, or `[]` when the group is
33
+ * `inherit` and the child should keep falling back to settings.json.
34
+ *
35
+ * Still exported on its own: the host-session turn (implementation-hold.ts) and
36
+ * the settings UI (register.ts) need the level rather than a whole fragment.
37
+ */
38
+ export declare function groupThinkingArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
39
+ /**
40
+ * Everything one group contributes to a child's argv, in one fragment.
41
+ *
42
+ * Every argv builder calls THIS rather than composing the two halves itself.
43
+ * That is why there is one carried field (`groupArgs`) rather than a `model`
44
+ * beside a `thinking`: a second required field would double the obligation on
45
+ * every future wiring, and a fragment assembled by hand is a doubled
46
+ * `--thinking` waiting to happen.
47
+ *
48
+ * ORDER: `--model` then `--thinking`. pi's parser is a flat loop, so it does not
49
+ * care. A human diffing two runs does — the identity of the child first, then
50
+ * the dial on it.
51
+ */
52
+ export declare function groupChildArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The live-config bridge for per-group child settings: group in, argv fragment out.
3
+ *
4
+ * Separate from reasoning.ts and group-models.ts because those must take no
5
+ * import with a runtime side effect — see their headers. The `getConfig()` read
6
+ * lives here instead: this file imports them and nothing in config/ imports it
7
+ * back, so the graph stays a tree.
8
+ *
9
+ * Read PER CALL, never cached at module scope, so a /task-config change lands on
10
+ * the next child without a restart. Same contract `childBaseArgs` keeps.
11
+ */
12
+ import { getConfig } from './config.js';
13
+ import { MODEL_INHERIT, modelArgs } from './group-models.js';
14
+ import { resolveReasoning, thinkingArgs } from './reasoning.js';
15
+ /**
16
+ * Specs this session has proven a child cannot resolve.
17
+ *
18
+ * WHY A SESSION-SCOPED SET AND NOT A LOOKUP
19
+ * -----------------------------------------
20
+ * The honest question is "can a `--no-extensions` child resolve this spec?", and
21
+ * only `ctx.modelRegistry` can answer it. Five of the six argv producers have no
22
+ * `ctx` — `pi-worker`, `pi-worker-docs`, `docs-core`, `fetch-core` and
23
+ * `child-runner` — so the answer cannot be fetched where it is needed. It also
24
+ * cannot be read from disk: models.json and models-store.json are only part of
25
+ * the catalogue, since pi-ai ships built-in model lists for 39 providers, and
26
+ * this project does not depend on pi-ai.
27
+ *
28
+ * So it is answered ONCE, at session_start, where ctx exists and every task is
29
+ * still in the future, and the verdict is left here.
30
+ *
31
+ * EMPTY MEANS EMIT. A host that never fires session_start therefore behaves
32
+ * exactly as it does today — the failure direction is "pi decides", never "we
33
+ * silently dropped a flag nobody checked".
34
+ */
35
+ let unusableSpecs = new Set();
36
+ export function setUnusableSpecs(specs) {
37
+ unusableSpecs = new Set(specs);
38
+ }
39
+ export function isSpecUsable(spec) {
40
+ return !unusableSpecs.has(spec);
41
+ }
42
+ /**
43
+ * The context window of each group's model, resolved in the SAME session pass
44
+ * that filled {@link setUnusableSpecs}.
45
+ *
46
+ * It lives here for the same reason that set does — `child-runner` and the
47
+ * workers have no `ctx`, so they cannot ask a registry — and it is filled by the
48
+ * same walk, so the two can never disagree about which model a group runs on.
49
+ *
50
+ * The number drives `StallDetector`'s churn rule, where the two error directions
51
+ * are NOT symmetric: too large fires late (degraded, and the no-new-ground rule
52
+ * still covers it), too small fires early and KILLS A HEALTHY CHILD. So an
53
+ * absent answer means "use the parent's", never a guess.
54
+ */
55
+ let groupWindows = {};
56
+ export function setGroupWindows(windows) {
57
+ groupWindows = { ...windows };
58
+ }
59
+ /** The group's own window, or `undefined` for "caller keeps its fallback". */
60
+ export function groupWindow(group) {
61
+ const w = groupWindows[group];
62
+ return w !== undefined && w > 0 ? w : undefined;
63
+ }
64
+ /**
65
+ * The `['--model', spec]` fragment for a group, or `[]` for `inherit` and for a
66
+ * spec this session proved unresolvable.
67
+ *
68
+ * Dropping the flag is not the same failure class as passing it. A spec naming a
69
+ * model that is gone, whose PROVIDER still has other models, does not make pi
70
+ * exit — `buildFallbackModel` invents a synthetic model id, forces
71
+ * `reasoning: true` onto it, inherits the provider's default baseUrl and answers
72
+ * at exit 0. Dropping the flag runs the same child the user got last week and
73
+ * says so out loud; passing it runs a model nobody chose and says nothing.
74
+ */
75
+ export function groupModelArgs(group, cfg) {
76
+ const spec = (cfg ?? getConfig()).groupModels[group];
77
+ if (spec === undefined || spec === MODEL_INHERIT)
78
+ return [];
79
+ return isSpecUsable(spec) ? modelArgs(spec) : [];
80
+ }
81
+ /**
82
+ * The `['--thinking', level]` fragment for a group, or `[]` when the group is
83
+ * `inherit` and the child should keep falling back to settings.json.
84
+ *
85
+ * Still exported on its own: the host-session turn (implementation-hold.ts) and
86
+ * the settings UI (register.ts) need the level rather than a whole fragment.
87
+ */
88
+ export function groupThinkingArgs(group, cfg) {
89
+ // The default is evaluated HERE, per call. Hoisting the read to module scope
90
+ // would leave every test green, so the optional parameter is what makes the
91
+ // per-call contract assertable.
92
+ return thinkingArgs(resolveReasoning(group, cfg ?? getConfig()));
93
+ }
94
+ /**
95
+ * Everything one group contributes to a child's argv, in one fragment.
96
+ *
97
+ * Every argv builder calls THIS rather than composing the two halves itself.
98
+ * That is why there is one carried field (`groupArgs`) rather than a `model`
99
+ * beside a `thinking`: a second required field would double the obligation on
100
+ * every future wiring, and a fragment assembled by hand is a doubled
101
+ * `--thinking` waiting to happen.
102
+ *
103
+ * ORDER: `--model` then `--thinking`. pi's parser is a flat loop, so it does not
104
+ * care. A human diffing two runs does — the identity of the child first, then
105
+ * the dial on it.
106
+ */
107
+ export function groupChildArgs(group, cfg) {
108
+ const live = cfg ?? getConfig();
109
+ return [...groupModelArgs(group, live), ...groupThinkingArgs(group, live)];
110
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Which model each child group runs on — the second table keyed on ChildGroup.
3
+ *
4
+ * PURE MODULE, like reasoning.ts and groups.ts: config.ts imports this during
5
+ * its own evaluation, so nothing here may have a runtime side effect.
6
+ *
7
+ * WHY THERE IS NO MODE ENUM
8
+ * -------------------------
9
+ * reasoningMode has four modes because there is a measured, shipped table worth
10
+ * a one-word "use the project's numbers", plus meaningful global on/off. Neither
11
+ * exists here. A model id is machine-local, so there can be no shipped table,
12
+ * and "all models on" is not a sentence. A mode enum would be four states of
13
+ * which three are unreachable.
14
+ *
15
+ * WHY THE CELL IS A STRING AND NOT A STRUCT
16
+ * -----------------------------------------
17
+ * `provider/id` is exactly what pi's `--model` takes, so the stored value goes
18
+ * to argv untouched. The round-trip property in config-items.test.ts needs the
19
+ * stored value recoverable verbatim from the offered label; a struct forces a
20
+ * format/parse pair that can drift.
21
+ */
22
+ import { type ChildGroup } from './groups.js';
23
+ /**
24
+ * The cell value meaning "emit no `--model`". Identical in spirit to `inherit`
25
+ * in the reasoning table, and identical in effect: an all-`inherit` table makes
26
+ * every child's argv byte-identical to a build without this feature.
27
+ *
28
+ * It is not a legal `provider/id` — pi rejects an unslashed pattern that matches
29
+ * no model — so it cannot collide with a real spec.
30
+ */
31
+ export declare const MODEL_INHERIT = "inherit";
32
+ /** Every cell on `inherit`. The shipped default, and deliberately not a table. */
33
+ export declare const DEFAULT_GROUP_MODELS: Readonly<Record<ChildGroup, string>>;
34
+ /**
35
+ * SHAPE ONLY, never existence.
36
+ *
37
+ * A config written on machine A must survive a session on machine B whose
38
+ * provider extension failed to load, or whose models.json is a different file
39
+ * entirely. Erasing a spec because this machine cannot resolve it would punish
40
+ * the two-machine user and lose a setting they never changed. Whether a spec
41
+ * resolves is asked once per session, where a warning can name it — not here,
42
+ * where the only available answer is deletion.
43
+ *
44
+ * So this rejects exactly what could RESHAPE argv, and nothing else:
45
+ * - a non-string, which cannot be an argv token at all
46
+ * - empty or whitespace-only, which pi would read as a missing value and
47
+ * consume the following flag as the model pattern
48
+ * - a leading `-`, which pi's flat parser reads as the next FLAG
49
+ * - embedded whitespace, which is one token here and two on a shell round-trip
50
+ */
51
+ export declare function isModelSpec(value: unknown): value is string;
52
+ export declare function sanitizeGroupModels(value: unknown): Record<ChildGroup, string>;
53
+ /**
54
+ * The `['--model', spec]` fragment for a resolved cell, or `[]` for `inherit`.
55
+ *
56
+ * NEVER `--provider`. Adding it is what turns pi's loud "model not found, exit
57
+ * 1" into `buildFallbackModel`'s silent synthetic model at exit 0 — the branch
58
+ * is `if (provider)` in pi's model-resolver, and a canonical `provider/id`
59
+ * reaches it through inference only when that provider already has a model the
60
+ * child can see. One flag keeps the loud failure loud.
61
+ *
62
+ * No `:thinking` suffix either. We emit an explicit `--thinking`, which pi
63
+ * prefers over the suffix, so a suffix would be a second dead source of truth
64
+ * for the same dial.
65
+ */
66
+ export declare function modelArgs(spec: string): string[];
67
+ /**
68
+ * The spec a built argv fragment actually carries, or `undefined` for a child
69
+ * that will resolve pi's saved default.
70
+ *
71
+ * Read BACK OUT of the argv rather than threaded alongside it, deliberately. The
72
+ * argv is what the child runs; a second field carrying "the model we meant"
73
+ * could disagree with it after any future edit, and the one consumer — the
74
+ * dead-backend probe — must ask about the endpoint the child will really use.
75
+ */
76
+ export declare function modelSpecFromArgs(args: readonly string[]): string | undefined;
77
+ /**
78
+ * Split a stored spec on its FIRST slash.
79
+ *
80
+ * `openrouter/z-ai/glm-4.6` is provider `openrouter` and id `z-ai/glm-4.6`:
81
+ * OpenRouter-style ids contain slashes of their own, and splitting on the last
82
+ * one would invent the provider `openrouter/z-ai`. Returns `undefined` for a
83
+ * spec with no slash, which `inherit` is and a canonical spec never is.
84
+ */
85
+ export declare function splitSpec(spec: string): {
86
+ provider: string;
87
+ id: string;
88
+ } | undefined;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Which model each child group runs on — the second table keyed on ChildGroup.
3
+ *
4
+ * PURE MODULE, like reasoning.ts and groups.ts: config.ts imports this during
5
+ * its own evaluation, so nothing here may have a runtime side effect.
6
+ *
7
+ * WHY THERE IS NO MODE ENUM
8
+ * -------------------------
9
+ * reasoningMode has four modes because there is a measured, shipped table worth
10
+ * a one-word "use the project's numbers", plus meaningful global on/off. Neither
11
+ * exists here. A model id is machine-local, so there can be no shipped table,
12
+ * and "all models on" is not a sentence. A mode enum would be four states of
13
+ * which three are unreachable.
14
+ *
15
+ * WHY THE CELL IS A STRING AND NOT A STRUCT
16
+ * -----------------------------------------
17
+ * `provider/id` is exactly what pi's `--model` takes, so the stored value goes
18
+ * to argv untouched. The round-trip property in config-items.test.ts needs the
19
+ * stored value recoverable verbatim from the offered label; a struct forces a
20
+ * format/parse pair that can drift.
21
+ */
22
+ import { sanitizeGroupRecord } from './groups.js';
23
+ /**
24
+ * The cell value meaning "emit no `--model`". Identical in spirit to `inherit`
25
+ * in the reasoning table, and identical in effect: an all-`inherit` table makes
26
+ * every child's argv byte-identical to a build without this feature.
27
+ *
28
+ * It is not a legal `provider/id` — pi rejects an unslashed pattern that matches
29
+ * no model — so it cannot collide with a real spec.
30
+ */
31
+ export const MODEL_INHERIT = 'inherit';
32
+ /** Every cell on `inherit`. The shipped default, and deliberately not a table. */
33
+ export const DEFAULT_GROUP_MODELS = {
34
+ research: MODEL_INHERIT,
35
+ 'research:files': MODEL_INHERIT,
36
+ 'research:apis': MODEL_INHERIT,
37
+ 'research:context': MODEL_INHERIT,
38
+ 'research:tooling': MODEL_INHERIT,
39
+ phase: MODEL_INHERIT,
40
+ planning: MODEL_INHERIT,
41
+ plan: MODEL_INHERIT,
42
+ gate: MODEL_INHERIT,
43
+ extraction: MODEL_INHERIT,
44
+ implementation: MODEL_INHERIT
45
+ };
46
+ /**
47
+ * SHAPE ONLY, never existence.
48
+ *
49
+ * A config written on machine A must survive a session on machine B whose
50
+ * provider extension failed to load, or whose models.json is a different file
51
+ * entirely. Erasing a spec because this machine cannot resolve it would punish
52
+ * the two-machine user and lose a setting they never changed. Whether a spec
53
+ * resolves is asked once per session, where a warning can name it — not here,
54
+ * where the only available answer is deletion.
55
+ *
56
+ * So this rejects exactly what could RESHAPE argv, and nothing else:
57
+ * - a non-string, which cannot be an argv token at all
58
+ * - empty or whitespace-only, which pi would read as a missing value and
59
+ * consume the following flag as the model pattern
60
+ * - a leading `-`, which pi's flat parser reads as the next FLAG
61
+ * - embedded whitespace, which is one token here and two on a shell round-trip
62
+ */
63
+ export function isModelSpec(value) {
64
+ if (typeof value !== 'string')
65
+ return false;
66
+ if (value.trim() !== value || value.length === 0)
67
+ return false;
68
+ if (value.startsWith('-'))
69
+ return false;
70
+ return !/\s/.test(value);
71
+ }
72
+ export function sanitizeGroupModels(value) {
73
+ return sanitizeGroupRecord(value, isModelSpec, () => MODEL_INHERIT);
74
+ }
75
+ /**
76
+ * The `['--model', spec]` fragment for a resolved cell, or `[]` for `inherit`.
77
+ *
78
+ * NEVER `--provider`. Adding it is what turns pi's loud "model not found, exit
79
+ * 1" into `buildFallbackModel`'s silent synthetic model at exit 0 — the branch
80
+ * is `if (provider)` in pi's model-resolver, and a canonical `provider/id`
81
+ * reaches it through inference only when that provider already has a model the
82
+ * child can see. One flag keeps the loud failure loud.
83
+ *
84
+ * No `:thinking` suffix either. We emit an explicit `--thinking`, which pi
85
+ * prefers over the suffix, so a suffix would be a second dead source of truth
86
+ * for the same dial.
87
+ */
88
+ export function modelArgs(spec) {
89
+ return spec === MODEL_INHERIT ? [] : ['--model', spec];
90
+ }
91
+ /**
92
+ * The spec a built argv fragment actually carries, or `undefined` for a child
93
+ * that will resolve pi's saved default.
94
+ *
95
+ * Read BACK OUT of the argv rather than threaded alongside it, deliberately. The
96
+ * argv is what the child runs; a second field carrying "the model we meant"
97
+ * could disagree with it after any future edit, and the one consumer — the
98
+ * dead-backend probe — must ask about the endpoint the child will really use.
99
+ */
100
+ export function modelSpecFromArgs(args) {
101
+ const i = args.indexOf('--model');
102
+ return i === -1 ? undefined : args[i + 1];
103
+ }
104
+ /**
105
+ * Split a stored spec on its FIRST slash.
106
+ *
107
+ * `openrouter/z-ai/glm-4.6` is provider `openrouter` and id `z-ai/glm-4.6`:
108
+ * OpenRouter-style ids contain slashes of their own, and splitting on the last
109
+ * one would invent the provider `openrouter/z-ai`. Returns `undefined` for a
110
+ * spec with no slash, which `inherit` is and a canonical spec never is.
111
+ */
112
+ export function splitSpec(spec) {
113
+ const i = spec.indexOf('/');
114
+ if (i <= 0 || i === spec.length - 1)
115
+ return undefined;
116
+ return { provider: spec.slice(0, i), id: spec.slice(i + 1) };
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The child roster — which children exist, and which of them share one setting.
3
+ *
4
+ * Split out of reasoning.ts because the roster is not about reasoning. Two
5
+ * tables are keyed on it now (thinking level, and model), and a third would be
6
+ * no more surprising. A roster named after one of its consumers reads as though
7
+ * adding a second consumer were a special case.
8
+ *
9
+ * PURE MODULE, for the same reason reasoning.ts is: config.ts imports both
10
+ * during its own module evaluation, so neither may take an import with a runtime
11
+ * side effect.
12
+ *
13
+ * Grouped by JOB, not by spawn mechanism — two children that both go through
14
+ * `runWorker` (a research worker and a verify gate) want different amounts of
15
+ * thinking, while `refine` in phases.ts and `compress-label` in title-label.ts
16
+ * want the same amount and share the `phase` cell.
17
+ *
18
+ * - `research` the ad-hoc `pi-worker` subagent tool, and the fallback the four
19
+ * research workers use when their own cell is unset
20
+ * - `research:files` / `research:apis` / `research:context` / `research:tooling`
21
+ * one cell per research worker, so a level can be paid for in
22
+ * one worker without paying for it in the other three
23
+ * - `phase` refine, verify-tooling, grill, compose, critique, compress-label
24
+ * - `planning` /task-auto's planning children — clarify, decompose, and the
25
+ * extract/coverage passes around them
26
+ * - `plan` /task-plan's question and answer children
27
+ * - `gate` enforce, verify, lint-fix, final-fix, recommend
28
+ * - `extraction` the --no-tools focused docs/fetch extractors
29
+ * - `implementation` the host-session turn that writes the code (not a child)
30
+ */
31
+ export type ChildGroup = 'research' | 'research:files' | 'research:apis' | 'research:context' | 'research:tooling' | 'phase' | 'planning' | 'plan' | 'gate' | 'extraction' | 'implementation';
32
+ export declare const CHILD_GROUPS: readonly ChildGroup[];
33
+ /**
34
+ * Which group each NAMED child belongs to.
35
+ *
36
+ * The name is the one `runChild` is called with, so this is exhaustive over the
37
+ * children that carry one. `config/groups.test.ts` scans the source for those
38
+ * call sites and fails if a name is missing here. A defaulting lookup would let a
39
+ * phase added later opt itself out of the tables without anyone deciding to.
40
+ *
41
+ * The gate and extraction groups are NOT here: those children reach the model
42
+ * through `groupChildArgs('gate' | 'extraction')` at call sites with no name in
43
+ * scope (gate-deps.ts, fetch-core.ts, docs-core.ts, pi-worker-docs.ts). The four
44
+ * research workers do have a name — their `spec.label` — so they are here.
45
+ */
46
+ export declare const GROUP_BY_CHILD: Readonly<Record<string, ChildGroup>>;
47
+ /**
48
+ * The group a named child belongs to.
49
+ *
50
+ * Returns `undefined` for a name the table does not know, and the CALLER decides
51
+ * what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
52
+ * the model with today's argv is always safe — while the test treats it as a
53
+ * failure. That split is deliberate: the guard belongs at build time, where
54
+ * someone can fix it, not at run time, where it would abort a user's task over a
55
+ * missing table row.
56
+ */
57
+ export declare function groupForChild(name: string): ChildGroup | undefined;
58
+ /**
59
+ * For a `research:*` group, the group a stored config falls back to when its own
60
+ * key is missing. Every other group maps to `undefined`.
61
+ */
62
+ export declare const RESEARCH_SUBGROUP_PARENT: Readonly<Partial<Record<ChildGroup, ChildGroup>>>;
63
+ /**
64
+ * Always returns a COMPLETE record, never a partial one, for any per-group table.
65
+ *
66
+ * A hand-edited file missing a group, or one carrying a group from a future
67
+ * version, must not reach a resolver as a hole: `table[group]` would be
68
+ * `undefined`, and every call site would need its own fallback. Filling the gaps
69
+ * here means the type is true at the only place that constructs the value.
70
+ *
71
+ * `fallbackFor` is a function of the GROUP, not a value. The reasoning table
72
+ * falls back per group to its measured default; the model table falls back to
73
+ * the constant `inherit`. A scalar parameter cannot express the first, which is
74
+ * the one that already ships.
75
+ */
76
+ export declare function sanitizeGroupRecord<T>(value: unknown, valid: (v: unknown) => v is T, fallbackFor: (group: ChildGroup) => T): Record<ChildGroup, T>;
@@ -0,0 +1,110 @@
1
+ export const CHILD_GROUPS = [
2
+ 'research',
3
+ 'research:files',
4
+ 'research:apis',
5
+ 'research:context',
6
+ 'research:tooling',
7
+ 'phase',
8
+ 'planning',
9
+ 'plan',
10
+ 'gate',
11
+ 'extraction',
12
+ 'implementation'
13
+ ];
14
+ /**
15
+ * Which group each NAMED child belongs to.
16
+ *
17
+ * The name is the one `runChild` is called with, so this is exhaustive over the
18
+ * children that carry one. `config/groups.test.ts` scans the source for those
19
+ * call sites and fails if a name is missing here. A defaulting lookup would let a
20
+ * phase added later opt itself out of the tables without anyone deciding to.
21
+ *
22
+ * The gate and extraction groups are NOT here: those children reach the model
23
+ * through `groupChildArgs('gate' | 'extraction')` at call sites with no name in
24
+ * scope (gate-deps.ts, fetch-core.ts, docs-core.ts, pi-worker-docs.ts). The four
25
+ * research workers do have a name — their `spec.label` — so they are here.
26
+ */
27
+ export const GROUP_BY_CHILD = {
28
+ // ── phase: task/phases.ts + task/title-label.ts ──────────────────────────
29
+ refine: 'phase',
30
+ 'verify-tooling': 'phase',
31
+ 'grill-auto': 'phase',
32
+ 'grill-gen': 'phase',
33
+ compose: 'phase',
34
+ critique: 'phase',
35
+ 'critique-triage': 'phase',
36
+ 'compress-label': 'phase',
37
+ // ── planning: task/auto-orchestrator.ts ──────────────────────────────────
38
+ 'clarify-triage': 'planning',
39
+ 'auto-clarify': 'planning',
40
+ 'auto-decompose': 'planning',
41
+ 'requirement-extract': 'planning',
42
+ 'decompose-coverage': 'planning',
43
+ 'coverage-map': 'planning',
44
+ 'contract-extract': 'planning',
45
+ 'launch-extract': 'planning',
46
+ // ── plan: task/plan-orchestrator.ts ──────────────────────────────────────
47
+ 'plan-question': 'plan',
48
+ 'plan-answer': 'plan',
49
+ // ── research: task/phases.ts `workerSpecs`, keyed on the spec's LABEL ─────
50
+ 'worker:files': 'research:files',
51
+ 'worker:apis': 'research:apis',
52
+ 'worker:context': 'research:context',
53
+ 'worker:tooling': 'research:tooling'
54
+ };
55
+ /**
56
+ * The group a named child belongs to.
57
+ *
58
+ * Returns `undefined` for a name the table does not know, and the CALLER decides
59
+ * what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
60
+ * the model with today's argv is always safe — while the test treats it as a
61
+ * failure. That split is deliberate: the guard belongs at build time, where
62
+ * someone can fix it, not at run time, where it would abort a user's task over a
63
+ * missing table row.
64
+ */
65
+ export function groupForChild(name) {
66
+ return GROUP_BY_CHILD[name];
67
+ }
68
+ /**
69
+ * For a `research:*` group, the group a stored config falls back to when its own
70
+ * key is missing. Every other group maps to `undefined`.
71
+ */
72
+ export const RESEARCH_SUBGROUP_PARENT = {
73
+ 'research:files': 'research',
74
+ 'research:apis': 'research',
75
+ 'research:context': 'research',
76
+ 'research:tooling': 'research'
77
+ };
78
+ /**
79
+ * Always returns a COMPLETE record, never a partial one, for any per-group table.
80
+ *
81
+ * A hand-edited file missing a group, or one carrying a group from a future
82
+ * version, must not reach a resolver as a hole: `table[group]` would be
83
+ * `undefined`, and every call site would need its own fallback. Filling the gaps
84
+ * here means the type is true at the only place that constructs the value.
85
+ *
86
+ * `fallbackFor` is a function of the GROUP, not a value. The reasoning table
87
+ * falls back per group to its measured default; the model table falls back to
88
+ * the constant `inherit`. A scalar parameter cannot express the first, which is
89
+ * the one that already ships.
90
+ */
91
+ export function sanitizeGroupRecord(value, valid, fallbackFor) {
92
+ const stored = typeof value === 'object' && value !== null && !Array.isArray(value) ?
93
+ value
94
+ : {};
95
+ const out = {};
96
+ for (const group of CHILD_GROUPS) {
97
+ const own = stored[group];
98
+ if (valid(own)) {
99
+ out[group] = own;
100
+ continue;
101
+ }
102
+ // A `research:*` key the stored config never had falls back to its
103
+ // parent `research` value, so a config written before the split keeps
104
+ // meaning what it meant.
105
+ const parent = RESEARCH_SUBGROUP_PARENT[group];
106
+ const inherited = parent === undefined ? undefined : stored[parent];
107
+ out[group] = valid(inherited) ? inherited : fallbackFor(group);
108
+ }
109
+ return out;
110
+ }