@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
@@ -21,6 +21,9 @@ import { createFastOverseerProvider } from '../team/overseer.js';
21
21
  import { createTaskTool } from '../team/task-tool.js';
22
22
  import { resolveSubagentModel } from '../team/model.js';
23
23
  import { buildTeamBlock } from '../team/prompt.js';
24
+ // THE THIRD LAYER's prompt half (team-command-forced-dispatch §3.6): spliced
25
+ // after `<team_mode>` and before the subagent block, only while armed.
26
+ import { buildForcedTeamBlock } from '../team/forced-prompt.js';
24
27
  import { buildFastBlock } from '../fast/prompt.js';
25
28
  import { ProcSupervisor } from '../proc/supervisor.js';
26
29
  import { buildBackgroundJobsBlock, buildBackgroundServicesBlock } from '../proc/prompt.js';
@@ -45,12 +48,14 @@ import { TodoStore } from '../todo/store.js';
45
48
  import { createTodoTool } from '../todo/todo-tool.js';
46
49
  import { buildTodoBlock } from '../todo/prompt.js';
47
50
  import { createPlanTools } from '../tools/plan-tools.js';
48
- import { DENY_ALL_HUMAN_INPUT } from '../tools/human-input.js';
51
+ import { ASK_GUARD_LIMITS, DENY_ALL_HUMAN_INPUT, questionFingerprint, } from '../tools/human-input.js';
49
52
  import { getLogger } from '../logging/logger.js';
50
53
  import { createNodeSkillHost } from '../skills/node-host.js';
51
54
  import { SkillService } from '../skills/service.js';
52
55
  import { createSkillTools } from '../skills/tools.js';
56
+ import { SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
53
57
  import { buildSystemPrompt } from './system-prompt.js';
58
+ import { buildSkillTriageBlock } from './skill-triage-prompt.js';
54
59
  import { resetConversationWith, } from './conversation-reset.js';
55
60
  import { archiveClearedConversation, } from '../session/clear-archive.js';
56
61
  /**
@@ -92,6 +97,28 @@ export class AgentController {
92
97
  skills;
93
98
  skillsEnabled;
94
99
  onSkillsChanged;
100
+ // -----------------------------------------------------------------------
101
+ // Skill triage (skill-triage-and-unbounded-ask §4.2.5) — PER-TURN state.
102
+ //
103
+ // Every field here is written in `prepareUserTurn` and cleared on `agent_end`,
104
+ // because a stale candidate set in the prompt is worse than no candidate set:
105
+ // it points the model at a skill chosen for a task it is no longer doing.
106
+ // -----------------------------------------------------------------------
107
+ /**
108
+ * This turn's routing result, or `null` when triage is off or nothing scored.
109
+ *
110
+ * It also carries the task it was computed for (`SkillRoutingReport.task`), so
111
+ * there is no separate copy of the turn's text to keep in step with it.
112
+ */
113
+ turnRouting = null;
114
+ /** The previous turn's routing, for `/skills route` with no argument. */
115
+ lastSkillRouting = null;
116
+ /** Did this turn call `skill(name)` successfully? The only proof of adoption. */
117
+ skillLoadedThisTurn = false;
118
+ /** Tool calls this turn, EXCLUDING `skill` / `skill_find` (see §4.2.7). */
119
+ toolCallsThisTurn = 0;
120
+ /** Skills already reported as "not loaded" — a session says it once (I-T5). */
121
+ skillTriageNoticed = new Set();
95
122
  /** Mutable session cwd — tools resolve relative paths against this. */
96
123
  cwd;
97
124
  /**
@@ -124,7 +151,20 @@ export class AgentController {
124
151
  // -----------------------------------------------------------------------
125
152
  effectiveMode;
126
153
  pendingMode = null;
127
- askRounds = 0;
154
+ /**
155
+ * Fingerprints of the question SETS the user ANSWERED in this turn. Bounded,
156
+ * and the bound is storage hygiene rather than a refusal rule — see
157
+ * `ASK_GUARD_LIMITS`.
158
+ *
159
+ * Replaces a numeric round counter that capped `ask_user` at four calls. There
160
+ * is no cap now; the only thing this prevents is the same batch of questions
161
+ * being put to the user twice, which is noise rather than information.
162
+ *
163
+ * Cleared in `prepareUserTurn` for a NEW turn only. A steer continuation is the
164
+ * same turn — clearing there would disable the guard exactly when a long turn
165
+ * most needs it (§4.1.6).
166
+ */
167
+ askedFingerprints = new Set();
128
168
  /** Static: whether this session could ever render a question overlay. */
129
169
  planInteractive;
130
170
  // -----------------------------------------------------------------------
@@ -135,18 +175,32 @@ export class AgentController {
135
175
  // immutable for the life of the session (see the note in the constructor),
136
176
  // so `/team on` in a session started with `--no-team` CANNOT add it.
137
177
  //
138
- // `teamEnabled` is live. `/team off` flips it, which makes `task` refuse
139
- // through its own guard and drops `<team_mode>` from the next prompt rebuild
140
- // — `rebuildSystemPrompt()` IS a live path, unlike the tool array.
178
+ // The LIVE switch is NOT a second field (team-command-forced-dispatch §3.5 /
179
+ // P1-4). It used to be one — `teamEnabled`, flipped by `/team on|off` and
180
+ // hand-synchronised with `config.team.enabled` — which was a second truth
181
+ // waiting to separate. The moment the settings screen grew a `Team mode` row,
182
+ // `applyModelSettingsSnapshot()` replaced `this.config` WITHOUT touching that
183
+ // copy, and the screen would have said `on` while `task` still answered
184
+ // TEAM_OFF_REFUSAL. `isTeamEnabled()` is now one derivation over
185
+ // `config.team.enabled`, and `setTeamEnabled()` is one write to it.
141
186
  //
142
- // Conflating them is P0-2: the prompt would advertise a tool that was never
143
- // registered, every call would come back "unknown tool", and the model would
144
- // have no way to discover why (D-17 / R-18).
187
+ // Conflating the two flags was P0-2 in the original feature: the prompt would
188
+ // advertise a tool that was never registered, every call would come back
189
+ // "unknown tool", and the model would have no way to discover why (D-17 / R-18).
145
190
  // -----------------------------------------------------------------------
146
191
  teamRegistered;
147
- teamEnabled;
148
192
  teamRuntime;
149
193
  // -----------------------------------------------------------------------
194
+ // Forced team mode (team-command-forced-dispatch §3.5 / D-4).
195
+ //
196
+ // SESSION STATE, NEVER PERSISTED: `null` means "not armed". `/team <brief>`
197
+ // and `/team on` set it, `/team off` clears it, and the process exiting is the
198
+ // third way it ends. It is not a `config.json` key on purpose — a persisted
199
+ // "always force a fan-out" would be inherited by `aragon exec` and by every
200
+ // launch after one experiment, at four children a turn (D-4 / AC-24).
201
+ // -----------------------------------------------------------------------
202
+ forcedTeam = null;
203
+ // -----------------------------------------------------------------------
150
204
  // Todo planning (todo-plan-execution §3.6a) — THE SAME TWO FLAGS, FOR THE
151
205
  // SAME REASON.
152
206
  //
@@ -389,8 +443,10 @@ export class AgentController {
389
443
  const fastTier = resolveFastTier(config, (id, role) => this.hasApiKey(id, role));
390
444
  const fastRegistered = fastTier.ok;
391
445
  // --- Team mode ---------------------------------------------------------
446
+ // ONE FLAG HERE (team-command-forced-dispatch P1-4): this is the IMMUTABLE
447
+ // snapshot of "is `task` in this.tools at all". The live switch is
448
+ // `config.team.enabled`, read through `isTeamEnabled()` — no second copy.
392
449
  this.teamRegistered = config.team.enabled;
393
- this.teamEnabled = config.team.enabled;
394
450
  this.teamRuntime = this.teamRegistered
395
451
  ? new TeamRuntime({
396
452
  getConfig: () => this.config,
@@ -491,7 +547,12 @@ export class AgentController {
491
547
  this.jobsRegistered = config.jobs.enabled && config.bash.background;
492
548
  this.jobMirror =
493
549
  this.jobsRegistered && config.jobs.persistLog
494
- ? new JobLogMirror({ root: () => join(getHomeRoot(), 'jobs', this.jobSessionTag) })
550
+ ? new JobLogMirror({
551
+ root: () => join(getHomeRoot(), 'jobs', this.jobSessionTag),
552
+ // NAMED, NOT DERIVED. The mirror sweeps only this directory; the session
553
+ // tagging above is what keeps its own run out of the sweep's reach.
554
+ jobsRoot: () => join(getHomeRoot(), 'jobs'),
555
+ })
495
556
  : undefined;
496
557
  this.procs = new ProcSupervisor({
497
558
  readyTimeoutMs: () => this.config.bash.readyTimeoutMs,
@@ -585,7 +646,13 @@ export class AgentController {
585
646
  // (or 11 with skills) it is today.
586
647
  planTools: createPlanTools({
587
648
  gate: humanInput,
588
- takeAskRound: () => this.takeAskRound(),
649
+ // TWO CALLBACKS, AND THE SPLIT IS LOAD-BEARING (§4.1.2). One
650
+ // read-and-write accessor could only be called before the question
651
+ // (recording an answer nobody gave, which makes "a cancelled round stays
652
+ // askable" impossible) or after it (which cannot stop a repeat). The guard
653
+ // is "read before, write only after a real answer".
654
+ hasAnsweredQuestions: (questions) => this.hasAnswered(questions),
655
+ noteAnsweredQuestions: (questions) => this.noteAnswered(questions),
589
656
  withHumanWait: (fn) => this.withHumanWait(fn),
590
657
  onPlanApproved: () => this.setAgentMode('build', { force: true }),
591
658
  }),
@@ -663,7 +730,10 @@ export class AgentController {
663
730
  createTaskTool({
664
731
  runtime: this.teamRuntime,
665
732
  // Read LIVE: `/team off` flips the flag, it cannot unregister.
666
- isTeamEnabled: () => this.teamEnabled,
733
+ // THROUGH THE METHOD, not the config: one derivation, so the
734
+ // task tool, `<team_mode>` and the settings screen can never
735
+ // disagree about whether this session may dispatch (P1-4).
736
+ isTeamEnabled: () => this.isTeamEnabled(),
667
737
  maxSubagents: () => this.config.team.maxSubagents,
668
738
  hasApiKey: () => this.hasApiKey(),
669
739
  activeProvider: () => this.config.provider,
@@ -909,6 +979,17 @@ export class AgentController {
909
979
  this.activeToolCalls += 1;
910
980
  if (event.type === 'tool_execution_end') {
911
981
  this.activeToolCalls = Math.max(0, this.activeToolCalls - 1);
982
+ // The ONLY proof a skill was adopted is a successful `skill` call. Reading
983
+ // the model's prose for a skill name would be a heuristic; this is a fact
984
+ // the executor already reports.
985
+ if (event.toolName === 'skill' && event.isError !== true)
986
+ this.skillLoadedThisTurn = true;
987
+ // `skill` / `skill_find` do not count as "work done": the notice below is
988
+ // for a turn that changed something without reading its skill, and a turn
989
+ // that ONLY searched for a skill is a turn that already tried.
990
+ if (event.toolName !== 'skill' && event.toolName !== 'skill_find') {
991
+ this.toolCallsThisTurn += 1;
992
+ }
912
993
  // THE ONE PUSH SITE. A notice that could not be pushed here is picked up
913
994
  // by the next tool result (the pull channel) or by the next user turn
914
995
  // (the fill channel), so nothing is lost by declining.
@@ -925,6 +1006,9 @@ export class AgentController {
925
1006
  // required: `turn_start` covers the next turn of the same run, and this
926
1007
  // covers a run that ended with a notice stranded in the queue.
927
1008
  this.jobRelay?.requeueUndelivered();
1009
+ // AFTER the requeue, so a turn that ends with a stranded notice still gets
1010
+ // its feedback line. Order between the two is otherwise irrelevant.
1011
+ this.maybeReportSkillTriage();
928
1012
  }
929
1013
  });
930
1014
  if (this.fast) {
@@ -973,23 +1057,52 @@ export class AgentController {
973
1057
  const tier = resolveFastTier(config, (id, role) => !!makeGetApiKey(config, role)(id));
974
1058
  const fastAvailable = config === this.config ? this.fast?.available() === true
975
1059
  : this.fast?.isRegistered() && config.fast.enabled && tier.ok;
1060
+ const skillsCatalog = this.skillsEnabled ? this.skills.catalogBlock() : '';
1061
+ // READ LIVE FROM THE SERVICE, never `config.skills` (§4.2.5 / R-12).
1062
+ // `SkillService.updateConfig` replaces the SERVICE's own config object, so
1063
+ // `this.config.skills` goes stale the moment `/skills triage off` runs — and a
1064
+ // stale read would leave the block in the prompt until the next launch, which
1065
+ // looks exactly like a broken switch. Same discipline as `/todo off`.
1066
+ const triageOn = this.skillsEnabled && this.skills.getConfig().triage === true;
1067
+ // An empty catalog means there is nothing to triage against, so the block is
1068
+ // absent rather than present-and-useless (I-T1 / AC-T18).
1069
+ const triageBlock = triageOn && skillsCatalog.length > 0
1070
+ ? buildSkillTriageBlock({ candidates: this.turnRouting?.candidates ?? [] })
1071
+ : '';
976
1072
  return buildSystemPrompt({
977
1073
  cwd: this.cwd,
978
1074
  tools: this.tools,
979
- skillsBlock: this.skillsEnabled ? this.skills.catalogBlock() + this.skills.alwaysBlock() : '',
1075
+ skillsBlock: this.skillsEnabled ? skillsCatalog + this.skills.alwaysBlock() : '',
1076
+ // Immediately after the catalog it refers to, before the plan block whose
1077
+ // first step defers to it (§4.2.4).
1078
+ skillTriageBlock: triageBlock,
980
1079
  agentMode: this.effectiveMode,
981
1080
  planInteractive: this.planInteractive,
982
- planMaxAskRounds: config.planModeMaxAskRounds,
983
- // BOTH flags, and both are required (§4.5). `teamRegistered` false means
984
- // there is no `task` tool to advertise; `teamEnabled` false means the user
985
- // turned it off for this session. Advertising in either case would
986
- // describe a capability the model does not have.
987
- teamBlock: this.teamRegistered && this.teamEnabled
1081
+ // BOTH halves, and both are required (§4.5). `teamRegistered` false means
1082
+ // there is no `task` tool to advertise; `config.team.enabled` false means
1083
+ // the user turned the capability off (in settings — the only writer).
1084
+ // Advertising in either case would describe a capability the model does not
1085
+ // have. Read through the field rather than `this.isTeamEnabled()` so the
1086
+ // sentence and the code agree, as the two flags always have here (P1-4).
1087
+ teamBlock: this.teamRegistered && this.config.team.enabled
988
1088
  ? buildTeamBlock({
989
1089
  maxSubagents: config.team.maxSubagents,
990
1090
  maxConcurrent: config.team.maxConcurrent,
991
1091
  })
992
1092
  : '',
1093
+ // THE THIRD LAYER (team-command-forced-dispatch §3.6): "you MUST dispatch"
1094
+ // on top of `<team_mode>`'s "you may". THREE conditions and all three are
1095
+ // load-bearing - armed for this session, the tool actually registered, and
1096
+ // the capability still on. The last two are `<team_mode>`'s own pair, so a
1097
+ // session can never be told to use a tool the prompt above it does not
1098
+ // mention. Empty everywhere else, which is what keeps I-2 (byte-identical
1099
+ // prompt) true for every session that never types `/team`.
1100
+ forcedTeamBlock: this.forcedTeam && this.teamRegistered && this.config.team.enabled
1101
+ ? buildForcedTeamBlock({
1102
+ count: this.forcedTeam.count,
1103
+ planMode: this.effectiveMode === 'plan',
1104
+ })
1105
+ : '',
993
1106
  // BOTH FLAGS, exactly as team mode uses both, and for the identical
994
1107
  // reason: `todoRegistered` false means there is no `todo_write` to
995
1108
  // advertise, `todoEnabled` false means the user turned it off for this
@@ -1183,7 +1296,7 @@ export class AgentController {
1183
1296
  }
1184
1297
  /** Prepare before clearing the plan, then enter the engine without yielding. */
1185
1298
  async startPrompt(text, options, request) {
1186
- this.prepareUserTurn(text);
1299
+ this.prepareUserTurn(text, { steering: false });
1187
1300
  if (request !== this.startupSequence) {
1188
1301
  return { status: 'not-started', reason: 'cancelled' };
1189
1302
  }
@@ -1212,10 +1325,39 @@ export class AgentController {
1212
1325
  // failure AC-25 exists to prevent.
1213
1326
  this.jobRelay?.flushForTurn();
1214
1327
  }
1215
- prepareUserTurn(text) {
1328
+ /**
1329
+ * THE BOUNDARY OF ONE TURN — and there are TWO call sites, which is the reason
1330
+ * this takes an explicit flag instead of resetting everything unconditionally.
1331
+ *
1332
+ * `startPrompt` passes `{ steering: false }`: the user typed something new, so
1333
+ * this really is a new turn. `continue()` passes `{ steering: true }` with the
1334
+ * CONCATENATED queued steering text, which resumes the SAME turn.
1335
+ *
1336
+ * A steer continuation must not re-triage (the candidates would change mid-turn,
1337
+ * and rebuilding the prompt aborts an idle compaction for no gain) and must not
1338
+ * clear the answered-question guard (a long turn interrupted by one steer is
1339
+ * exactly when a repeat question is most likely). Hence per-turn state is
1340
+ * initialised in ONE branch.
1341
+ *
1342
+ * `steering` is REQUIRED and positional-second so each call site states which
1343
+ * kind of turn it is starting, rather than letting a default make "new turn" the
1344
+ * thoughtless option.
1345
+ */
1346
+ prepareUserTurn(text, options) {
1216
1347
  if (this.skillsEnabled)
1217
1348
  this.skills.beginUserTurn();
1218
- this.askRounds = 0;
1349
+ if (!options.steering) {
1350
+ this.askedFingerprints.clear();
1351
+ this.turnRouting = this.skillsEnabled ? this.skills.triage(text) : null;
1352
+ this.skillLoadedThisTurn = false;
1353
+ this.toolCallsThisTurn = 0;
1354
+ // BEFORE the engine is entered, because `runAgentLoop` captures the system
1355
+ // prompt BY VALUE on its way in — a rebuild after `agent.prompt()` would be
1356
+ // one turn late. This is also why steer does not rebuild: `continue()` runs
1357
+ // while the loop already holds its snapshot, so the cost would be an idle
1358
+ // compaction aborted for a prompt nothing has read yet (§4.2.5).
1359
+ this.rebuildSystemPrompt();
1360
+ }
1219
1361
  this.fast?.setGoal(text);
1220
1362
  }
1221
1363
  /** Cancel pending startup as well as any engine run already in progress. */
@@ -1393,7 +1535,7 @@ export class AgentController {
1393
1535
  }
1394
1536
  if (this.pendingUserSteering.size > 0) {
1395
1537
  const request = ++this.startupSequence;
1396
- this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'));
1538
+ this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'), { steering: true });
1397
1539
  if (request !== this.startupSequence) {
1398
1540
  return { status: 'not-started', reason: 'cancelled' };
1399
1541
  }
@@ -1494,18 +1636,90 @@ export class AgentController {
1494
1636
  return {
1495
1637
  effective: this.effectiveMode,
1496
1638
  pending: this.pendingMode,
1497
- askRoundsUsed: this.askRounds,
1498
- maxAskRounds: this.config.planModeMaxAskRounds,
1499
1639
  };
1500
1640
  }
1501
- /** Spend one `ask_user` round. Called from the tool, never from the UI. */
1502
- takeAskRound() {
1503
- const max = this.config.planModeMaxAskRounds;
1504
- if (this.askRounds >= max) {
1505
- return { ok: false, used: this.askRounds, remaining: 0, max };
1641
+ /**
1642
+ * READ-ONLY guard probe, called by `ask_user` BEFORE it opens the overlay.
1643
+ *
1644
+ * No side effect of any kind: a check that also writes cannot be placed before
1645
+ * the question without recording an answer that has not happened yet, and the
1646
+ * "a cancelled round stays askable" rule would then be unimplementable.
1647
+ */
1648
+ hasAnswered(questions) {
1649
+ return this.askedFingerprints.has(questionFingerprint(questions));
1650
+ }
1651
+ /**
1652
+ * Record an ANSWERED question set, called by `ask_user` after a real answer.
1653
+ *
1654
+ * Evicts the oldest entry past `ASK_GUARD_LIMITS.maxRemembered`. That eviction
1655
+ * is memory hygiene and NOT a refusal rule — a bounded rejection would be the
1656
+ * deleted round cap wearing a bigger number.
1657
+ */
1658
+ noteAnswered(questions) {
1659
+ this.askedFingerprints.add(questionFingerprint(questions));
1660
+ while (this.askedFingerprints.size > ASK_GUARD_LIMITS.maxRemembered) {
1661
+ const oldest = this.askedFingerprints.values().next();
1662
+ if (oldest.done === true)
1663
+ break;
1664
+ this.askedFingerprints.delete(oldest.value);
1665
+ }
1666
+ }
1667
+ /** `/skills route` with no argument: the LAST turn's real routing result. */
1668
+ getSkillRouting() {
1669
+ return this.lastSkillRouting;
1670
+ }
1671
+ /**
1672
+ * One line when a confidently-matched skill was NOT loaded — and silence every
1673
+ * other time (D-8).
1674
+ *
1675
+ * THE DISCIPLINE IS "SILENT ON SUCCESS, VISIBLE ON FAILURE". Announcing the
1676
+ * candidates every turn would be ignored by the third day; announcing nothing
1677
+ * ever would make the whole feature unfalsifiable. "A skill covered this task,
1678
+ * you did the work anyway, and you never opened it" is precisely the failure this
1679
+ * feature exists to fix, so it is the one case that speaks — and it names the
1680
+ * command that explains it.
1681
+ *
1682
+ * SEVEN CONDITIONS, and the first one is the one that is easy to get wrong.
1683
+ *
1684
+ * 0. This turn was NOT aborted and NOT ended by an error.
1685
+ * `agent_end` is emitted unconditionally from a `finally` block in
1686
+ * `core/src/engine/agent.ts` and its payload carries no reason, no `aborted`
1687
+ * and no `error` field — so "did this turn finish normally?" has to be read
1688
+ * from this class's own state. `stopEngine()` sets `abortRequested` before it
1689
+ * calls `agent.abort()` and `agent_start` clears it, which makes it an exact
1690
+ * answer. Without this condition an Esc mis-touch would produce a line
1691
+ * criticising the model for not loading a skill: commentary in response to an
1692
+ * interruption.
1693
+ *
1694
+ * Conditions 5 and 0 are not redundant: 0 decides what counts as the end of a
1695
+ * turn, 5 decides whether the turn did any work. With only 5, a tool call made
1696
+ * before an abort would still trigger the notice.
1697
+ */
1698
+ maybeReportSkillTriage() {
1699
+ const routing = this.turnRouting;
1700
+ const best = routing?.candidates[0];
1701
+ const shouldReport = this.abortRequested === false &&
1702
+ this.skillsEnabled &&
1703
+ this.skills.getConfig().triage === true &&
1704
+ routing !== null &&
1705
+ best !== undefined &&
1706
+ best.score >= SKILL_TRIAGE_LIMITS.confidentScore &&
1707
+ this.skillLoadedThisTurn === false &&
1708
+ this.toolCallsThisTurn >= 1 &&
1709
+ !this.skillTriageNoticed.has(best.name);
1710
+ if (shouldReport && best !== undefined) {
1711
+ this.skillTriageNoticed.add(best.name);
1712
+ this.notifyHostFn?.('info', `No skill was loaded; the closest installed match was "${best.name}". ` +
1713
+ 'Run /skills route to see why.');
1506
1714
  }
1507
- this.askRounds += 1;
1508
- return { ok: true, used: this.askRounds, remaining: max - this.askRounds, max };
1715
+ // The routing result outlives the turn so `/skills route` can explain it; the
1716
+ // candidates themselves must not, or the next `rebuildSystemPrompt()` would
1717
+ // resurrect a set computed for a task that is over.
1718
+ if (routing !== null)
1719
+ this.lastSkillRouting = routing;
1720
+ this.turnRouting = null;
1721
+ this.skillLoadedThisTurn = false;
1722
+ this.toolCallsThisTurn = 0;
1509
1723
  }
1510
1724
  /**
1511
1725
  * Run `fn` with the idle watchdog paused.
@@ -1546,22 +1760,67 @@ export class AgentController {
1546
1760
  isTeamRegistered() {
1547
1761
  return this.teamRegistered;
1548
1762
  }
1549
- /** Whether `task` will actually dispatch. Flipped live by `/team on|off`. */
1763
+ /**
1764
+ * Whether `task` will actually dispatch - a DERIVED value.
1765
+ *
1766
+ * One truth (`config.team.enabled`) plus one immutable snapshot
1767
+ * (`teamRegistered`). The previous version read a private copy that `/team
1768
+ * on|off` hand-synchronised, which worked only while that command was the sole
1769
+ * writer; the settings screen writes the config directly, so the copy would
1770
+ * have kept saying `on` (P1-4 / AC-25).
1771
+ */
1550
1772
  isTeamEnabled() {
1551
- return this.teamEnabled;
1773
+ return this.teamRegistered && this.config.team.enabled;
1552
1774
  }
1553
1775
  /**
1554
1776
  * Flip the live switch and re-render the prompt.
1555
1777
  *
1778
+ * THE NAME IS KEPT (three call sites plus the settings path) but the body is
1779
+ * one write: `setTeamConfig()` already clamps, bumps the revision and rebuilds,
1780
+ * so there is no second place where a half-applied state can exist.
1781
+ *
1556
1782
  * Turning it ON in a session that started with `--no-team` deliberately does
1557
1783
  * NOT register the tool (D-17): the array is immutable, and advertising an
1558
1784
  * unregistered tool is the dead end P0-2 is about. `/team` reports that
1559
1785
  * honestly by checking `isTeamRegistered()` first.
1560
1786
  */
1561
1787
  setTeamEnabled(enabled) {
1562
- this.advanceSettingsRevision();
1563
- this.teamEnabled = enabled;
1788
+ this.setTeamConfig({ enabled });
1789
+ }
1790
+ /**
1791
+ * Arm forced team mode for this session (team-command-forced-dispatch §3.5).
1792
+ *
1793
+ * Returns the count actually adopted. The clamp is `[1, team.maxSubagents]`:
1794
+ * `0`, a negative and a fraction are refused by the parser upstream (`-n 0` is
1795
+ * a syntax error, not a clamp), so this is the boundary check of a value that
1796
+ * crossed a module line - which is the one place `CLAUDE.md` asks for it, not a
1797
+ * second validation of the same input.
1798
+ *
1799
+ * THE COUNT IS FROZEN for the rest of the session: `/team -n 2 <brief>` then a
1800
+ * plain message keeps two children per turn, so the user's one expressed intent
1801
+ * does not silently widen back to four.
1802
+ */
1803
+ armForcedTeam(count) {
1804
+ const ceiling = Math.max(1, this.config.team.maxSubagents);
1805
+ const adopted = Math.max(1, Math.min(Math.floor(count), ceiling));
1806
+ this.forcedTeam = { count: adopted };
1807
+ // RE-RENDER: the `<forced_team_mode>` block has to be in the very next
1808
+ // request, and `rebuildSystemPrompt()` is the live path that does it (it is
1809
+ // also why the command body prints a notice: this produces no AgentEvent, so
1810
+ // without one React would not repaint the `Team N` chip - P1-6).
1564
1811
  this.rebuildSystemPrompt();
1812
+ return adopted;
1813
+ }
1814
+ /** Disarm. IDEMPOTENT: disarming an unarmed session rewrites nothing. */
1815
+ disarmForcedTeam() {
1816
+ if (!this.forcedTeam)
1817
+ return;
1818
+ this.forcedTeam = null;
1819
+ this.rebuildSystemPrompt();
1820
+ }
1821
+ /** The armed state, read at render time by the chip and by `/team`'s status. */
1822
+ getForcedTeam() {
1823
+ return this.forcedTeam;
1565
1824
  }
1566
1825
  getTeamConfig() {
1567
1826
  return this.config.team;
@@ -1584,8 +1843,8 @@ export class AgentController {
1584
1843
  * One authority for the `task` report's disclosure line and its price
1585
1844
  * table: both read through here, so the header can never name one model
1586
1845
  * while pricing another. Re-resolved on every call for the reason
1587
- * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
1588
- * all rewrite the inputs mid-session.
1846
+ * `resolveFastTier` records - `/fast off`, a settings save and the `Subagent
1847
+ * model` row all rewrite the inputs mid-session.
1589
1848
  */
1590
1849
  subagentModelResolution() {
1591
1850
  return resolveSubagentModel(this.config, (id, role) => this.hasApiKey(id, role));
@@ -1670,7 +1929,7 @@ export class AgentController {
1670
1929
  * THIS EXISTS BECAUSE `persistConfig` DOES NOT TOUCH THE RUNTIME (P1-2 /
1671
1930
  * AC-38). `App` reads `controller.getConfig()` on every render, and that
1672
1931
  * returns THIS object; the App's `persistConfig` only writes the file. So
1673
- * `/todo panel off` must call both, exactly as `/team max` does — with only
1932
+ * `/todo panel off` must call both, exactly as settings' `Team` page does — with
1674
1933
  * the persist call the command reports success and changes nothing until the
1675
1934
  * next launch.
1676
1935
  *
@@ -97,8 +97,19 @@ export function releasedSlots(draft) {
97
97
  || hasConnectionPatch(draft.patch, role))
98
98
  released.add(role);
99
99
  }
100
- if (draft.patch.team !== undefined)
100
+ // ONLY A MODEL BINDING DECISION RELEASES THE SLOT (subagent-fanout D-7). The
101
+ // fan-out width and the supervisor live in the SAME `team` section but are not
102
+ // the kit's business: `{ maxSubagents: 6 }` used to hit the old
103
+ // `patch.team !== undefined` test, so narrowing the fan-out from the settings
104
+ // screen silently dropped the subagent model a session kit had installed for
105
+ // this session - with every value on screen still correct.
106
+ //
107
+ // `'model' in patch`, not `patch.model !== undefined`: an explicit
108
+ // `model: undefined` still means "this transaction spoke about the model".
109
+ const teamPatch = draft.patch.team;
110
+ if (teamPatch && ('model' in teamPatch || 'modelProfileId' in teamPatch)) {
101
111
  released.add('subagent');
112
+ }
102
113
  return [...released].filter((slot) => held.includes(slot));
103
114
  }
104
115
  /** The session kit as it stands AFTER this transaction's explicit decisions. */
@@ -8,11 +8,9 @@
8
8
  * ASCII ONLY — `agent/` is inside the glyph scanner's scope.
9
9
  */
10
10
  /** Bump when the wording changes, so a transcript can be dated. */
11
- export declare const PLAN_MODE_BLOCK_VERSION = "v2-2026-10-08";
11
+ export declare const PLAN_MODE_BLOCK_VERSION = "v3-2026-10-26";
12
12
  export interface PlanModeBlockOptions {
13
13
  /** False under `-p` / piped stdin: no overlay can be rendered, so no tools. */
14
14
  interactive: boolean;
15
- /** `planModeMaxAskRounds`; substituted into the guidance so the two agree. */
16
- maxAskRounds: number;
17
15
  }
18
16
  export declare function buildPlanModeBlock(options: PlanModeBlockOptions): string;
@@ -8,7 +8,7 @@
8
8
  * ASCII ONLY — `agent/` is inside the glyph scanner's scope.
9
9
  */
10
10
  /** Bump when the wording changes, so a transcript can be dated. */
11
- export const PLAN_MODE_BLOCK_VERSION = 'v2-2026-10-08';
11
+ export const PLAN_MODE_BLOCK_VERSION = 'v3-2026-10-26';
12
12
  /**
13
13
  * The closing paragraph of the interactive block.
14
14
  *
@@ -42,13 +42,18 @@ const HEADLESS_PARAGRAPH = [
42
42
  ' write the finished plan as your final message in markdown.',
43
43
  ].join('\n');
44
44
  export function buildPlanModeBlock(options) {
45
- const { interactive, maxAskRounds } = options;
45
+ const { interactive } = options;
46
+ // NO ROUND COUNT APPEARS HERE, and none may be reintroduced: the number used
47
+ // to be substituted from `planModeMaxAskRounds`, which meant the prompt itself
48
+ // told the model it would run out. "3-5 questions" was also wrong against a
49
+ // schema and normalizer that both accept 1-5; correcting it here is one line.
46
50
  const askStep = interactive
47
51
  ? [
48
52
  '2. If a decision would change the work and you cannot settle it by reading, call',
49
- ' ask_user with 3-5 multiple-choice questions in ONE call. Mark exactly one',
50
- ` option recommended:true and put the reason in its description. You may ask up`,
51
- ` to ${maxAskRounds} rounds; each round costs the user time, so make them count.`,
53
+ ' ask_user with 1-5 multiple-choice questions in ONE call. Mark exactly one option',
54
+ ' recommended:true and put the reason in its description. There is NO round limit -',
55
+ ' ask as many rounds as the work needs - but each round costs the user time, so batch',
56
+ ' the questions and skip anything the workspace already answers.',
52
57
  ]
53
58
  : [
54
59
  '2. If a decision would change the work and you cannot settle it by reading, state',
@@ -76,8 +81,9 @@ export function buildPlanModeBlock(options) {
76
81
  '',
77
82
  'Work in this order:',
78
83
  '1. Follow the project-guidance protocol: read applicable guidance and index navigation',
79
- ' before any source search or read. Then inspect the real code with read_file,',
80
- ' list_dir, glob, grep and relevant skills. Never plan against unread structure.',
84
+ ' before any source search or read. Then run the skill triage described above and load',
85
+ ' any skill that covers this task, BEFORE inspecting the real code with read_file,',
86
+ ' list_dir, glob and grep. Never plan against unread structure.',
81
87
  ...askStep,
82
88
  ...submitStep,
83
89
  '',
@@ -15,7 +15,7 @@ import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agen
15
15
  import type { AgentMode } from './agent-mode.js';
16
16
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
17
17
  import type { FastReview, FastSnapshot } from '../fast/types.js';
18
- import type { CompactionMode, CompactionRecord, CompactionSnapshot, CompactionUiTrigger, ContextUsageSnapshot } from '../compaction/types.js';
18
+ import type { CompactionMode, CompactionPhase, CompactionRecord, CompactionSnapshot, CompactionUiTrigger, ContextUsageSnapshot } from '../compaction/types.js';
19
19
  import type { AcceptanceSnapshot, TodoItem, TodoSnapshot } from '../todo/types.js';
20
20
  import type { JobKind, ServiceSnapshot, ServiceStatus } from '../proc/types.js';
21
21
  import type { RetryPhase } from './retry-view.js';
@@ -237,6 +237,22 @@ export type Entry = {
237
237
  messages: number;
238
238
  charsRemoved: number;
239
239
  };
240
+ /**
241
+ * The stage the LIVE card is in (context-compaction-never-fires C).
242
+ *
243
+ * Absent on a settled card and on every card written before this field
244
+ * existed, which is why the renderer treats it as optional and falls back to
245
+ * the old `summarizing with <model>` line.
246
+ */
247
+ phase?: CompactionPhase;
248
+ /** Set when the cut relaxed `keepRecentTurns` to exist at all (B). */
249
+ keepRelaxed?: boolean;
250
+ retainedTurns?: number;
251
+ /** Messages the relaxed cut left verbatim - the number the card states. */
252
+ retainedMessages?: number;
253
+ keepRecentTurnsUsed?: number;
254
+ /** Messages the digest left out, so they cannot be quoted (A1). */
255
+ elidedMessages?: number;
240
256
  live: boolean;
241
257
  } | {
242
258
  id: string;
@@ -683,6 +699,18 @@ export type ViewAction = {
683
699
  type: 'compactionEnd';
684
700
  record: CompactionRecord;
685
701
  }
702
+ /**
703
+ * The live card's stage (context-compaction-never-fires C).
704
+ *
705
+ * Its own action rather than a field on `compactionStart` because the phases
706
+ * arrive AFTER it, one per step, and because a phase must be able to land on a
707
+ * card that a previous attempt already settled into the same entry id.
708
+ */
709
+ | {
710
+ type: 'compactionPhase';
711
+ index: number;
712
+ phase: CompactionPhase;
713
+ }
686
714
  /**
687
715
  * Compaction spend.
688
716
  *