@aragon-agent/cli 0.6.39 → 0.6.40

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 (69) hide show
  1. package/CHANGELOG.md +2589 -2523
  2. package/README.md +3706 -3614
  3. package/dist/agent/controller.d.ts +103 -9
  4. package/dist/agent/controller.js +196 -19
  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 +14 -2
  13. package/dist/agent/system-prompt.js +7 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +12 -3
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +25 -0
  18. package/dist/commands/subagent-command.js +242 -0
  19. package/dist/compaction/command.js +16 -3
  20. package/dist/compaction/compactor.d.ts +25 -0
  21. package/dist/compaction/compactor.js +111 -10
  22. package/dist/compaction/digest.d.ts +24 -1
  23. package/dist/compaction/digest.js +139 -17
  24. package/dist/compaction/limits.d.ts +21 -0
  25. package/dist/compaction/limits.js +21 -0
  26. package/dist/compaction/reason-text.d.ts +36 -0
  27. package/dist/compaction/reason-text.js +78 -0
  28. package/dist/compaction/types.d.ts +82 -14
  29. package/dist/compaction/wiring.d.ts +13 -8
  30. package/dist/compaction/wiring.js +1 -0
  31. package/dist/config/load.js +50 -3
  32. package/dist/config/schema.d.ts +82 -6
  33. package/dist/config/schema.js +107 -9
  34. package/dist/config/store.js +7 -2
  35. package/dist/proc/log-mirror.d.ts +21 -4
  36. package/dist/proc/log-mirror.js +29 -7
  37. package/dist/skills/service.d.ts +41 -0
  38. package/dist/skills/service.js +62 -0
  39. package/dist/skills/triage.d.ts +194 -0
  40. package/dist/skills/triage.js +393 -0
  41. package/dist/team/forced-dispatch.d.ts +95 -0
  42. package/dist/team/forced-dispatch.js +208 -0
  43. package/dist/team/limits.d.ts +21 -1
  44. package/dist/team/limits.js +21 -1
  45. package/dist/team/normalize.d.ts +15 -0
  46. package/dist/team/normalize.js +21 -4
  47. package/dist/team/prompt.d.ts +6 -3
  48. package/dist/team/prompt.js +9 -5
  49. package/dist/team/task-tool.js +11 -7
  50. package/dist/tools/human-input.d.ts +35 -2
  51. package/dist/tools/human-input.js +38 -0
  52. package/dist/tools/plan-tools.d.ts +20 -12
  53. package/dist/tools/plan-tools.js +24 -17
  54. package/dist/ui/App.js +14 -1
  55. package/dist/ui/StatusBar.d.ts +1 -0
  56. package/dist/ui/StatusBar.js +1 -0
  57. package/dist/ui/Transcript.js +1 -1
  58. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  59. package/dist/ui/entries/CompactionCard.js +65 -4
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +18 -1
  62. package/dist/ui/overlays/SettingsScreen.js +29 -5
  63. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  64. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  65. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  66. package/dist/ui/overlays/model-profile-state.js +12 -0
  67. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  68. package/dist/ui/overlays/team-fanout-state.js +97 -0
  69. package/package.json +2 -2
@@ -36,6 +36,7 @@ import type { CompactionEventListener, CompactionSnapshot, ContextUsageSnapshot
36
36
  import type { AcceptanceItem, TodoEventListener, TodoSnapshot } from '../todo/types.js';
37
37
  import { type HumanInputGate } from '../tools/human-input.js';
38
38
  import { SkillService, type ApprovalGate } from '../skills/service.js';
39
+ import type { SkillRoutingReport } from '../skills/triage.js';
39
40
  import { type ConversationResetOptions, type ConversationResetResult } from './conversation-reset.js';
40
41
  import type { AgentMode } from './agent-mode.js';
41
42
  import type { NoticeLevel } from './reducer.js';
@@ -183,16 +184,19 @@ export interface ControllerDeps {
183
184
  */
184
185
  modelWindows?: ModelWindows;
185
186
  }
186
- /** What `setAgentMode` actually adopted — see `AgentController.setAgentMode`. */
187
+ /**
188
+ * What `setAgentMode` actually adopted — see `AgentController.setAgentMode`.
189
+ *
190
+ * THIS IS ALSO WHAT `/plan status` REPORTS. A `PlanStatus extends AgentModeState`
191
+ * with `askRoundsUsed` / `maxAskRounds` existed until the round cap was deleted;
192
+ * keeping an empty sub-interface around would only leave a comment that every
193
+ * later reader has to work through to learn that nothing is missing.
194
+ */
187
195
  export interface AgentModeState {
188
196
  effective: AgentMode;
189
197
  /** Non-null only while a `plan -> build` switch is waiting for `agent_end`. */
190
198
  pending: AgentMode | null;
191
199
  }
192
- export interface PlanStatus extends AgentModeState {
193
- askRoundsUsed: number;
194
- maxAskRounds: number;
195
- }
196
200
  export interface PreflightResult {
197
201
  ok: boolean;
198
202
  message?: string;
@@ -235,6 +239,21 @@ export declare class AgentController {
235
239
  private readonly skills;
236
240
  private readonly skillsEnabled;
237
241
  private onSkillsChanged?;
242
+ /**
243
+ * This turn's routing result, or `null` when triage is off or nothing scored.
244
+ *
245
+ * It also carries the task it was computed for (`SkillRoutingReport.task`), so
246
+ * there is no separate copy of the turn's text to keep in step with it.
247
+ */
248
+ private turnRouting;
249
+ /** The previous turn's routing, for `/skills route` with no argument. */
250
+ private lastSkillRouting;
251
+ /** Did this turn call `skill(name)` successfully? The only proof of adoption. */
252
+ private skillLoadedThisTurn;
253
+ /** Tool calls this turn, EXCLUDING `skill` / `skill_find` (see §4.2.7). */
254
+ private toolCallsThisTurn;
255
+ /** Skills already reported as "not loaded" — a session says it once (I-T5). */
256
+ private readonly skillTriageNoticed;
238
257
  /** Mutable session cwd — tools resolve relative paths against this. */
239
258
  private cwd;
240
259
  /**
@@ -259,7 +278,20 @@ export declare class AgentController {
259
278
  private readonly structuredResultBlock;
260
279
  private effectiveMode;
261
280
  private pendingMode;
262
- private askRounds;
281
+ /**
282
+ * Fingerprints of the question SETS the user ANSWERED in this turn. Bounded,
283
+ * and the bound is storage hygiene rather than a refusal rule — see
284
+ * `ASK_GUARD_LIMITS`.
285
+ *
286
+ * Replaces a numeric round counter that capped `ask_user` at four calls. There
287
+ * is no cap now; the only thing this prevents is the same batch of questions
288
+ * being put to the user twice, which is noise rather than information.
289
+ *
290
+ * Cleared in `prepareUserTurn` for a NEW turn only. A steer continuation is the
291
+ * same turn — clearing there would disable the guard exactly when a long turn
292
+ * most needs it (§4.1.6).
293
+ */
294
+ private readonly askedFingerprints;
263
295
  /** Static: whether this session could ever render a question overlay. */
264
296
  private readonly planInteractive;
265
297
  private readonly teamRegistered;
@@ -445,6 +477,24 @@ export declare class AgentController {
445
477
  * semantics depend on whether a notice happened to be pending.
446
478
  */
447
479
  private flushPendingNotices;
480
+ /**
481
+ * THE BOUNDARY OF ONE TURN — and there are TWO call sites, which is the reason
482
+ * this takes an explicit flag instead of resetting everything unconditionally.
483
+ *
484
+ * `startPrompt` passes `{ steering: false }`: the user typed something new, so
485
+ * this really is a new turn. `continue()` passes `{ steering: true }` with the
486
+ * CONCATENATED queued steering text, which resumes the SAME turn.
487
+ *
488
+ * A steer continuation must not re-triage (the candidates would change mid-turn,
489
+ * and rebuilding the prompt aborts an idle compaction for no gain) and must not
490
+ * clear the answered-question guard (a long turn interrupted by one steer is
491
+ * exactly when a repeat question is most likely). Hence per-turn state is
492
+ * initialised in ONE branch.
493
+ *
494
+ * `steering` is REQUIRED and positional-second so each call site states which
495
+ * kind of turn it is starting, rather than letting a default make "new turn" the
496
+ * thoughtless option.
497
+ */
448
498
  private prepareUserTurn;
449
499
  /** Cancel pending startup as well as any engine run already in progress. */
450
500
  abort(): void;
@@ -583,9 +633,53 @@ export declare class AgentController {
583
633
  */
584
634
  applyPendingMode(): AgentModeState | null;
585
635
  /** Named read surface for `/plan status`, so no command reaches into internals. */
586
- getPlanStatus(): PlanStatus;
587
- /** Spend one `ask_user` round. Called from the tool, never from the UI. */
588
- private takeAskRound;
636
+ getPlanStatus(): AgentModeState;
637
+ /**
638
+ * READ-ONLY guard probe, called by `ask_user` BEFORE it opens the overlay.
639
+ *
640
+ * No side effect of any kind: a check that also writes cannot be placed before
641
+ * the question without recording an answer that has not happened yet, and the
642
+ * "a cancelled round stays askable" rule would then be unimplementable.
643
+ */
644
+ private hasAnswered;
645
+ /**
646
+ * Record an ANSWERED question set, called by `ask_user` after a real answer.
647
+ *
648
+ * Evicts the oldest entry past `ASK_GUARD_LIMITS.maxRemembered`. That eviction
649
+ * is memory hygiene and NOT a refusal rule — a bounded rejection would be the
650
+ * deleted round cap wearing a bigger number.
651
+ */
652
+ private noteAnswered;
653
+ /** `/skills route` with no argument: the LAST turn's real routing result. */
654
+ getSkillRouting(): SkillRoutingReport | null;
655
+ /**
656
+ * One line when a confidently-matched skill was NOT loaded — and silence every
657
+ * other time (D-8).
658
+ *
659
+ * THE DISCIPLINE IS "SILENT ON SUCCESS, VISIBLE ON FAILURE". Announcing the
660
+ * candidates every turn would be ignored by the third day; announcing nothing
661
+ * ever would make the whole feature unfalsifiable. "A skill covered this task,
662
+ * you did the work anyway, and you never opened it" is precisely the failure this
663
+ * feature exists to fix, so it is the one case that speaks — and it names the
664
+ * command that explains it.
665
+ *
666
+ * SEVEN CONDITIONS, and the first one is the one that is easy to get wrong.
667
+ *
668
+ * 0. This turn was NOT aborted and NOT ended by an error.
669
+ * `agent_end` is emitted unconditionally from a `finally` block in
670
+ * `core/src/engine/agent.ts` and its payload carries no reason, no `aborted`
671
+ * and no `error` field — so "did this turn finish normally?" has to be read
672
+ * from this class's own state. `stopEngine()` sets `abortRequested` before it
673
+ * calls `agent.abort()` and `agent_start` clears it, which makes it an exact
674
+ * answer. Without this condition an Esc mis-touch would produce a line
675
+ * criticising the model for not loading a skill: commentary in response to an
676
+ * interruption.
677
+ *
678
+ * Conditions 5 and 0 are not redundant: 0 decides what counts as the end of a
679
+ * turn, 5 decides whether the turn did any work. With only 5, a tool call made
680
+ * before an abort would still trigger the notice.
681
+ */
682
+ private maybeReportSkillTriage;
589
683
  /**
590
684
  * Run `fn` with the idle watchdog paused.
591
685
  *
@@ -45,12 +45,14 @@ import { TodoStore } from '../todo/store.js';
45
45
  import { createTodoTool } from '../todo/todo-tool.js';
46
46
  import { buildTodoBlock } from '../todo/prompt.js';
47
47
  import { createPlanTools } from '../tools/plan-tools.js';
48
- import { DENY_ALL_HUMAN_INPUT } from '../tools/human-input.js';
48
+ import { ASK_GUARD_LIMITS, DENY_ALL_HUMAN_INPUT, questionFingerprint, } from '../tools/human-input.js';
49
49
  import { getLogger } from '../logging/logger.js';
50
50
  import { createNodeSkillHost } from '../skills/node-host.js';
51
51
  import { SkillService } from '../skills/service.js';
52
52
  import { createSkillTools } from '../skills/tools.js';
53
+ import { SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
53
54
  import { buildSystemPrompt } from './system-prompt.js';
55
+ import { buildSkillTriageBlock } from './skill-triage-prompt.js';
54
56
  import { resetConversationWith, } from './conversation-reset.js';
55
57
  import { archiveClearedConversation, } from '../session/clear-archive.js';
56
58
  /**
@@ -92,6 +94,28 @@ export class AgentController {
92
94
  skills;
93
95
  skillsEnabled;
94
96
  onSkillsChanged;
97
+ // -----------------------------------------------------------------------
98
+ // Skill triage (skill-triage-and-unbounded-ask §4.2.5) — PER-TURN state.
99
+ //
100
+ // Every field here is written in `prepareUserTurn` and cleared on `agent_end`,
101
+ // because a stale candidate set in the prompt is worse than no candidate set:
102
+ // it points the model at a skill chosen for a task it is no longer doing.
103
+ // -----------------------------------------------------------------------
104
+ /**
105
+ * This turn's routing result, or `null` when triage is off or nothing scored.
106
+ *
107
+ * It also carries the task it was computed for (`SkillRoutingReport.task`), so
108
+ * there is no separate copy of the turn's text to keep in step with it.
109
+ */
110
+ turnRouting = null;
111
+ /** The previous turn's routing, for `/skills route` with no argument. */
112
+ lastSkillRouting = null;
113
+ /** Did this turn call `skill(name)` successfully? The only proof of adoption. */
114
+ skillLoadedThisTurn = false;
115
+ /** Tool calls this turn, EXCLUDING `skill` / `skill_find` (see §4.2.7). */
116
+ toolCallsThisTurn = 0;
117
+ /** Skills already reported as "not loaded" — a session says it once (I-T5). */
118
+ skillTriageNoticed = new Set();
95
119
  /** Mutable session cwd — tools resolve relative paths against this. */
96
120
  cwd;
97
121
  /**
@@ -124,7 +148,20 @@ export class AgentController {
124
148
  // -----------------------------------------------------------------------
125
149
  effectiveMode;
126
150
  pendingMode = null;
127
- askRounds = 0;
151
+ /**
152
+ * Fingerprints of the question SETS the user ANSWERED in this turn. Bounded,
153
+ * and the bound is storage hygiene rather than a refusal rule — see
154
+ * `ASK_GUARD_LIMITS`.
155
+ *
156
+ * Replaces a numeric round counter that capped `ask_user` at four calls. There
157
+ * is no cap now; the only thing this prevents is the same batch of questions
158
+ * being put to the user twice, which is noise rather than information.
159
+ *
160
+ * Cleared in `prepareUserTurn` for a NEW turn only. A steer continuation is the
161
+ * same turn — clearing there would disable the guard exactly when a long turn
162
+ * most needs it (§4.1.6).
163
+ */
164
+ askedFingerprints = new Set();
128
165
  /** Static: whether this session could ever render a question overlay. */
129
166
  planInteractive;
130
167
  // -----------------------------------------------------------------------
@@ -491,7 +528,12 @@ export class AgentController {
491
528
  this.jobsRegistered = config.jobs.enabled && config.bash.background;
492
529
  this.jobMirror =
493
530
  this.jobsRegistered && config.jobs.persistLog
494
- ? new JobLogMirror({ root: () => join(getHomeRoot(), 'jobs', this.jobSessionTag) })
531
+ ? new JobLogMirror({
532
+ root: () => join(getHomeRoot(), 'jobs', this.jobSessionTag),
533
+ // NAMED, NOT DERIVED. The mirror sweeps only this directory; the session
534
+ // tagging above is what keeps its own run out of the sweep's reach.
535
+ jobsRoot: () => join(getHomeRoot(), 'jobs'),
536
+ })
495
537
  : undefined;
496
538
  this.procs = new ProcSupervisor({
497
539
  readyTimeoutMs: () => this.config.bash.readyTimeoutMs,
@@ -585,7 +627,13 @@ export class AgentController {
585
627
  // (or 11 with skills) it is today.
586
628
  planTools: createPlanTools({
587
629
  gate: humanInput,
588
- takeAskRound: () => this.takeAskRound(),
630
+ // TWO CALLBACKS, AND THE SPLIT IS LOAD-BEARING (§4.1.2). One
631
+ // read-and-write accessor could only be called before the question
632
+ // (recording an answer nobody gave, which makes "a cancelled round stays
633
+ // askable" impossible) or after it (which cannot stop a repeat). The guard
634
+ // is "read before, write only after a real answer".
635
+ hasAnsweredQuestions: (questions) => this.hasAnswered(questions),
636
+ noteAnsweredQuestions: (questions) => this.noteAnswered(questions),
589
637
  withHumanWait: (fn) => this.withHumanWait(fn),
590
638
  onPlanApproved: () => this.setAgentMode('build', { force: true }),
591
639
  }),
@@ -909,6 +957,17 @@ export class AgentController {
909
957
  this.activeToolCalls += 1;
910
958
  if (event.type === 'tool_execution_end') {
911
959
  this.activeToolCalls = Math.max(0, this.activeToolCalls - 1);
960
+ // The ONLY proof a skill was adopted is a successful `skill` call. Reading
961
+ // the model's prose for a skill name would be a heuristic; this is a fact
962
+ // the executor already reports.
963
+ if (event.toolName === 'skill' && event.isError !== true)
964
+ this.skillLoadedThisTurn = true;
965
+ // `skill` / `skill_find` do not count as "work done": the notice below is
966
+ // for a turn that changed something without reading its skill, and a turn
967
+ // that ONLY searched for a skill is a turn that already tried.
968
+ if (event.toolName !== 'skill' && event.toolName !== 'skill_find') {
969
+ this.toolCallsThisTurn += 1;
970
+ }
912
971
  // THE ONE PUSH SITE. A notice that could not be pushed here is picked up
913
972
  // by the next tool result (the pull channel) or by the next user turn
914
973
  // (the fill channel), so nothing is lost by declining.
@@ -925,6 +984,9 @@ export class AgentController {
925
984
  // required: `turn_start` covers the next turn of the same run, and this
926
985
  // covers a run that ended with a notice stranded in the queue.
927
986
  this.jobRelay?.requeueUndelivered();
987
+ // AFTER the requeue, so a turn that ends with a stranded notice still gets
988
+ // its feedback line. Order between the two is otherwise irrelevant.
989
+ this.maybeReportSkillTriage();
928
990
  }
929
991
  });
930
992
  if (this.fast) {
@@ -973,13 +1035,27 @@ export class AgentController {
973
1035
  const tier = resolveFastTier(config, (id, role) => !!makeGetApiKey(config, role)(id));
974
1036
  const fastAvailable = config === this.config ? this.fast?.available() === true
975
1037
  : this.fast?.isRegistered() && config.fast.enabled && tier.ok;
1038
+ const skillsCatalog = this.skillsEnabled ? this.skills.catalogBlock() : '';
1039
+ // READ LIVE FROM THE SERVICE, never `config.skills` (§4.2.5 / R-12).
1040
+ // `SkillService.updateConfig` replaces the SERVICE's own config object, so
1041
+ // `this.config.skills` goes stale the moment `/skills triage off` runs — and a
1042
+ // stale read would leave the block in the prompt until the next launch, which
1043
+ // looks exactly like a broken switch. Same discipline as `/todo off`.
1044
+ const triageOn = this.skillsEnabled && this.skills.getConfig().triage === true;
1045
+ // An empty catalog means there is nothing to triage against, so the block is
1046
+ // absent rather than present-and-useless (I-T1 / AC-T18).
1047
+ const triageBlock = triageOn && skillsCatalog.length > 0
1048
+ ? buildSkillTriageBlock({ candidates: this.turnRouting?.candidates ?? [] })
1049
+ : '';
976
1050
  return buildSystemPrompt({
977
1051
  cwd: this.cwd,
978
1052
  tools: this.tools,
979
- skillsBlock: this.skillsEnabled ? this.skills.catalogBlock() + this.skills.alwaysBlock() : '',
1053
+ skillsBlock: this.skillsEnabled ? skillsCatalog + this.skills.alwaysBlock() : '',
1054
+ // Immediately after the catalog it refers to, before the plan block whose
1055
+ // first step defers to it (§4.2.4).
1056
+ skillTriageBlock: triageBlock,
980
1057
  agentMode: this.effectiveMode,
981
1058
  planInteractive: this.planInteractive,
982
- planMaxAskRounds: config.planModeMaxAskRounds,
983
1059
  // BOTH flags, and both are required (§4.5). `teamRegistered` false means
984
1060
  // there is no `task` tool to advertise; `teamEnabled` false means the user
985
1061
  // turned it off for this session. Advertising in either case would
@@ -1183,7 +1259,7 @@ export class AgentController {
1183
1259
  }
1184
1260
  /** Prepare before clearing the plan, then enter the engine without yielding. */
1185
1261
  async startPrompt(text, options, request) {
1186
- this.prepareUserTurn(text);
1262
+ this.prepareUserTurn(text, { steering: false });
1187
1263
  if (request !== this.startupSequence) {
1188
1264
  return { status: 'not-started', reason: 'cancelled' };
1189
1265
  }
@@ -1212,10 +1288,39 @@ export class AgentController {
1212
1288
  // failure AC-25 exists to prevent.
1213
1289
  this.jobRelay?.flushForTurn();
1214
1290
  }
1215
- prepareUserTurn(text) {
1291
+ /**
1292
+ * THE BOUNDARY OF ONE TURN — and there are TWO call sites, which is the reason
1293
+ * this takes an explicit flag instead of resetting everything unconditionally.
1294
+ *
1295
+ * `startPrompt` passes `{ steering: false }`: the user typed something new, so
1296
+ * this really is a new turn. `continue()` passes `{ steering: true }` with the
1297
+ * CONCATENATED queued steering text, which resumes the SAME turn.
1298
+ *
1299
+ * A steer continuation must not re-triage (the candidates would change mid-turn,
1300
+ * and rebuilding the prompt aborts an idle compaction for no gain) and must not
1301
+ * clear the answered-question guard (a long turn interrupted by one steer is
1302
+ * exactly when a repeat question is most likely). Hence per-turn state is
1303
+ * initialised in ONE branch.
1304
+ *
1305
+ * `steering` is REQUIRED and positional-second so each call site states which
1306
+ * kind of turn it is starting, rather than letting a default make "new turn" the
1307
+ * thoughtless option.
1308
+ */
1309
+ prepareUserTurn(text, options) {
1216
1310
  if (this.skillsEnabled)
1217
1311
  this.skills.beginUserTurn();
1218
- this.askRounds = 0;
1312
+ if (!options.steering) {
1313
+ this.askedFingerprints.clear();
1314
+ this.turnRouting = this.skillsEnabled ? this.skills.triage(text) : null;
1315
+ this.skillLoadedThisTurn = false;
1316
+ this.toolCallsThisTurn = 0;
1317
+ // BEFORE the engine is entered, because `runAgentLoop` captures the system
1318
+ // prompt BY VALUE on its way in — a rebuild after `agent.prompt()` would be
1319
+ // one turn late. This is also why steer does not rebuild: `continue()` runs
1320
+ // while the loop already holds its snapshot, so the cost would be an idle
1321
+ // compaction aborted for a prompt nothing has read yet (§4.2.5).
1322
+ this.rebuildSystemPrompt();
1323
+ }
1219
1324
  this.fast?.setGoal(text);
1220
1325
  }
1221
1326
  /** Cancel pending startup as well as any engine run already in progress. */
@@ -1393,7 +1498,7 @@ export class AgentController {
1393
1498
  }
1394
1499
  if (this.pendingUserSteering.size > 0) {
1395
1500
  const request = ++this.startupSequence;
1396
- this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'));
1501
+ this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'), { steering: true });
1397
1502
  if (request !== this.startupSequence) {
1398
1503
  return { status: 'not-started', reason: 'cancelled' };
1399
1504
  }
@@ -1494,18 +1599,90 @@ export class AgentController {
1494
1599
  return {
1495
1600
  effective: this.effectiveMode,
1496
1601
  pending: this.pendingMode,
1497
- askRoundsUsed: this.askRounds,
1498
- maxAskRounds: this.config.planModeMaxAskRounds,
1499
1602
  };
1500
1603
  }
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 };
1604
+ /**
1605
+ * READ-ONLY guard probe, called by `ask_user` BEFORE it opens the overlay.
1606
+ *
1607
+ * No side effect of any kind: a check that also writes cannot be placed before
1608
+ * the question without recording an answer that has not happened yet, and the
1609
+ * "a cancelled round stays askable" rule would then be unimplementable.
1610
+ */
1611
+ hasAnswered(questions) {
1612
+ return this.askedFingerprints.has(questionFingerprint(questions));
1613
+ }
1614
+ /**
1615
+ * Record an ANSWERED question set, called by `ask_user` after a real answer.
1616
+ *
1617
+ * Evicts the oldest entry past `ASK_GUARD_LIMITS.maxRemembered`. That eviction
1618
+ * is memory hygiene and NOT a refusal rule — a bounded rejection would be the
1619
+ * deleted round cap wearing a bigger number.
1620
+ */
1621
+ noteAnswered(questions) {
1622
+ this.askedFingerprints.add(questionFingerprint(questions));
1623
+ while (this.askedFingerprints.size > ASK_GUARD_LIMITS.maxRemembered) {
1624
+ const oldest = this.askedFingerprints.values().next();
1625
+ if (oldest.done === true)
1626
+ break;
1627
+ this.askedFingerprints.delete(oldest.value);
1628
+ }
1629
+ }
1630
+ /** `/skills route` with no argument: the LAST turn's real routing result. */
1631
+ getSkillRouting() {
1632
+ return this.lastSkillRouting;
1633
+ }
1634
+ /**
1635
+ * One line when a confidently-matched skill was NOT loaded — and silence every
1636
+ * other time (D-8).
1637
+ *
1638
+ * THE DISCIPLINE IS "SILENT ON SUCCESS, VISIBLE ON FAILURE". Announcing the
1639
+ * candidates every turn would be ignored by the third day; announcing nothing
1640
+ * ever would make the whole feature unfalsifiable. "A skill covered this task,
1641
+ * you did the work anyway, and you never opened it" is precisely the failure this
1642
+ * feature exists to fix, so it is the one case that speaks — and it names the
1643
+ * command that explains it.
1644
+ *
1645
+ * SEVEN CONDITIONS, and the first one is the one that is easy to get wrong.
1646
+ *
1647
+ * 0. This turn was NOT aborted and NOT ended by an error.
1648
+ * `agent_end` is emitted unconditionally from a `finally` block in
1649
+ * `core/src/engine/agent.ts` and its payload carries no reason, no `aborted`
1650
+ * and no `error` field — so "did this turn finish normally?" has to be read
1651
+ * from this class's own state. `stopEngine()` sets `abortRequested` before it
1652
+ * calls `agent.abort()` and `agent_start` clears it, which makes it an exact
1653
+ * answer. Without this condition an Esc mis-touch would produce a line
1654
+ * criticising the model for not loading a skill: commentary in response to an
1655
+ * interruption.
1656
+ *
1657
+ * Conditions 5 and 0 are not redundant: 0 decides what counts as the end of a
1658
+ * turn, 5 decides whether the turn did any work. With only 5, a tool call made
1659
+ * before an abort would still trigger the notice.
1660
+ */
1661
+ maybeReportSkillTriage() {
1662
+ const routing = this.turnRouting;
1663
+ const best = routing?.candidates[0];
1664
+ const shouldReport = this.abortRequested === false &&
1665
+ this.skillsEnabled &&
1666
+ this.skills.getConfig().triage === true &&
1667
+ routing !== null &&
1668
+ best !== undefined &&
1669
+ best.score >= SKILL_TRIAGE_LIMITS.confidentScore &&
1670
+ this.skillLoadedThisTurn === false &&
1671
+ this.toolCallsThisTurn >= 1 &&
1672
+ !this.skillTriageNoticed.has(best.name);
1673
+ if (shouldReport && best !== undefined) {
1674
+ this.skillTriageNoticed.add(best.name);
1675
+ this.notifyHostFn?.('info', `No skill was loaded; the closest installed match was "${best.name}". ` +
1676
+ 'Run /skills route to see why.');
1506
1677
  }
1507
- this.askRounds += 1;
1508
- return { ok: true, used: this.askRounds, remaining: max - this.askRounds, max };
1678
+ // The routing result outlives the turn so `/skills route` can explain it; the
1679
+ // candidates themselves must not, or the next `rebuildSystemPrompt()` would
1680
+ // resurrect a set computed for a task that is over.
1681
+ if (routing !== null)
1682
+ this.lastSkillRouting = routing;
1683
+ this.turnRouting = null;
1684
+ this.skillLoadedThisTurn = false;
1685
+ this.toolCallsThisTurn = 0;
1509
1686
  }
1510
1687
  /**
1511
1688
  * Run `fn` with the idle watchdog paused.
@@ -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
  *