@aragon-agent/cli 0.6.39 → 0.6.42

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 (83) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +126 -20
  3. package/dist/agent/controller.d.ts +139 -14
  4. package/dist/agent/controller.js +299 -40
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +29 -1
  9. package/dist/agent/reducer.js +34 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +33 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +28 -185
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +30 -0
  18. package/dist/commands/subagent-command.js +39 -0
  19. package/dist/commands/team-command.d.ts +49 -0
  20. package/dist/commands/team-command.js +259 -0
  21. package/dist/compaction/command.js +16 -3
  22. package/dist/compaction/compactor.d.ts +25 -0
  23. package/dist/compaction/compactor.js +111 -10
  24. package/dist/compaction/digest.d.ts +24 -1
  25. package/dist/compaction/digest.js +139 -17
  26. package/dist/compaction/limits.d.ts +21 -0
  27. package/dist/compaction/limits.js +21 -0
  28. package/dist/compaction/reason-text.d.ts +36 -0
  29. package/dist/compaction/reason-text.js +78 -0
  30. package/dist/compaction/types.d.ts +82 -14
  31. package/dist/compaction/wiring.d.ts +13 -8
  32. package/dist/compaction/wiring.js +1 -0
  33. package/dist/config/load.js +50 -3
  34. package/dist/config/model-kits.d.ts +2 -1
  35. package/dist/config/schema.d.ts +82 -6
  36. package/dist/config/schema.js +107 -9
  37. package/dist/config/store.js +10 -4
  38. package/dist/proc/log-mirror.d.ts +21 -4
  39. package/dist/proc/log-mirror.js +29 -7
  40. package/dist/skills/service.d.ts +41 -0
  41. package/dist/skills/service.js +62 -0
  42. package/dist/skills/triage.d.ts +194 -0
  43. package/dist/skills/triage.js +393 -0
  44. package/dist/team/dispatch-gate.d.ts +86 -0
  45. package/dist/team/dispatch-gate.js +75 -0
  46. package/dist/team/forced-dispatch.d.ts +95 -0
  47. package/dist/team/forced-dispatch.js +208 -0
  48. package/dist/team/forced-mode.d.ts +116 -0
  49. package/dist/team/forced-mode.js +156 -0
  50. package/dist/team/forced-prompt.d.ts +48 -0
  51. package/dist/team/forced-prompt.js +84 -0
  52. package/dist/team/limits.d.ts +61 -1
  53. package/dist/team/limits.js +69 -1
  54. package/dist/team/model.d.ts +5 -5
  55. package/dist/team/model.js +5 -5
  56. package/dist/team/normalize.d.ts +15 -0
  57. package/dist/team/normalize.js +21 -4
  58. package/dist/team/prompt.d.ts +6 -3
  59. package/dist/team/prompt.js +9 -5
  60. package/dist/team/task-tool.js +11 -7
  61. package/dist/tools/human-input.d.ts +35 -2
  62. package/dist/tools/human-input.js +38 -0
  63. package/dist/tools/plan-tools.d.ts +20 -12
  64. package/dist/tools/plan-tools.js +24 -17
  65. package/dist/ui/App.js +130 -11
  66. package/dist/ui/StatusBar.d.ts +15 -0
  67. package/dist/ui/StatusBar.js +2 -0
  68. package/dist/ui/Transcript.js +1 -1
  69. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  70. package/dist/ui/entries/CompactionCard.js +65 -4
  71. package/dist/ui/layout/status-layout.d.ts +27 -0
  72. package/dist/ui/layout/status-layout.js +27 -1
  73. package/dist/ui/overlays/HelpOverlay.js +8 -4
  74. package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
  75. package/dist/ui/overlays/SettingsScreen.js +39 -5
  76. package/dist/ui/overlays/TeamFanoutPage.d.ts +51 -0
  77. package/dist/ui/overlays/TeamFanoutPage.js +90 -0
  78. package/dist/ui/overlays/kit-settings-state.js +3 -2
  79. package/dist/ui/overlays/model-profile-state.d.ts +20 -2
  80. package/dist/ui/overlays/model-profile-state.js +12 -0
  81. package/dist/ui/overlays/team-fanout-state.d.ts +106 -0
  82. package/dist/ui/overlays/team-fanout-state.js +155 -0
  83. package/package.json +81 -81
@@ -27,6 +27,8 @@ import { canProbe, editorProbeTarget, visibleProbeViews } from './model-probe-vi
27
27
  import { createModelProfileState, createCustomProfile, modelProfileReducer, profileUsers, draftTeam, draftSubagentProfileId, subagentModelSummary, subagentSelectedId, SUBAGENT_FAST_ROW_ID } from './model-profile-state.js';
28
28
  import { KitEditor } from './KitEditor.js';
29
29
  import { KitManager } from './KitManager.js';
30
+ import { TeamFanoutPage } from './TeamFanoutPage.js';
31
+ import { fanoutRows, teamFanoutValues, teamSummary } from './team-fanout-state.js';
30
32
  import { heldSlots, kitManagerRows, kitPackRow, kitReducer } from './kit-settings-state.js';
31
33
  /**
32
34
  * THE ROOT PAGE'S ROW INDEXES, AS NAMES (subagent-model V-5).
@@ -40,10 +42,15 @@ import { heldSlots, kitManagerRows, kitPackRow, kitReducer } from './kit-setting
40
42
  * fifth of these and the "five offsets agreed by accident" defect does not need
41
43
  * a sequel. Order here IS the render order.
42
44
  */
43
- const ROOT_ROWS = ['main', 'fast', 'subagent', 'kit', 'manage'];
45
+ const ROOT_ROWS = ['main', 'fast', 'subagent', 'team', 'kit', 'manage'];
44
46
  const ROOT_MAIN = ROOT_ROWS.indexOf('main');
45
47
  const ROOT_FAST = ROOT_ROWS.indexOf('fast');
46
48
  const ROOT_SUBAGENT = ROOT_ROWS.indexOf('subagent');
49
+ // The fan-out page (subagent-fanout D-5). It is FOURTH, directly under the model
50
+ // row it complements: "which model children run on" and "how many children" are
51
+ // one decision, and splitting them across the page would make the pair harder to
52
+ // keep consistent than the pair of numbers inside it.
53
+ const ROOT_TEAM = ROOT_ROWS.indexOf('team');
47
54
  const ROOT_KIT = ROOT_ROWS.indexOf('kit');
48
55
  const ROOT_MANAGE = ROOT_ROWS.indexOf('manage');
49
56
  const ROOT_FIRST_FIELD = ROOT_ROWS.length;
@@ -583,9 +590,9 @@ function useProfileSettingsState(props) {
583
590
  && ['provider', 'model', 'baseUrl', 'contextWindow', 'apiKey'].includes(item.key)),
584
591
  ...FIELDS.filter((item) => library.fastId === null
585
592
  && ['fastModel', 'fastProvider'].includes(item.key)), ...PROFILE_FIELDS];
586
- // SEVEN FIXED ROWS now (Main, Fast, SUBAGENT, Kit pack, Manage, Save, Reload)
587
- // around the `fields` block - derived from ROOT_ROWS so the count and the
588
- // offsets cannot drift apart (see those constants).
593
+ // EIGHT FIXED ROWS now (Main, Fast, Subagent model, Subagent fan-out, Kit pack,
594
+ // Manage, Save, Reload) around the `fields` block - derived from ROOT_ROWS so
595
+ // the count and the offsets cannot drift apart (see those constants).
589
596
  const count = fields.length + ROOT_ROWS.length + 2;
590
597
  const field = fields[index - ROOT_FIRST_FIELD];
591
598
  const readError = state.draft.readError;
@@ -679,8 +686,15 @@ function activateRootSetting(props, view) {
679
686
  // `draft.patch.team` rather than a `${role}Id` library key.
680
687
  dispatch({ type: 'page', page: 'picker', role: 'subagent' });
681
688
  }
689
+ else if (index === ROOT_TEAM) {
690
+ // MUST STAY ABOVE THE FALLBACK `else` BELOW, WHICH IS `saveProfileSettings`
691
+ // (R-P1-8). A missing or mis-derived constant here does not fail to compile:
692
+ // pressing Enter on this row would silently COMMIT the whole draft, so
693
+ // `team-fanout-page.test.tsx` asserts that Enter on this row saves nothing.
694
+ dispatch({ type: 'page', page: 'team' });
695
+ }
682
696
  else if (index === ROOT_KIT) {
683
- // The fourth root row opens the manager, not a picker: kits are data the user
697
+ // The fifth root row opens the manager, not a picker: kits are data the user
684
698
  // owns here, and `ctrl+K` is where they are CONSUMED (agent-model-kits §4.5).
685
699
  dispatch({ type: 'page', page: 'kits' });
686
700
  }
@@ -836,6 +850,14 @@ function fastTierUnavailable(props, view) {
836
850
  }
837
851
  function renderProfileSubpage(props, view) {
838
852
  const { state, library, dispatch, setConfirmIndex } = view;
853
+ // THE FAN-OUT PAGE (subagent-fanout 3.4.4). Placed before the picker branch
854
+ // because that branch is the unconditional fallback; the page name is unique,
855
+ // so its position among the others is arbitrary.
856
+ if (state.page === 'team')
857
+ return _jsx(TeamFanoutPage, { ...props, view: {
858
+ rows: fanoutRows(teamFanoutValues(state.draft), props.teamRegistered),
859
+ error: state.error,
860
+ }, dispatch: dispatch, onBack: () => dispatch({ type: 'page', page: 'root' }) });
839
861
  // THE KIT PAGES (agent-model-kits §4.5). They are dispatched BEFORE the profile
840
862
  // pages because `'kit-slot'` RENDERS a profile picker while belonging to the
841
863
  // kit editor - the page name, not the component, decides where the user is.
@@ -945,9 +967,21 @@ function buildRootSettingsRows(props, view) {
945
967
  profiles: state.draft.profiles,
946
968
  hasKey: (id) => (props.apiKeys[id] ?? '').length > 0 })}${kitTag}`
947
969
  : subagentLine;
970
+ // THE TEAM SUMMARY (subagent-fanout 3.4.4, renamed by
971
+ // team-command-forced-dispatch §3.9): a DERIVED row, like the three above it,
972
+ // and deliberately not a control - the values are adjusted on the page Enter
973
+ // opens, so the root row cannot change one by accident. It now summarises the
974
+ // WHOLE section, switch included, because `Team mode` is one of those values
975
+ // and a summary that always printed numbers would describe a capability the
976
+ // user may have just switched off.
977
+ const summary = teamSummary(teamFanoutValues(state.draft));
948
978
  const lines = [`Main profile ${mainValue}`,
949
979
  `Fast profile ${fastValue}`,
950
980
  `Subagent model ${subagentValue}`,
981
+ // `Team`, not `Subagent fan-out`: the row heads a page that now owns
982
+ // four values, only two of which are about fan-out width. `padEnd(18)` is
983
+ // unchanged, so the value column still lines up with `Subagent model`.
984
+ `Team ${summary}`,
951
985
  `Kit pack ${kitPackRow(state)}`,
952
986
  `Manage profiles ${library.entries.length} saved`,
953
987
  ...fields.map((item) => `${item.label.padEnd(18)}${item.key === 'apiKey'
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Settings > Team: four arrows-adjustable rows (subagent-fanout 3.4.3, extended
3
+ * by team-command-forced-dispatch §3.9).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * THE PAGE OWNS ONE PIECE OF STATE: its cursor. Every value comes from
8
+ * `props.view.rows`, which `SettingsScreen` derives from the reducer state - so
9
+ * this page cannot hold a second copy of the draft and then disagree with the
10
+ * root page about what is about to be saved (the discipline
11
+ * `model-profile-state.ts` records at its own head).
12
+ *
13
+ * FOUR ROWS, NOT THREE: `Team mode` moved here from `/team on|off`, because a
14
+ * command that both configures and executes leaves the user unable to predict
15
+ * what it did. The switch is the FIRST row - the other three describe a capability
16
+ * it turns on.
17
+ *
18
+ * IT DOES NOT LISTEN FOR Ctrl+S, AND THE FOOTER SAYS SO (R-P1-13).
19
+ * `useRootSettingsInput` gates its `useInput` on `state.page === 'root'`, so a
20
+ * subpage has no save listener at all - the kit page included. A footer
21
+ * promising "Ctrl+S saves here" would therefore be a lie the user acts on. The
22
+ * honest instruction is the one already in their fingers: Esc back, Ctrl+S on the
23
+ * main page.
24
+ */
25
+ import React from 'react';
26
+ import { type ProfileFrameProps } from './ModelProfilePicker.js';
27
+ import type { TeamFanoutField, TeamFanoutView } from './team-fanout-state.js';
28
+ /** What the page may ask the screen to change. */
29
+ export interface TeamFanoutAction {
30
+ type: 'team-fanout';
31
+ field: TeamFanoutField;
32
+ delta: -1 | 1;
33
+ }
34
+ interface TeamFanoutPageProps extends ProfileFrameProps {
35
+ view: TeamFanoutView;
36
+ dispatch: (action: TeamFanoutAction) => void;
37
+ /** Esc: back to the settings root. The draft is NOT discarded here. */
38
+ onBack: () => void;
39
+ /**
40
+ * Whether `task` is in this SESSION's tool list (`--no-team` is the false case).
41
+ *
42
+ * OPTIONAL, like every other pass-through on this screen: fixtures build
43
+ * `ProfileFrameProps` by literal, and "absent" honestly means "not disclosed"
44
+ * rather than "registered". When it is `false` and the draft says `on`, the
45
+ * `Team mode` row reads `on (next launch)` - the tool array is immutable, so
46
+ * that is the truth about what saving will achieve (I-7 / AC-9).
47
+ */
48
+ teamRegistered?: boolean;
49
+ }
50
+ export declare function TeamFanoutPage(props: TeamFanoutPageProps): React.ReactElement;
51
+ export {};
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Settings > Team: four arrows-adjustable rows (subagent-fanout 3.4.3, extended
4
+ * by team-command-forced-dispatch §3.9).
5
+ *
6
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
7
+ *
8
+ * THE PAGE OWNS ONE PIECE OF STATE: its cursor. Every value comes from
9
+ * `props.view.rows`, which `SettingsScreen` derives from the reducer state - so
10
+ * this page cannot hold a second copy of the draft and then disagree with the
11
+ * root page about what is about to be saved (the discipline
12
+ * `model-profile-state.ts` records at its own head).
13
+ *
14
+ * FOUR ROWS, NOT THREE: `Team mode` moved here from `/team on|off`, because a
15
+ * command that both configures and executes leaves the user unable to predict
16
+ * what it did. The switch is the FIRST row - the other three describe a capability
17
+ * it turns on.
18
+ *
19
+ * IT DOES NOT LISTEN FOR Ctrl+S, AND THE FOOTER SAYS SO (R-P1-13).
20
+ * `useRootSettingsInput` gates its `useInput` on `state.page === 'root'`, so a
21
+ * subpage has no save listener at all - the kit page included. A footer
22
+ * promising "Ctrl+S saves here" would therefore be a lie the user acts on. The
23
+ * honest instruction is the one already in their fingers: Esc back, Ctrl+S on the
24
+ * main page.
25
+ */
26
+ import { useState } from 'react';
27
+ import { Text, useInput } from 'ink';
28
+ import { pickGlyphs } from '../glyphs.js';
29
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
30
+ import { clipProfileText, useProfileScroll } from './ModelProfilePicker.js';
31
+ /**
32
+ * Label column, matching the root page's `padEnd(18)` rows above and below this
33
+ * one. `Team mode` is 9 characters and `Max subagents` is 13, so 18 keeps the
34
+ * value column aligned with `Subagent model` on the root page - measured, not
35
+ * guessed.
36
+ */
37
+ const LABEL_PAD = 18;
38
+ /**
39
+ * The two rows `Enter` toggles, and nothing else (R-P1-12).
40
+ *
41
+ * A stray Return that silently changed a NUMBER is indistinguishable from a bug,
42
+ * and the arrow keys are already the obvious control there.
43
+ */
44
+ const TOGGLE_FIELDS = new Set(['enabled', 'overseer']);
45
+ export function TeamFanoutPage(props) {
46
+ const [index, setIndex] = useState(0);
47
+ const rows = props.view.rows;
48
+ const count = Math.max(1, rows.length);
49
+ const cursor = Math.min(index, count - 1);
50
+ const focused = rows[cursor];
51
+ const offset = useProfileScroll(props, cursor);
52
+ const nudge = (delta) => {
53
+ if (!focused)
54
+ return;
55
+ props.dispatch({ type: 'team-fanout', field: focused.field, delta });
56
+ };
57
+ useInput((_input, key) => {
58
+ if (props.isActive === false)
59
+ return;
60
+ if (key.escape) {
61
+ props.onBack();
62
+ return;
63
+ }
64
+ if (key.upArrow || (key.tab && key.shift)) {
65
+ setIndex((cursor - 1 + count) % count);
66
+ return;
67
+ }
68
+ if (key.downArrow || key.tab) {
69
+ setIndex((cursor + 1) % count);
70
+ return;
71
+ }
72
+ if (key.leftArrow || key.rightArrow) {
73
+ nudge(key.leftArrow ? -1 : 1);
74
+ return;
75
+ }
76
+ // `Enter` toggles the two boolean rows and does NOTHING on a numeric row.
77
+ if (key.return && focused && TOGGLE_FIELDS.has(focused.field))
78
+ nudge(1);
79
+ });
80
+ const lines = rows.map((row) => `${row.label.padEnd(LABEL_PAD)}${row.value}`);
81
+ const glyphs = pickGlyphs(props.caps);
82
+ const body = lines.map((line, at) => (_jsx(Text, { wrap: "truncate", color: at === cursor ? props.theme.accent : props.theme.muted, children: `${at === cursor ? glyphs.caret : ' '} ${clipProfileText(line, props.cols - 8)}` }, at)));
83
+ // The hint explains the FOCUSED row rather than decorating the frame: numbers
84
+ // whose meaning is not obvious ("per dispatch" versus "at once") are the reason
85
+ // this page exists at all.
86
+ if (focused) {
87
+ body.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: clipProfileText(focused.hint, props.cols - 8) }, "hint"));
88
+ }
89
+ return _jsx(OverlayFrame, { ...props, rows: body, scrollOffset: offset, title: props.view.error ?? 'Team', hint: "Arrows adjust. Esc goes back; Ctrl+S on the main page saves." });
90
+ }
@@ -232,8 +232,9 @@ export function kitPackRow(state) {
232
232
  const partial = session.held.length < 3;
233
233
  const held = partial ? `: ${session.held.join('+')}` : '';
234
234
  // THE SAME `~` THE CHIP SHOWS (D-12): dropping a slot is only ONE way to
235
- // drift. The other is a connection that `/model`, `/fast` or `/team model`
236
- // rewrote after the kit was applied, and a row that stayed silent about that
235
+ // drift. The other is a connection that `/model`, `/fast` or the settings
236
+ // `Subagent model` row rewrote after the kit was applied, and a row that
237
+ // stayed silent about that
237
238
  // would disagree with the status bar about the same session.
238
239
  const drift = liveDrift(state, session) ? '~' : '';
239
240
  return `${sessionKit.name} [session${held}]${drift}${defaultClause}`;
@@ -3,7 +3,14 @@ import type { AgentKit } from '../../config/model-kits.js';
3
3
  import type { ModelSettingsDraft } from '../../config/model-profile-store.js';
4
4
  import type { SubagentModelMode } from '../../team/model.js';
5
5
  import type { KitDraft } from './kit-settings-state.js';
6
- export type ProfilePage = 'root' | 'picker' | 'manager' | 'editor' | 'models' | 'delete-confirm' | 'discard-confirm' | 'kits' | 'kit-edit' | 'kit-slot' | 'kit-delete';
6
+ import { type TeamFanoutField } from './team-fanout-state.js';
7
+ export type ProfilePage = 'root' | 'picker' | 'manager' | 'editor' | 'models' | 'delete-confirm' | 'discard-confirm' | 'kits' | 'kit-edit' | 'kit-slot' | 'kit-delete' | 'team';
8
+ /**
9
+ * `'team'` is the fan-out page (subagent-fanout D-5), and it is a PAGE rather
10
+ * than three more fields on the root page for a measurable reason: the root page
11
+ * already scrolls on a 24-row terminal, and three arrows-adjustable rows would
12
+ * push `Save` and `Reload` off the first screen on every machine.
13
+ */
7
14
  /**
8
15
  * Which role a PROFILE PICKER page is choosing for.
9
16
  *
@@ -100,6 +107,17 @@ export type ModelProfileAction = {
100
107
  } | {
101
108
  type: 'reload';
102
109
  draft: ModelSettingsDraft;
110
+ }
111
+ /**
112
+ * One arrow press on the fan-out page (subagent-fanout 3.4.2).
113
+ *
114
+ * The reducer, not the page, owns the write: the page holds a cursor and
115
+ * nothing else, so there is exactly one place a draft can change.
116
+ */
117
+ | {
118
+ type: 'team-fanout';
119
+ field: TeamFanoutField;
120
+ delta: -1 | 1;
103
121
  };
104
122
  /** Keep the original secret separate from the empty replacement field. */
105
123
  export declare function createProfileEditor(profile: ModelProfile): ProfileEditorDraft;
@@ -128,7 +146,7 @@ export declare function draftTeam(draft: ModelSettingsDraft): {
128
146
  export declare function draftSubagentProfileId(draft: ModelSettingsDraft): string | null;
129
147
  /** The projected picker selection for the subagent role (6.2). */
130
148
  export declare function subagentSelectedId(draft: ModelSettingsDraft): string | null;
131
- /** What the settings root row and `/team model` show for the current mode. */
149
+ /** What the settings root row and `/team`'s status lines show for the current mode. */
132
150
  export interface SubagentModelSummaryInput {
133
151
  mode: SubagentModelMode;
134
152
  modelProfileId: string | null;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { emptyModelProfiles, duplicateModelProfile, normalizeModelProfiles, validateModelProfiles } from '../../config/model-profiles.js';
3
3
  import { parseContextWindowInput } from '../../config/schema.js';
4
+ import { stepFanoutValue, teamFanoutValues } from './team-fanout-state.js';
4
5
  /** The picker's built-in `Fast tier model` row; never a profile id on disk. */
5
6
  export const SUBAGENT_FAST_ROW_ID = '__fast__';
6
7
  /** Keep the original secret separate from the empty replacement field. */
@@ -221,6 +222,17 @@ export function modelProfileReducer(state, action) {
221
222
  }
222
223
  case 'cancel-editor': return { ...state, editor: undefined, page: state.returnPage,
223
224
  error: undefined };
225
+ case 'team-fanout': {
226
+ // THE FRAGMENT IS THE ONLY THING WRITTEN (D-17 / R-P0-1). `stepFanoutValue`
227
+ // returns `Partial<TeamFanoutPatch>`, so `model` and `modelProfileId` - the
228
+ // two keys `releasedSlots` reads as "the user re-decided the subagent slot" -
229
+ // cannot enter the patch from here. `teamFanoutValues` is the CLAMPED view
230
+ // (R-P0-2), so a step starts from the number the user is looking at.
231
+ const step = stepFanoutValue(teamFanoutValues(state.draft), action.field, action.delta);
232
+ return { ...state, dirty: true, error: undefined,
233
+ draft: { ...state.draft,
234
+ patch: { ...state.draft.patch, team: { ...state.draft.patch.team, ...step } } } };
235
+ }
224
236
  case 'duplicate': {
225
237
  if (library.entries.length >= 200)
226
238
  return { ...state,
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Settings > Team: the pure half of the page (subagent-fanout 3.4.2, extended by
3
+ * team-command-forced-dispatch §3.9).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * THE PAGE WRITES FOUR KEYS AND CANNOT BE MADE TO WRITE A FIFTH (D-17 / R-P0-1).
8
+ * `TeamFanoutPatch` is `Pick<TeamConfig, 'enabled' | 'maxSubagents' |
9
+ * 'maxConcurrent' | 'overseer'>`, and `stepFanoutValue` returns
10
+ * `Partial<TeamFanoutPatch>`: a width adjustment therefore CANNOT carry `model`
11
+ * or `modelProfileId` into `draft.patch.team`. That matters because
12
+ * `releasedSlots` reads those two keys as "the user re-decided the subagent
13
+ * slot", so a page that returned the whole section would silently release the
14
+ * model a session kit had just installed - and no test would notice, because
15
+ * every value on screen would still be right.
16
+ *
17
+ * FOUR KEYS BECAUSE `enabled` MOVED HERE (team-command-forced-dispatch D-3): the
18
+ * team switch used to be reachable only as `/team on|off`, which also wrote
19
+ * `config.json`. The switch belongs on the same page as width, concurrency and
20
+ * supervision - they are four faces of one decision - and `/team` is now the ACT
21
+ * ("arm this session"), never a configuration write (I-4).
22
+ *
23
+ * EVERYTHING HERE IS A PURE FUNCTION over the draft. The page owns exactly one
24
+ * piece of state (its cursor); values come from the reducer, which is the same
25
+ * discipline the rest of this screen follows.
26
+ */
27
+ import type { TeamConfig } from '../../config/schema.js';
28
+ import type { ModelSettingsDraft } from '../../config/model-profile-store.js';
29
+ export type TeamFanoutField = 'enabled' | 'maxSubagents' | 'maxConcurrent' | 'overseer';
30
+ /**
31
+ * THE ONLY KEYS THIS PAGE MAY WRITE (R-P0-1).
32
+ *
33
+ * Not `Partial<TeamConfig>`: a fragment that could carry `model` would make a
34
+ * width adjustment read as a model decision at save time.
35
+ */
36
+ export type TeamFanoutPatch = Pick<TeamConfig, 'enabled' | 'maxSubagents' | 'maxConcurrent' | 'overseer'>;
37
+ export interface TeamFanoutRow {
38
+ field: TeamFanoutField;
39
+ label: string;
40
+ /** The value as the page prints it (`'8'` / `'on'`). */
41
+ value: string;
42
+ /** One line, <= 60 columns: WHY this number, not a restatement of the label. */
43
+ hint: string;
44
+ }
45
+ export interface TeamFanoutView {
46
+ /** Exactly four, in the fixed order the cursor indexes. */
47
+ rows: TeamFanoutRow[];
48
+ error?: string;
49
+ }
50
+ /**
51
+ * The CLAMPED team section this draft would save (R-P0-2), for display only.
52
+ *
53
+ * THE CLAMP IS NOT COSMETIC. Without it a patch carrying only
54
+ * `maxSubagents: 6` renders "6 per dispatch, 8 at once" on top of an 8-wide
55
+ * baseline, while `mergePersistedConfig` clamps the same patch to 6/6 on the way
56
+ * to disk - so the screen and the file disagree, and every arrow key makes the
57
+ * user reconcile them by hand.
58
+ */
59
+ export declare function teamFanoutValues(draft: ModelSettingsDraft): TeamConfig;
60
+ /**
61
+ * The PATCH FRAGMENT for one arrow press, clamped exactly like `clampTeamConfig`.
62
+ *
63
+ * `current` must be the value the screen actually shows (`teamFanoutValues`), so
64
+ * a step always starts from a number the user can see.
65
+ *
66
+ * NO WRAPAROUND: a form control is not a ring, and `Left` at 1 jumping to 10
67
+ * would be a way to widen a dispatch while trying to narrow it.
68
+ *
69
+ * EVERY MEMBER IS HANDLED EXPLICITLY AND THE TAIL IS `never` (P0-1). The old
70
+ * body was three `if`s with the `maxSubagents` branch as the FALLTHROUGH, so
71
+ * adding `'enabled'` to the union compiled cleanly and made the new `Team mode`
72
+ * row adjust `Max subagents` instead - every number on screen self-consistent,
73
+ * no test red, and the user's intent to switch the team OFF arrived as a wider
74
+ * fan-out. The `never` assignment below turns that class of mistake into a
75
+ * compile error, which is the only place it can be caught cheaply.
76
+ */
77
+ export declare function stepFanoutValue(current: TeamConfig, field: TeamFanoutField, delta: -1 | 1): Partial<TeamFanoutPatch>;
78
+ /**
79
+ * The page's four rows, in cursor order.
80
+ *
81
+ * `Team mode` IS FIRST because it is the premise of the other three: with `task`
82
+ * out of the tool list there is no width to configure and no supervisor to run,
83
+ * and a page that listed the numbers first would invite the user to tune a
84
+ * capability they had switched off.
85
+ *
86
+ * TWO HINTS CARRY A CONDITION THE DESIGN OWNS RATHER THAN DECORATION (R-1 / C-2):
87
+ * the default pool is 8, which is 8 simultaneous streams against one API key, and
88
+ * the honest mitigation for an HTTP 429 storm is "lower this number" - said HERE,
89
+ * where the number is, not only in a risk table nobody reads.
90
+ *
91
+ * `teamRegistered === false` (a `--no-team` session) changes TWO things on the
92
+ * `enabled` row and nothing else: `on (next launch)` because the tool array is
93
+ * immutable (I-7), and a hint that says why. Absent means "not disclosed",
94
+ * which every existing fixture relies on.
95
+ */
96
+ export declare function fanoutRows(team: TeamConfig, teamRegistered?: boolean): TeamFanoutRow[];
97
+ /**
98
+ * The one-line summary the settings root row prints for this page.
99
+ *
100
+ * `off - no subagents` rather than the numbers, because with the capability off
101
+ * the numbers describe nothing the user can spend. This replaced
102
+ * `fanoutSummary()` outright instead of living beside it: two summary functions
103
+ * over one page is two authorities for one line, and the root row would sooner or
104
+ * later disagree with the page it summarises.
105
+ */
106
+ export declare function teamSummary(team: TeamConfig): string;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Settings > Team: the pure half of the page (subagent-fanout 3.4.2, extended by
3
+ * team-command-forced-dispatch §3.9).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * THE PAGE WRITES FOUR KEYS AND CANNOT BE MADE TO WRITE A FIFTH (D-17 / R-P0-1).
8
+ * `TeamFanoutPatch` is `Pick<TeamConfig, 'enabled' | 'maxSubagents' |
9
+ * 'maxConcurrent' | 'overseer'>`, and `stepFanoutValue` returns
10
+ * `Partial<TeamFanoutPatch>`: a width adjustment therefore CANNOT carry `model`
11
+ * or `modelProfileId` into `draft.patch.team`. That matters because
12
+ * `releasedSlots` reads those two keys as "the user re-decided the subagent
13
+ * slot", so a page that returned the whole section would silently release the
14
+ * model a session kit had just installed - and no test would notice, because
15
+ * every value on screen would still be right.
16
+ *
17
+ * FOUR KEYS BECAUSE `enabled` MOVED HERE (team-command-forced-dispatch D-3): the
18
+ * team switch used to be reachable only as `/team on|off`, which also wrote
19
+ * `config.json`. The switch belongs on the same page as width, concurrency and
20
+ * supervision - they are four faces of one decision - and `/team` is now the ACT
21
+ * ("arm this session"), never a configuration write (I-4).
22
+ *
23
+ * EVERYTHING HERE IS A PURE FUNCTION over the draft. The page owns exactly one
24
+ * piece of state (its cursor); values come from the reducer, which is the same
25
+ * discipline the rest of this screen follows.
26
+ */
27
+ import { clampTeamConfig, HARD_MAX_SUBAGENTS } from '../../config/schema.js';
28
+ function clampInt(value, min, max) {
29
+ return Math.max(min, Math.min(max, Math.floor(value)));
30
+ }
31
+ /**
32
+ * The CLAMPED team section this draft would save (R-P0-2), for display only.
33
+ *
34
+ * THE CLAMP IS NOT COSMETIC. Without it a patch carrying only
35
+ * `maxSubagents: 6` renders "6 per dispatch, 8 at once" on top of an 8-wide
36
+ * baseline, while `mergePersistedConfig` clamps the same patch to 6/6 on the way
37
+ * to disk - so the screen and the file disagree, and every arrow key makes the
38
+ * user reconcile them by hand.
39
+ */
40
+ export function teamFanoutValues(draft) {
41
+ return clampTeamConfig({ ...draft.baseline.team, ...draft.patch.team });
42
+ }
43
+ /**
44
+ * The PATCH FRAGMENT for one arrow press, clamped exactly like `clampTeamConfig`.
45
+ *
46
+ * `current` must be the value the screen actually shows (`teamFanoutValues`), so
47
+ * a step always starts from a number the user can see.
48
+ *
49
+ * NO WRAPAROUND: a form control is not a ring, and `Left` at 1 jumping to 10
50
+ * would be a way to widen a dispatch while trying to narrow it.
51
+ *
52
+ * EVERY MEMBER IS HANDLED EXPLICITLY AND THE TAIL IS `never` (P0-1). The old
53
+ * body was three `if`s with the `maxSubagents` branch as the FALLTHROUGH, so
54
+ * adding `'enabled'` to the union compiled cleanly and made the new `Team mode`
55
+ * row adjust `Max subagents` instead - every number on screen self-consistent,
56
+ * no test red, and the user's intent to switch the team OFF arrived as a wider
57
+ * fan-out. The `never` assignment below turns that class of mistake into a
58
+ * compile error, which is the only place it can be caught cheaply.
59
+ */
60
+ export function stepFanoutValue(current, field, delta) {
61
+ if (field === 'enabled') {
62
+ // Both arrows toggle: `Team mode` has two states, and a direction has no
63
+ // meaning beyond "the user pressed an arrow" (the `Supervisor` rule below).
64
+ return { enabled: !current.enabled };
65
+ }
66
+ if (field === 'overseer') {
67
+ return { overseer: !current.overseer };
68
+ }
69
+ if (field === 'maxConcurrent') {
70
+ const ceiling = Math.min(HARD_MAX_SUBAGENTS, current.maxSubagents);
71
+ return { maxConcurrent: clampInt(current.maxConcurrent + delta, 1, ceiling) };
72
+ }
73
+ if (field === 'maxSubagents') {
74
+ const next = clampInt(current.maxSubagents + delta, 1, HARD_MAX_SUBAGENTS);
75
+ // THE FOLLOW-DOWN IS WRITTEN INTO THE PATCH (R-P1-9), not just rendered: a
76
+ // narrower population cannot keep a wider pool, but a DISPLAY-only correction
77
+ // would snap back the moment the user widened again, so "the change I just
78
+ // made" would not stick - the exact complaint this design exists to answer.
79
+ return current.maxConcurrent > next
80
+ ? { maxSubagents: next, maxConcurrent: next }
81
+ : { maxSubagents: next };
82
+ }
83
+ // THE EXHAUSTIVE GUARD IS THE POINT (P0-1): a new member of `TeamFanoutField`
84
+ // must fail to COMPILE here, not silently fall through to `maxSubagents`.
85
+ //
86
+ // IT SITS AFTER EVERY BRANCH RETURNS, not before the last one. §3.9's snippet
87
+ // shows it above the `maxSubagents` branch, where TypeScript rejects the file
88
+ // outright (`'maxSubagents' is not assignable to 'never'`) - the intent
89
+ // survives, the position does not.
90
+ const _exhaustive = field;
91
+ return _exhaustive;
92
+ }
93
+ /**
94
+ * The page's four rows, in cursor order.
95
+ *
96
+ * `Team mode` IS FIRST because it is the premise of the other three: with `task`
97
+ * out of the tool list there is no width to configure and no supervisor to run,
98
+ * and a page that listed the numbers first would invite the user to tune a
99
+ * capability they had switched off.
100
+ *
101
+ * TWO HINTS CARRY A CONDITION THE DESIGN OWNS RATHER THAN DECORATION (R-1 / C-2):
102
+ * the default pool is 8, which is 8 simultaneous streams against one API key, and
103
+ * the honest mitigation for an HTTP 429 storm is "lower this number" - said HERE,
104
+ * where the number is, not only in a risk table nobody reads.
105
+ *
106
+ * `teamRegistered === false` (a `--no-team` session) changes TWO things on the
107
+ * `enabled` row and nothing else: `on (next launch)` because the tool array is
108
+ * immutable (I-7), and a hint that says why. Absent means "not disclosed",
109
+ * which every existing fixture relies on.
110
+ */
111
+ export function fanoutRows(team, teamRegistered) {
112
+ const blocked = teamRegistered === false && team.enabled;
113
+ return [
114
+ {
115
+ field: 'enabled',
116
+ label: 'Team mode',
117
+ value: blocked ? 'on (next launch)' : team.enabled ? 'on' : 'off',
118
+ hint: blocked
119
+ ? '--no-team session: the task tool cannot be added live'
120
+ : 'off: no task tool next launch; this session may lack it',
121
+ },
122
+ {
123
+ field: 'maxSubagents',
124
+ label: 'Max subagents',
125
+ value: String(team.maxSubagents),
126
+ hint: `children per dispatch; ${HARD_MAX_SUBAGENTS} is the hard ceiling`,
127
+ },
128
+ {
129
+ field: 'maxConcurrent',
130
+ label: 'Run at once',
131
+ value: String(team.maxConcurrent),
132
+ hint: 'provider limits: lower this if you see rate-limit errors',
133
+ },
134
+ {
135
+ field: 'overseer',
136
+ label: 'Supervisor',
137
+ value: team.overseer ? 'on' : 'off',
138
+ hint: 'checks stalled children; never kills them',
139
+ },
140
+ ];
141
+ }
142
+ /**
143
+ * The one-line summary the settings root row prints for this page.
144
+ *
145
+ * `off - no subagents` rather than the numbers, because with the capability off
146
+ * the numbers describe nothing the user can spend. This replaced
147
+ * `fanoutSummary()` outright instead of living beside it: two summary functions
148
+ * over one page is two authorities for one line, and the root row would sooner or
149
+ * later disagree with the page it summarises.
150
+ */
151
+ export function teamSummary(team) {
152
+ if (!team.enabled)
153
+ return 'off - no subagents';
154
+ return `${team.maxSubagents} per dispatch, ${team.maxConcurrent} at once`;
155
+ }