@aragon-agent/cli 0.6.38 → 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 (209) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +544 -11
  3. package/dist/agent/controller.d.ts +245 -10
  4. package/dist/agent/controller.js +367 -22
  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 +70 -2
  9. package/dist/agent/reducer.js +59 -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 +28 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +106 -32
  15. package/dist/commands/builtins.js +24 -3
  16. package/dist/commands/keys-command.d.ts +23 -0
  17. package/dist/commands/keys-command.js +38 -0
  18. package/dist/commands/registry.d.ts +39 -0
  19. package/dist/commands/skills.js +86 -1
  20. package/dist/commands/subagent-command.d.ts +25 -0
  21. package/dist/commands/subagent-command.js +242 -0
  22. package/dist/commands/task-command.d.ts +39 -0
  23. package/dist/commands/task-command.js +102 -0
  24. package/dist/compaction/command.js +16 -3
  25. package/dist/compaction/compactor.d.ts +25 -0
  26. package/dist/compaction/compactor.js +111 -10
  27. package/dist/compaction/digest.d.ts +24 -1
  28. package/dist/compaction/digest.js +139 -17
  29. package/dist/compaction/limits.d.ts +21 -0
  30. package/dist/compaction/limits.js +21 -0
  31. package/dist/compaction/reason-text.d.ts +36 -0
  32. package/dist/compaction/reason-text.js +78 -0
  33. package/dist/compaction/types.d.ts +82 -14
  34. package/dist/compaction/wiring.d.ts +13 -8
  35. package/dist/compaction/wiring.js +1 -0
  36. package/dist/config/cli-commands.d.ts +23 -0
  37. package/dist/config/cli-commands.js +36 -0
  38. package/dist/config/env.js +17 -4
  39. package/dist/config/load.js +55 -3
  40. package/dist/config/schema.d.ts +125 -10
  41. package/dist/config/schema.js +138 -14
  42. package/dist/config/store.js +23 -3
  43. package/dist/diagnostics/doctor.d.ts +9 -0
  44. package/dist/diagnostics/doctor.js +34 -0
  45. package/dist/diagnostics/info.d.ts +71 -0
  46. package/dist/diagnostics/info.js +91 -5
  47. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  48. package/dist/exec/bridge/caller-tools.js +213 -0
  49. package/dist/exec/bridge/decisions.d.ts +78 -0
  50. package/dist/exec/bridge/decisions.js +116 -0
  51. package/dist/exec/bridge/human-answers.d.ts +38 -0
  52. package/dist/exec/bridge/human-answers.js +164 -0
  53. package/dist/exec/bridge/limits.d.ts +68 -0
  54. package/dist/exec/bridge/limits.js +68 -0
  55. package/dist/exec/bridge/ndjson.d.ts +17 -0
  56. package/dist/exec/bridge/ndjson.js +83 -0
  57. package/dist/exec/bridge/pending.d.ts +70 -0
  58. package/dist/exec/bridge/pending.js +132 -0
  59. package/dist/exec/bridge/protocol.d.ts +346 -0
  60. package/dist/exec/bridge/protocol.js +256 -0
  61. package/dist/exec/bridge/server.d.ts +142 -0
  62. package/dist/exec/bridge/server.js +502 -0
  63. package/dist/exec/bridge/source.d.ts +58 -0
  64. package/dist/exec/bridge/source.js +145 -0
  65. package/dist/exec/bridge/writer.d.ts +26 -0
  66. package/dist/exec/bridge/writer.js +156 -0
  67. package/dist/exec/cli-commands.js +30 -2
  68. package/dist/exec/emitter.d.ts +16 -2
  69. package/dist/exec/emitter.js +26 -6
  70. package/dist/exec/events.d.ts +465 -10
  71. package/dist/exec/events.js +47 -1
  72. package/dist/exec/hooks/dispatch.d.ts +76 -0
  73. package/dist/exec/hooks/dispatch.js +169 -0
  74. package/dist/exec/hooks/index.d.ts +83 -0
  75. package/dist/exec/hooks/index.js +205 -0
  76. package/dist/exec/hooks/limits.d.ts +56 -0
  77. package/dist/exec/hooks/limits.js +49 -0
  78. package/dist/exec/hooks/registry.d.ts +49 -0
  79. package/dist/exec/hooks/registry.js +91 -0
  80. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  81. package/dist/exec/hooks/tool-wrapper.js +154 -0
  82. package/dist/exec/hooks/turn.d.ts +61 -0
  83. package/dist/exec/hooks/turn.js +59 -0
  84. package/dist/exec/hooks/validate.d.ts +85 -0
  85. package/dist/exec/hooks/validate.js +310 -0
  86. package/dist/exec/index.d.ts +130 -4
  87. package/dist/exec/index.js +501 -10
  88. package/dist/exec/options.d.ts +124 -2
  89. package/dist/exec/options.js +413 -6
  90. package/dist/exec/permission.d.ts +9 -1
  91. package/dist/exec/permission.js +7 -1
  92. package/dist/exec/request/limits.d.ts +51 -0
  93. package/dist/exec/request/limits.js +49 -0
  94. package/dist/exec/request/scope.d.ts +106 -0
  95. package/dist/exec/request/scope.js +149 -0
  96. package/dist/exec/result/limits.d.ts +74 -0
  97. package/dist/exec/result/limits.js +72 -0
  98. package/dist/exec/result/prompt.d.ts +28 -0
  99. package/dist/exec/result/prompt.js +74 -0
  100. package/dist/exec/result/schema.d.ts +99 -0
  101. package/dist/exec/result/schema.js +464 -0
  102. package/dist/exec/result/sink.d.ts +56 -0
  103. package/dist/exec/result/sink.js +50 -0
  104. package/dist/exec/result/tool.d.ts +37 -0
  105. package/dist/exec/result/tool.js +118 -0
  106. package/dist/exec/runner.d.ts +395 -1
  107. package/dist/exec/runner.js +860 -8
  108. package/dist/exec/threads/aggregate.d.ts +34 -0
  109. package/dist/exec/threads/aggregate.js +30 -0
  110. package/dist/exec/threads/capacity.d.ts +23 -0
  111. package/dist/exec/threads/capacity.js +38 -0
  112. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  113. package/dist/exec/threads/dispatch-source.js +106 -0
  114. package/dist/exec/threads/execute-threads.d.ts +53 -0
  115. package/dist/exec/threads/execute-threads.js +234 -0
  116. package/dist/exec/threads/gate.d.ts +42 -0
  117. package/dist/exec/threads/gate.js +81 -0
  118. package/dist/exec/threads/identity.d.ts +36 -0
  119. package/dist/exec/threads/identity.js +67 -0
  120. package/dist/exec/threads/interrupt.d.ts +29 -0
  121. package/dist/exec/threads/interrupt.js +79 -0
  122. package/dist/exec/threads/limits.d.ts +58 -0
  123. package/dist/exec/threads/limits.js +35 -0
  124. package/dist/exec/threads/outcome.d.ts +47 -0
  125. package/dist/exec/threads/outcome.js +26 -0
  126. package/dist/exec/threads/registry.d.ts +81 -0
  127. package/dist/exec/threads/registry.js +427 -0
  128. package/dist/exec/threads/runtime.d.ts +107 -0
  129. package/dist/exec/threads/runtime.js +523 -0
  130. package/dist/exec/threads/thread-source.d.ts +20 -0
  131. package/dist/exec/threads/thread-source.js +134 -0
  132. package/dist/fast/limits.d.ts +54 -0
  133. package/dist/fast/limits.js +54 -0
  134. package/dist/fast/summary-call.d.ts +72 -0
  135. package/dist/fast/summary-call.js +133 -0
  136. package/dist/fast/summary-prompt.d.ts +106 -0
  137. package/dist/fast/summary-prompt.js +320 -0
  138. package/dist/fast/task-summary-types.d.ts +66 -0
  139. package/dist/fast/task-summary-types.js +17 -0
  140. package/dist/logging/logger.d.ts +1 -1
  141. package/dist/proc/limits.d.ts +7 -0
  142. package/dist/proc/limits.js +7 -0
  143. package/dist/proc/log-mirror.d.ts +21 -4
  144. package/dist/proc/log-mirror.js +29 -7
  145. package/dist/proc/output-streams.d.ts +50 -0
  146. package/dist/proc/output-streams.js +54 -0
  147. package/dist/skills/service.d.ts +41 -0
  148. package/dist/skills/service.js +62 -0
  149. package/dist/skills/triage.d.ts +194 -0
  150. package/dist/skills/triage.js +393 -0
  151. package/dist/team/forced-dispatch.d.ts +95 -0
  152. package/dist/team/forced-dispatch.js +208 -0
  153. package/dist/team/limits.d.ts +21 -1
  154. package/dist/team/limits.js +21 -1
  155. package/dist/team/normalize.d.ts +15 -0
  156. package/dist/team/normalize.js +21 -4
  157. package/dist/team/prompt.d.ts +6 -3
  158. package/dist/team/prompt.js +9 -5
  159. package/dist/team/runtime.d.ts +9 -0
  160. package/dist/team/runtime.js +8 -0
  161. package/dist/team/subagent.d.ts +15 -0
  162. package/dist/team/subagent.js +45 -1
  163. package/dist/team/task-tool.js +11 -7
  164. package/dist/tools/bash-tool.d.ts +10 -0
  165. package/dist/tools/bash-tool.js +71 -6
  166. package/dist/tools/human-input.d.ts +35 -2
  167. package/dist/tools/human-input.js +38 -0
  168. package/dist/tools/index.d.ts +93 -1
  169. package/dist/tools/index.js +112 -3
  170. package/dist/tools/plan-tools.d.ts +20 -12
  171. package/dist/tools/plan-tools.js +24 -17
  172. package/dist/tools/tool-gate.d.ts +100 -0
  173. package/dist/tools/tool-gate.js +87 -0
  174. package/dist/ui/App.js +238 -12
  175. package/dist/ui/Header.d.ts +28 -16
  176. package/dist/ui/Header.js +21 -19
  177. package/dist/ui/StatusBar.d.ts +1 -0
  178. package/dist/ui/StatusBar.js +1 -0
  179. package/dist/ui/Transcript.js +1 -1
  180. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  181. package/dist/ui/entries/CompactionCard.js +65 -4
  182. package/dist/ui/interaction-hints.js +6 -0
  183. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  184. package/dist/ui/layout/OverlayFrame.js +19 -4
  185. package/dist/ui/layout/header-hints.d.ts +55 -62
  186. package/dist/ui/layout/header-hints.js +59 -166
  187. package/dist/ui/layout/header-left.d.ts +115 -0
  188. package/dist/ui/layout/header-left.js +206 -0
  189. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  190. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  191. package/dist/ui/layout/status-detail-layout.js +5 -1
  192. package/dist/ui/layout/status-layout.d.ts +10 -0
  193. package/dist/ui/layout/status-layout.js +18 -1
  194. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  195. package/dist/ui/layout/task-summary-cell.js +65 -0
  196. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  197. package/dist/ui/overlays/HelpOverlay.js +29 -2
  198. package/dist/ui/overlays/SettingsScreen.js +29 -5
  199. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  200. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  201. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  202. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  203. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  204. package/dist/ui/overlays/model-profile-state.js +12 -0
  205. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  206. package/dist/ui/overlays/team-fanout-state.js +97 -0
  207. package/dist/ui/shortcuts.d.ts +81 -0
  208. package/dist/ui/shortcuts.js +221 -0
  209. package/package.json +2 -2
@@ -11,7 +11,7 @@ import { resolveModelProfileState } from '../config/model-profile-resolution.js'
11
11
  import { makeGetApiKey } from '../config/load.js';
12
12
  import { updatePersistedConfig } from '../config/store.js';
13
13
  import { ModelWindows } from '../config/model-windows.js';
14
- import { createBuiltinTools } from '../tools/index.js';
14
+ import { createBuiltinTools, gatedToolTimeouts, } from '../tools/index.js';
15
15
  import { createFileChangeStore } from '../tools/file-change-store.js';
16
16
  import { createToolOutputStore, } from '../tools/tool-output-store.js';
17
17
  import { TeamRuntime } from '../team/runtime.js';
@@ -34,18 +34,25 @@ import { getHomeRoot } from '../config/app-paths.js';
34
34
  import { addSignalHook } from '../logging/install.js';
35
35
  import { describeFastTierProblem, fastProviderOf, resolveFastTier } from '../fast/resolve.js';
36
36
  import { FastWiring, offFastStatus } from '../fast/wiring.js';
37
+ // ONE-SHOT FAST CALLS (tui-shortcut-drawer-and-task-summary §6.1). The TYPES come
38
+ // from `fast/task-summary-types.ts` and only `fast/` produces them, so this module
39
+ // never learns how a label is made - it owns the controller and the epoch, which
40
+ // is the pair `runTaskSummaryCall` is forbidden to own (see its header).
41
+ import { createTaskSummaryDeps, runTaskSummaryCall } from '../fast/summary-call.js';
37
42
  import { CompactionWiring, offCompactionSnapshot } from '../compaction/wiring.js';
38
43
  import { ContextMeter } from '../compaction/meter.js';
39
44
  import { TodoStore } from '../todo/store.js';
40
45
  import { createTodoTool } from '../todo/todo-tool.js';
41
46
  import { buildTodoBlock } from '../todo/prompt.js';
42
47
  import { createPlanTools } from '../tools/plan-tools.js';
43
- 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';
44
49
  import { getLogger } from '../logging/logger.js';
45
50
  import { createNodeSkillHost } from '../skills/node-host.js';
46
51
  import { SkillService } from '../skills/service.js';
47
52
  import { createSkillTools } from '../skills/tools.js';
53
+ import { SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
48
54
  import { buildSystemPrompt } from './system-prompt.js';
55
+ import { buildSkillTriageBlock } from './skill-triage-prompt.js';
49
56
  import { resetConversationWith, } from './conversation-reset.js';
50
57
  import { archiveClearedConversation, } from '../session/clear-archive.js';
51
58
  /**
@@ -74,9 +81,41 @@ export class AgentController {
74
81
  modelMetadataAbort = new AbortController();
75
82
  disposed = false;
76
83
  tools;
84
+ /**
85
+ * Tools the CALLER serves, replaced on every `tool_register` (I-13).
86
+ *
87
+ * A SECOND ARRAY, NOT ONE GROWN ONE. `this.tools` is read by `listTools()`,
88
+ * `toolNames()`, `composeSystemPrompt()` and three "fixed at construction" flags
89
+ * (bash_output/bash_kill, task, todo_write). Growing it would silently change
90
+ * what those flags mean; keeping the caller's tools beside it gives one honest
91
+ * rule - the REGISTRY is the union, `this.tools` stays the host's own set.
92
+ */
93
+ callerTools = [];
77
94
  skills;
78
95
  skillsEnabled;
79
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();
80
119
  /** Mutable session cwd — tools resolve relative paths against this. */
81
120
  cwd;
82
121
  /**
@@ -88,6 +127,17 @@ export class AgentController {
88
127
  * Empty on every path but `aragon exec`.
89
128
  */
90
129
  appendSystemPrompt;
130
+ /**
131
+ * The `<structured_result>` block (headless-request-scope-and-typed-result
132
+ * section 2.3.8), or `''`.
133
+ *
134
+ * A FIELD FOR THE SAME REASON `appendSystemPrompt` IS ONE: `composeSystemPrompt()`
135
+ * runs again on every rebuild, so a value spliced once at construction would vanish
136
+ * at the first `/cwd` change, skills rescan or mode flip - and the model would then
137
+ * be asked to submit a result it had never been told about. Empty on every path but
138
+ * `aragon exec --result-schema`.
139
+ */
140
+ structuredResultBlock;
91
141
  // -----------------------------------------------------------------------
92
142
  // Plan mode (§3.1) — THE CONTROLLER OWNS THE MODE.
93
143
  //
@@ -98,7 +148,20 @@ export class AgentController {
98
148
  // -----------------------------------------------------------------------
99
149
  effectiveMode;
100
150
  pendingMode = null;
101
- 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();
102
165
  /** Static: whether this session could ever render a question overlay. */
103
166
  planInteractive;
104
167
  // -----------------------------------------------------------------------
@@ -308,6 +371,10 @@ export class AgentController {
308
371
  this.config = config;
309
372
  this.cwd = config.cwd;
310
373
  this.appendSystemPrompt = deps.appendSystemPrompt ?? '';
374
+ // ABOVE `createBuiltinTools` FOR THE REASON THE PROMPT FIELD IS: the tool has to
375
+ // exist before the array is built, and the block has to exist before the prompt is
376
+ // composed - and both are read again on every rebuild.
377
+ this.structuredResultBlock = deps.structuredResultBlock ?? '';
311
378
  const humanInput = deps.humanInput ?? DENY_ALL_HUMAN_INPUT;
312
379
  this.planInteractive = humanInput.neverPrompts !== true;
313
380
  this.effectiveMode = config.startInPlanMode ? 'plan' : 'build';
@@ -431,6 +498,14 @@ export class AgentController {
431
498
  // mid-dispatch and abort it at `idleTimeoutMs`. The queue pauses the
432
499
  // CHILD's watchdog instead, which is the one that needs it (P0-4).
433
500
  ...(confirm ? { humanQueue: new TeamHumanQueue(confirm) } : {}),
501
+ // SITE ONE OF TWO FOR THE CHILD GATE (headless-host-bridge 2.3.7). The
502
+ // second is `runtime.ts::subDeps`; declaring it here and forgetting that
503
+ // one type-checks and forwards nothing, and every child would then run
504
+ // unapproved while the caller believes it is approving.
505
+ ...(deps.toolGateForChild ? { toolGateFor: deps.toolGateForChild } : {}),
506
+ ...(deps.decisionBudgetMs !== undefined
507
+ ? { decisionBudgetMs: deps.decisionBudgetMs }
508
+ : {}),
434
509
  // CHILDREN INHERIT THE POLICY (§4.2 invariant 3 / R-4 / AC-12).
435
510
  // Without this line `--deny-tool bash` is bypassed by one `task` call
436
511
  // and nothing anywhere reports it. Spread conditionally so a session
@@ -453,7 +528,12 @@ export class AgentController {
453
528
  this.jobsRegistered = config.jobs.enabled && config.bash.background;
454
529
  this.jobMirror =
455
530
  this.jobsRegistered && config.jobs.persistLog
456
- ? 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
+ })
457
537
  : undefined;
458
538
  this.procs = new ProcSupervisor({
459
539
  readyTimeoutMs: () => this.config.bash.readyTimeoutMs,
@@ -547,7 +627,13 @@ export class AgentController {
547
627
  // (or 11 with skills) it is today.
548
628
  planTools: createPlanTools({
549
629
  gate: humanInput,
550
- 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),
551
637
  withHumanWait: (fn) => this.withHumanWait(fn),
552
638
  onPlanApproved: () => this.setAgentMode('build', { force: true }),
553
639
  }),
@@ -599,6 +685,15 @@ export class AgentController {
599
685
  }),
600
686
  ]
601
687
  : [],
688
+ // SPREAD, NEVER `resultTool: deps.resultTool` (the D-12 lesson, and the shape
689
+ // every optional dep in this bag follows). With no schema the options bag has no
690
+ // such key, so `createBuiltinTools` adds nothing and the tool array is the one it
691
+ // builds today - which is what makes I-S2 provable by object identity.
692
+ ...(deps.resultTool ? { resultTool: deps.resultTool } : {}),
693
+ // THE HOOK PAIR, SPREAD FOR THE SAME REASON AND AT THE SAME SITE (D-12): a dep that
694
+ // is declared but not forwarded is dropped in silence, and the only symptom is that
695
+ // the caller's hooks never fire.
696
+ ...(deps.hookRunner ? { hookRunner: deps.hookRunner } : {}),
602
697
  // The PULL port. Absent when jobs are off, which leaves every tool object
603
698
  // untouched and provable by identity.
604
699
  ...(this.jobsRegistered
@@ -663,6 +758,21 @@ export class AgentController {
663
758
  // today — which is what makes "the TUI and `-p` are unchanged" provable by
664
759
  // identity rather than by inspection.
665
760
  ...(deps.permission ? { permission: deps.permission } : {}),
761
+ // THE CALLER'S APPROVAL GATE, spread the same way and for the same reason
762
+ // (headless-host-bridge section 2.3.3): with no gate the options bag has no
763
+ // such key, so `createBuiltinTools` never enters its mapping branch and the
764
+ // tool OBJECTS are the ones it builds today.
765
+ //
766
+ // THE PAUSE IS `withHumanWait`, NOT A SECOND WRAPPER. `withPausedWatchdog` is
767
+ // documented as not reentrant, and this is the same function the plan tools
768
+ // already receive - so an approval wait and a question wait can never nest
769
+ // two pauses and resume the watchdog one level early.
770
+ ...(deps.toolGate
771
+ ? {
772
+ toolGate: deps.toolGate,
773
+ withToolWait: (fn) => this.withHumanWait(fn),
774
+ }
775
+ : {}),
666
776
  // Omitted entirely when skills are off, which is what leaves the seven
667
777
  // built-ins unwrapped and AC-G17 provable by object identity.
668
778
  ...(this.skillsEnabled
@@ -747,6 +857,22 @@ export class AgentController {
747
857
  toolTimeoutOverrides: {
748
858
  ask_user: config.planModeHumanTimeoutMs,
749
859
  submit_plan: config.planModeHumanTimeoutMs,
860
+ // THE TWO PHASES, ADDED (headless-host-bridge §2.3.4). The executor
861
+ // arms its timer BEFORE the approval wrapper runs, so a ceiling of
862
+ // `toolTimeoutMs` alone would leave the tool less than its configured
863
+ // budget by exactly the time a human took to answer. ADMITTED
864
+ // OVER-GRANT: a caller that answers instantly still gets the sum. The
865
+ // opposite trade - charging the wait to the tool - would make "the
866
+ // human thought for two minutes" read as "this command is too slow",
867
+ // and a tool timeout is supposed to mean the latter.
868
+ //
869
+ // THE KEY SET IS `CALLER_GATED_TOOLS`, the same constant
870
+ // `ToolGate.applies` is built from (I-6): two lists here would drift,
871
+ // and the drift would be a gated tool with no wait budget - a
872
+ // five-minute approval that kills its own tool at 180 s.
873
+ ...(deps.decisionBudgetMs !== undefined
874
+ ? gatedToolTimeouts(config.toolTimeoutMs, deps.decisionBudgetMs)
875
+ : {}),
750
876
  // A fan-out is not a hung tool either. Like the two above, this
751
877
  // number only bites BECAUSE `task` races `ctx.signal` — the
752
878
  // executor's timeout is cooperative, so an override is inert for any
@@ -831,6 +957,17 @@ export class AgentController {
831
957
  this.activeToolCalls += 1;
832
958
  if (event.type === 'tool_execution_end') {
833
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
+ }
834
971
  // THE ONE PUSH SITE. A notice that could not be pushed here is picked up
835
972
  // by the next tool result (the pull channel) or by the next user turn
836
973
  // (the fill channel), so nothing is lost by declining.
@@ -847,6 +984,9 @@ export class AgentController {
847
984
  // required: `turn_start` covers the next turn of the same run, and this
848
985
  // covers a run that ended with a notice stranded in the queue.
849
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();
850
990
  }
851
991
  });
852
992
  if (this.fast) {
@@ -895,13 +1035,27 @@ export class AgentController {
895
1035
  const tier = resolveFastTier(config, (id, role) => !!makeGetApiKey(config, role)(id));
896
1036
  const fastAvailable = config === this.config ? this.fast?.available() === true
897
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
+ : '';
898
1050
  return buildSystemPrompt({
899
1051
  cwd: this.cwd,
900
1052
  tools: this.tools,
901
- 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,
902
1057
  agentMode: this.effectiveMode,
903
1058
  planInteractive: this.planInteractive,
904
- planMaxAskRounds: config.planModeMaxAskRounds,
905
1059
  // BOTH flags, and both are required (§4.5). `teamRegistered` false means
906
1060
  // there is no `task` tool to advertise; `teamEnabled` false means the user
907
1061
  // turned it off for this session. Advertising in either case would
@@ -946,6 +1100,11 @@ export class AgentController {
946
1100
  // tools that make its promises achievable are registered. With the flag off
947
1101
  // the prompt is byte-identical to the pre-feature text (I-3).
948
1102
  jobsBlock: this.jobsRegistered ? buildBackgroundJobsBlock() : '',
1103
+ // FROM THE FIELD, so the block survives every rebuild for the reason the field's
1104
+ // own comment records. Empty everywhere but `aragon exec --result-schema`, and
1105
+ // `buildSystemPrompt` splices it conditionally - so every existing prompt is
1106
+ // byte-identical (I-S1).
1107
+ structuredResultBlock: this.structuredResultBlock,
949
1108
  // Read from the FIELD on every rebuild, for the reason the field's own
950
1109
  // comment gives. Empty everywhere but `aragon exec`, and `buildSystemPrompt`
951
1110
  // splices it conditionally, so every existing prompt is byte-identical.
@@ -1100,7 +1259,7 @@ export class AgentController {
1100
1259
  }
1101
1260
  /** Prepare before clearing the plan, then enter the engine without yielding. */
1102
1261
  async startPrompt(text, options, request) {
1103
- this.prepareUserTurn(text);
1262
+ this.prepareUserTurn(text, { steering: false });
1104
1263
  if (request !== this.startupSequence) {
1105
1264
  return { status: 'not-started', reason: 'cancelled' };
1106
1265
  }
@@ -1129,10 +1288,39 @@ export class AgentController {
1129
1288
  // failure AC-25 exists to prevent.
1130
1289
  this.jobRelay?.flushForTurn();
1131
1290
  }
1132
- 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) {
1133
1310
  if (this.skillsEnabled)
1134
1311
  this.skills.beginUserTurn();
1135
- 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
+ }
1136
1324
  this.fast?.setGoal(text);
1137
1325
  }
1138
1326
  /** Cancel pending startup as well as any engine run already in progress. */
@@ -1310,7 +1498,7 @@ export class AgentController {
1310
1498
  }
1311
1499
  if (this.pendingUserSteering.size > 0) {
1312
1500
  const request = ++this.startupSequence;
1313
- this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'));
1501
+ this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'), { steering: true });
1314
1502
  if (request !== this.startupSequence) {
1315
1503
  return { status: 'not-started', reason: 'cancelled' };
1316
1504
  }
@@ -1411,18 +1599,90 @@ export class AgentController {
1411
1599
  return {
1412
1600
  effective: this.effectiveMode,
1413
1601
  pending: this.pendingMode,
1414
- askRoundsUsed: this.askRounds,
1415
- maxAskRounds: this.config.planModeMaxAskRounds,
1416
1602
  };
1417
1603
  }
1418
- /** Spend one `ask_user` round. Called from the tool, never from the UI. */
1419
- takeAskRound() {
1420
- const max = this.config.planModeMaxAskRounds;
1421
- if (this.askRounds >= max) {
1422
- 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.');
1423
1677
  }
1424
- this.askRounds += 1;
1425
- 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;
1426
1686
  }
1427
1687
  /**
1428
1688
  * Run `fn` with the idle watchdog paused.
@@ -2273,6 +2533,60 @@ export class AgentController {
2273
2533
  fastSnapshot() {
2274
2534
  return this.fast?.snapshot() ?? null;
2275
2535
  }
2536
+ /**
2537
+ * Summarise one user prompt into a header label, using the FAST tier only.
2538
+ *
2539
+ * NEVER REJECTS - the same contract `prompt()` has, and the reason `App` needs
2540
+ * no `.catch`. Every failure is folded into a discriminated
2541
+ * `TaskSummaryOutcome`, because a header label must not be able to produce an
2542
+ * unhandled rejection in a TUI that has nowhere to show one.
2543
+ *
2544
+ * THE CONTROLLER OWNS THE `AbortController` AND ABORTS IT UNCONDITIONALLY on the
2545
+ * way out: `runTaskSummaryCall` may return a `timeout` outcome while the request
2546
+ * is still open (it is forbidden to abort its own call - see that file's
2547
+ * header), so this `finally` is what actually releases the connection.
2548
+ *
2549
+ * EVERY `this.fast` READ IS BEHIND THE CLOSURE, exactly as the reviewer and the
2550
+ * overseer wiring do it: `complete` is only reached from inside the call, which
2551
+ * runs long after construction, so there is no use-before-assign.
2552
+ */
2553
+ async summarizeTask(anchor, opts = {}) {
2554
+ const deps = createTaskSummaryDeps({
2555
+ getConfig: () => this.config,
2556
+ hasKey: (id, role) => this.hasApiKey(id, role),
2557
+ getApiKey: (id, role) => this.resolveKey(id, role),
2558
+ complete: (providerId, request) => {
2559
+ if (this.fast === null) {
2560
+ // A session whose tier never registered has no fast transport at all;
2561
+ // rejecting here is caught by `runTaskSummaryCall` and reported as a
2562
+ // tier reason rather than as a crash.
2563
+ return Promise.reject(new Error('Fast tier is not registered for this session.'));
2564
+ }
2565
+ return this.fast.completeViaFastRegistry(providerId, request);
2566
+ },
2567
+ ...(opts.ellipsis ? { ellipsis: () => opts.ellipsis } : {}),
2568
+ });
2569
+ const controller = new AbortController();
2570
+ const upstream = opts.signal;
2571
+ const onAbort = () => controller.abort();
2572
+ if (upstream) {
2573
+ if (upstream.aborted)
2574
+ controller.abort();
2575
+ else
2576
+ upstream.addEventListener('abort', onAbort);
2577
+ }
2578
+ try {
2579
+ return await runTaskSummaryCall(deps, {
2580
+ anchor,
2581
+ signal: controller.signal,
2582
+ now: Date.now(),
2583
+ });
2584
+ }
2585
+ finally {
2586
+ upstream?.removeEventListener('abort', onAbort);
2587
+ controller.abort();
2588
+ }
2589
+ }
2276
2590
  // -----------------------------------------------------------------------
2277
2591
  // Context occupancy (context-usage-gauge-accuracy §4.2) — THREE FORWARDERS
2278
2592
  //
@@ -2485,8 +2799,39 @@ export class AgentController {
2485
2799
  return () => { };
2486
2800
  return this.compaction.subscribe(listener);
2487
2801
  }
2488
- /** List usable tools (name + description) for the /tools command. */
2802
+ /**
2803
+ * List usable tools (name + description) for the /tools command.
2804
+ *
2805
+ * THE UNION, SO THE VIEW AND THE REGISTRY CANNOT DISAGREE (I-13). `system/init`
2806
+ * builds `init.tools` from here, as do `/tools` and `/skills`; reporting only the
2807
+ * host's own set while the model is calling `db_query` is the exact failure that
2808
+ * invariant names - a caller reading "the model has no such tool" about a tool it
2809
+ * is watching run.
2810
+ */
2489
2811
  listTools() {
2490
- return this.tools;
2812
+ return this.callerTools.length === 0 ? this.tools : [...this.tools, ...this.callerTools];
2813
+ }
2814
+ /**
2815
+ * Replace the caller-served tool set. Called ONLY at a turn boundary.
2816
+ *
2817
+ * `Agent.setTools()` unregisters and re-registers entries in the LIVE registry.
2818
+ * This class already learned the lesson once: `submit_plan` mutates the mode from
2819
+ * INSIDE a tool execution, so rebuilding the array at that moment would mutate
2820
+ * the registry mid-iteration (see the comment on the plan tools). Hence the
2821
+ * refusal rather than a silently deferred write: a caller that registers
2822
+ * mid-turn gets an error it can act on, and the bridge defers the frame instead.
2823
+ *
2824
+ * `[]` is meaningful and not an omission: it means "restore the factory set".
2825
+ */
2826
+ applyCallerTools(tools) {
2827
+ if (this.agent.state.isRunning) {
2828
+ throw new Error('applyCallerTools() called mid-turn; defer to the turn boundary.');
2829
+ }
2830
+ this.callerTools = [...tools];
2831
+ this.agent.setTools([...this.tools, ...this.callerTools]);
2832
+ }
2833
+ /** The names the caller currently serves, for `ack.tools`. */
2834
+ callerToolNames() {
2835
+ return this.callerTools.map((tool) => tool.name);
2491
2836
  }
2492
2837
  }
@@ -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
  '',