@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
@@ -55,6 +55,23 @@ export type CompactionMode = 'summarized' | 'truncated' | 'relieved' | 'none';
55
55
  * thing a CLI-local type is for.
56
56
  */
57
57
  export type CompactionUiTrigger = 'pressure' | 'overflow' | 'manual';
58
+ /**
59
+ * Where one attempt currently is, so "compacting context" can also say WHAT it is
60
+ * doing for the forty-five seconds it is on screen.
61
+ *
62
+ * IT EXISTS BECAUSE THE GATES WERE INVISIBLE (context-compaction-never-fires
63
+ * RC-3). Planning and digesting both run BEFORE any model call, and both used to
64
+ * be able to reject while the only surface said `Context 95%` and nothing else.
65
+ * A phase is the CLI's answer to "is this stuck, or merely slow", and it is
66
+ * CLI-LOCAL for the reason the whole stream is: the engine has no phases, only a
67
+ * splice.
68
+ *
69
+ * THERE IS NO `settled` MEMBER, deliberately: the settle is already the
70
+ * `compaction_end` record. A phase restating it would give the card two ways to
71
+ * learn one fact, and the extra phase would race the record whenever the engine's
72
+ * verdict comes back first.
73
+ */
74
+ export type CompactionPhase = 'planning' | 'digesting' | 'summarizing' | 'validating' | 'applying';
58
75
  /** Whether a figure was measured by the provider or estimated by us. */
59
76
  export type PressureSource = 'usage' | 'estimate';
60
77
  /** The exact occupancy evidence at the compaction checkpoint. */
@@ -183,6 +200,36 @@ export interface CompactionRecord {
183
200
  usage?: TokenUsage;
184
201
  /** Set when an overflow attempt halved `keepRecentTurns` for itself (D-24). */
185
202
  keepRecentTurnsUsed?: number;
203
+ /**
204
+ * Set when `planCompaction` had to relax `keepRecentTurns` to find a cut
205
+ * (context-compaction-never-fires B).
206
+ *
207
+ * DISCLOSED ON THE CARD, never silent: the relaxation trades "the last N turns
208
+ * are verbatim" for "compaction happens at all", and a user who is not told
209
+ * which side they got cannot tell a lossless compaction from a lossy one.
210
+ * `retainedTurns` and `retainedMessages` are what actually survived, so the card
211
+ * can state all three numbers instead of implying one.
212
+ */
213
+ keepRelaxed?: boolean;
214
+ retainedTurns?: number;
215
+ /**
216
+ * How many MESSAGES the relaxed cut left verbatim.
217
+ *
218
+ * `retainedTurns` COUNTS USER TURNS, and in the sessions this fix exists for - one
219
+ * prompt followed by a hundred tool calls - that number is zero even though forty
220
+ * messages survived word for word. A card reading "kept 0 recent turns" at that
221
+ * moment says the opposite of what happened, so both numbers are recorded.
222
+ */
223
+ retainedMessages?: number;
224
+ /**
225
+ * Messages left OUT of the digest text and therefore not quotable by the
226
+ * summarizer (context-compaction-never-fires A1).
227
+ *
228
+ * The elision is LOUD in three places: the digest text itself carries an
229
+ * `[elided ...]` marker, this field puts the count on the card, and the elided
230
+ * ids never enter the source map, so a delta citing one is rejected.
231
+ */
232
+ elidedMessages?: number;
186
233
  /**
187
234
  * Present ONLY when tail relief fired (hardening §3.3.4 / W2).
188
235
  *
@@ -221,14 +268,24 @@ export interface CompactionSnapshot {
221
268
  /** The summarizer's model id, or `''`. */
222
269
  model: string;
223
270
  /**
224
- * How many compactions ANNOUNCED themselves (quiet-noop §6.2 / D-5).
271
+ * How many compactions ANNOUNCED themselves, i.e. how many cards the user saw.
225
272
  *
226
- * COMPACTIONS THAT RAN, NOT CHECKPOINTS THAT FIRED. A `pressure` compaction
227
- * that declines before calling a model or changing the history never advances
228
- * this, which is what keeps the card's `#N` contiguous. `declined` below is
229
- * the other half of the same total.
273
+ * IT COUNTS ATTEMPTS, NOT SPLICES (context-compaction-never-fires C). Before
274
+ * this change only attempts that committed to work announced, so this was
275
+ * "compactions that ran"; now every attempt opens a card - including the ones
276
+ * that decline at the planner or the digest - because a silent decline is
277
+ * indistinguishable from a dead feature, which is the bug this feature shipped
278
+ * with. `applied` below is the other half, and `/compact status` prints both.
230
279
  */
231
280
  compactions: number;
281
+ /**
282
+ * How many of those attempts actually replaced the history.
283
+ *
284
+ * OPTIONAL SO EVERY EXISTING CONSTRUCTION SITE STAYS UNTOUCHED. Absent means
285
+ * "not reported", which `/compact status` renders exactly as the pre-change
286
+ * wording did.
287
+ */
288
+ applied?: number;
232
289
  /**
233
290
  * How many `pressure` checkpoints declined BEFORE committing to any work
234
291
  * (quiet-noop D-9 / P1-2).
@@ -260,19 +317,26 @@ export interface CompactionSnapshot {
260
317
  /**
261
318
  * THE COMPACTION EVENT STREAM.
262
319
  *
263
- * `compaction_start` and `compaction_end` are emitted ONLY for compactions that
264
- * COMMITTED TO WORK (quiet-noop §3.1): a `pressure` compaction that declines
265
- * before calling a model or changing the history emits NEITHER, and is
266
- * deliberately indistinguishable from a turn at which `shouldCompact` returned
267
- * false. `manual` and `overflow` always emit both.
320
+ * EVERY ATTEMPT ANNOUNCES ITSELF (context-compaction-never-fires C, which
321
+ * REVERSES quiet-noop §3.1). A `pressure` compaction used to reach
322
+ * `compaction_start` only after it committed to paid work, so an attempt that
323
+ * declined at the planner (`nothing_to_drop`) or at the digest budget produced
324
+ * no card at all - and a user sitting at 95 % with a dead auto-compaction had no
325
+ * way to learn that the feature was trying and failing 96 times. The card is now
326
+ * opened at the entry of `compact()` and ALWAYS settled, so `#N` counts attempts
327
+ * and the transcript carries one line per attempt, applied or not.
328
+ *
329
+ * WHY THE OLD DESIGN WAS DEFENSIBLE AND IS STILL WRONG. Its stated worry was a
330
+ * red `context not compacted` card every two turns flooding the transcript. The
331
+ * guards bound that to `stuckLimit` declines per run (`limits.ts`), and after
332
+ * them the auto path self-disables and says so once - so the flood never
333
+ * happens, while the silence happened every time.
268
334
  *
269
335
  * The pair is still BALANCED: every `compaction_start` is followed by exactly
270
336
  * one `compaction_end` (a `finally` in the engine guarantees the verdict, and
271
337
  * `settlePending` synthesizes a skeleton record when the compactor's own has not
272
- * arrived). Consumers that count PAIRS are unaffected; consumers that counted
273
- * ATTEMPTS must read `/compact status` or the JSONL diagnostic log instead -
274
- * CORE's own `compaction_start` / `compaction_end` pair still fires for every
275
- * attempt, declined or not, and that is the stream the log subscribes to.
338
+ * arrived). `phase` events land BETWEEN them, in order, and only for the live
339
+ * card.
276
340
  */
277
341
  export type CompactionEvent = {
278
342
  type: 'compaction_start';
@@ -282,6 +346,10 @@ export type CompactionEvent = {
282
346
  } | {
283
347
  type: 'compaction_end';
284
348
  record: CompactionRecord;
349
+ } | {
350
+ type: 'phase';
351
+ index: number;
352
+ phase: CompactionPhase;
285
353
  } | ({
286
354
  type: 'usage';
287
355
  usage: TokenUsage;
@@ -27,14 +27,19 @@ import type { CompactionIdentity } from './memory-identity.js';
27
27
  * `/compact off`, `/compact threshold 0.8` and a settings-screen save take effect
28
28
  * mid-session without touching the `Agent`.
29
29
  *
30
- * DECLINED COMPACTIONS PRODUCE NO CLI EVENTS (quiet-noop §3.6). A `pressure`
31
- * compaction that changed nothing, called nothing and spent nothing never emits
32
- * `compaction_start`, so `onCompactorEvent` never opens a card and `settlePending`
33
- * finds neither a held record nor a skeleton to synthesize. CORE still emits its
34
- * own pair for every attempt, which is why `settlePending` still runs - it just
35
- * refreshes the snapshot and returns. `onCompactorEvent`'s `if (!this.open)`
36
- * guard is now unreachable for declines and stays for the abort race it was
37
- * written for.
30
+ * DECLINED COMPACTIONS ARE VISIBLE (context-compaction-never-fires C, which
31
+ * REVERSES quiet-noop §3.6). A `pressure` compaction that changes nothing used to
32
+ * emit no `compaction_start`, so `onCompactorEvent` never opened a card and the
33
+ * only trace of an attempt was a counter in `/compact status`. The compactor now
34
+ * announces at the entry of `compact()` and always settles, so this class holds a
35
+ * record for every attempt and `settlePending` emits it - declined attempts
36
+ * included, with the reason on the card.
37
+ *
38
+ * WHAT THE OLD RULE PROTECTED IS STILL PROTECTED, by a different mechanism: the
39
+ * transcript cannot flood, because `maxPerRun` (5) and `stuckLimit` (2) bound the
40
+ * attempts and the auto path self-disables and says so once. `onCompactorEvent`'s
41
+ * `if (!this.open)` guard is still load-bearing: it is what stops a late record
42
+ * from re-opening a card the engine's verdict already closed.
38
43
  */
39
44
  import type { ModelRole } from '../config/model-profiles.js';
40
45
  import { type AgentEvent, type ContextManager, type Message, type ModelInfo, type ModelRef, type ProviderRegistry, type ProviderRegistryOptions, type TokenUsage } from '@aragon-agent/core';
@@ -368,6 +368,7 @@ export class CompactionWiring {
368
368
  live: this.enabled && summarizer !== null,
369
369
  model: summarizer?.ref.modelId ?? '',
370
370
  compactions: totals.compactions,
371
+ applied: totals.applied,
371
372
  // OMITTED WHEN ZERO (quiet-noop D-9), so a healthy session's snapshot is
372
373
  // shaped exactly as it was before this feature and `/compact status` has
373
374
  // nothing extra to render.
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import process from 'node:process';
9
9
  import { JOB_WAIT_MARGIN_MS } from '../proc/limits.js';
10
- import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
10
+ import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, legacyTeamFanoutPatch, LEGACY_TEAM_FANOUT_DEFAULTS, parseThresholdInput, } from './schema.js';
11
11
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
12
12
  import { readConfigFile, updatePersistedConfig } from './store.js';
13
13
  import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey, } from './model-profile-resolution.js';
@@ -517,6 +517,49 @@ function migrateLegacyTeamCeilings(raw) {
517
517
  keys: Object.keys(patch).join(','),
518
518
  });
519
519
  }
520
+ /**
521
+ * Rewrite the cli<=0.6.x persisted fan-out defaults (5 / 3) to the new ones
522
+ * (8 / 8) and persist the change (subagent-fanout D-3 / D-4).
523
+ *
524
+ * ONE-SHOT and IDEMPOTENT, and the FILE LAYER ONLY, for the reasons
525
+ * `migrateLegacyTeamCeilings` records one block above: env (`ARAGON_TEAM_MAX`)
526
+ * and `--team-max` are THIS launch's intent and are never rewritten, the raw
527
+ * file object is patched IN PLACE so the `file` layer resolved below already
528
+ * reads 8/8, and a failed write leaves the marker absent so the next launch
529
+ * retries the whole migration.
530
+ *
531
+ * WHY A MARKER AND NOT JUST THE NUMBERS: `fanoutMigrated` is what keeps a user
532
+ * who deliberately returns to 5/3 from being migrated again on every launch
533
+ * (`legacyTeamFanoutPatch` returns `null` once it is set). The price of
534
+ * exact-equality is that one deliberate 5/3 is widened once; the marker bounds
535
+ * that to at most once.
536
+ */
537
+ function migrateLegacyTeamFanout(raw) {
538
+ const patch = legacyTeamFanoutPatch(raw?.team);
539
+ if (patch === null)
540
+ return;
541
+ // READ THE STARTING POINT BEFORE THE IN-PLACE PATCH, and never hardcode
542
+ // `5/3`: rule 4 of the matcher accepts an ABSENT `maxConcurrent`, in which
543
+ // case the honest starting point is "unset" - a log line the user cannot find
544
+ // on disk is worse than no log line.
545
+ const from = `${LEGACY_TEAM_FANOUT_DEFAULTS.maxSubagents}/${raw?.team?.maxConcurrent ?? 'unset'}`;
546
+ // THE FULL SECTION, not the bare patch: the write port types `team` as a
547
+ // whole `TeamConfig`, and the section the file already carries plus the three
548
+ // keys IS the section we want on disk.
549
+ const migratedTeam = { ...raw.team, ...patch };
550
+ Object.assign(raw.team, patch);
551
+ try {
552
+ updatePersistedConfig({ team: migratedTeam });
553
+ }
554
+ catch {
555
+ // Unwritable config dir: in-memory values are already migrated; retry next
556
+ // launch (idempotent, because the marker never made it to disk).
557
+ }
558
+ getLogger().warn('config', 'team_fanout_migrated', {
559
+ from,
560
+ to: `${patch.maxSubagents}/${patch.maxConcurrent}`,
561
+ });
562
+ }
520
563
  /**
521
564
  * Resolve the effective config by merging all layers, then derive the runtime
522
565
  * (non-persisted) fields and enforce the timeout invariant.
@@ -527,6 +570,12 @@ export function loadConfig(flags = {}, options = {}) {
527
570
  loadDotenv(cwd);
528
571
  const read = readConfigFile();
529
572
  migrateLegacyTeamCeilings(read.config);
573
+ // THE NEXT LINE, not a shared pass: the two migrations are independent, and
574
+ // keeping them as two statements keeps each one's "returns null when nothing
575
+ // matches" contract visible. Both run BEFORE `projectModelProfiles` for the
576
+ // same reason - the `file` layer resolved below must already read the migrated
577
+ // numbers, and neither migration needs a second projection path.
578
+ migrateLegacyTeamFanout(read.config);
530
579
  const projection = projectModelProfiles(read.config ?? {});
531
580
  const file = projection.file;
532
581
  const env = readEnvConfig();
@@ -570,7 +619,6 @@ export function loadConfig(flags = {}, options = {}) {
570
619
  // a config file that turned plan mode on.
571
620
  const startInPlanMode = pick(flags.plan, env.partial.planModeDefault, file.planModeDefault) ??
572
621
  DEFAULT_CONFIG.planModeDefault;
573
- const planModeMaxAskRounds = clampAskRounds(file.planModeMaxAskRounds, DEFAULT_CONFIG.planModeMaxAskRounds);
574
622
  const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
575
623
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
576
624
  // THE `jobs` SECTION IS RESOLVED HERE, ABOVE THE TIMEOUT BLOCK, and the
@@ -706,7 +754,6 @@ export function loadConfig(flags = {}, options = {}) {
706
754
  // From `<home>/state.json`, not the config file — see `ui-state.ts`.
707
755
  submitCount: readSubmitCount(),
708
756
  startInPlanMode,
709
- planModeMaxAskRounds,
710
757
  planModeHumanTimeoutMs,
711
758
  skills,
712
759
  skillsRuntime: {
@@ -70,7 +70,8 @@ export declare const KIT_LIMITS: {
70
70
  * exactly as it is for `team.modelProfileId` (`schema.ts` clampTeamConfig):
71
71
  * `createModelProfileState` blanks the whole settings page when validation
72
72
  * fails, so promoting "a hand-edited file left one dangling id" into "the page
73
- * and `/team model` are both dead" is a wildly disproportionate blast radius.
73
+ * and the `Subagent model` row are both dead" is a wildly disproportionate blast
74
+ * radius.
74
75
  *
75
76
  * `duplicate_name` is deliberately NOT a code: two kits may share a name
76
77
  * (§6.6), and `/kit use` disambiguates with an `#id` prefix.
@@ -206,15 +206,15 @@ export declare const DEFAULT_SCROLL_RESUME_MS = 5000;
206
206
  * Exactly the trap `retry.maxRetries` needed this helper for.
207
207
  */
208
208
  export declare function clampScrollResumeMs(v: unknown, fallback: number): number;
209
- /** `ask_user` calls allowed per user turn. */
210
- export declare const DEFAULT_PLAN_MAX_ASK_ROUNDS = 4;
211
209
  /**
212
210
  * Ceiling on ONE human wait, in ms. Thirty minutes is far outside "the user
213
211
  * went to get coffee", but a ceiling has to exist: an unattended terminal must
214
212
  * not wedge a CI job or a stale ssh session forever.
213
+ *
214
+ * THIS IS NOT A ROUND LIMIT and the distinction matters. It bounds a single wait
215
+ * (liveness); it says nothing about how many times the model may ask.
215
216
  */
216
217
  export declare const DEFAULT_PLAN_HUMAN_TIMEOUT_MS = 1800000;
217
- export declare function clampAskRounds(v: unknown, fallback: number): number;
218
218
  export declare function clampHumanTimeout(v: unknown, fallback: number): number;
219
219
  export interface SkillsConfig {
220
220
  enabled: boolean;
@@ -259,6 +259,18 @@ export interface SkillsConfig {
259
259
  * just be a second decorative field.
260
260
  */
261
261
  toolPolicy: SkillsToolPolicyMode;
262
+ /**
263
+ * Automatic skill triage (skill-triage-and-unbounded-ask §4.2.9): compute a
264
+ * short candidate list for each new task and put it in the system prompt, so
265
+ * "is a skill applicable?" becomes a question the protocol forces the model to
266
+ * answer rather than one it has to remember to ask.
267
+ *
268
+ * DEFAULT ON, and OFF must be byte-identical to the pre-feature prompt. That is
269
+ * what the block's conditional splice in `buildSystemPrompt` guarantees
270
+ * (invariant I-T1) — a permanently-on block would be a prompt change nobody
271
+ * could turn off.
272
+ */
273
+ triage: boolean;
262
274
  }
263
275
  export declare const SKILLS_INTEGRITY_MODES: readonly ["off", "warn", "strict"];
264
276
  export type SkillsIntegrityMode = (typeof SKILLS_INTEGRITY_MODES)[number];
@@ -343,6 +355,16 @@ export interface TeamConfig {
343
355
  * failed subagent.
344
356
  */
345
357
  maxConcurrent: number;
358
+ /**
359
+ * The one-shot marker `migrateLegacyTeamFanout` writes (subagent-fanout
360
+ * D-3), and NOTHING else may ever write it.
361
+ *
362
+ * It exists because the fan-out default moved 5/3 -> 8/8 while every save
363
+ * materializes the whole section: without a marker, a user who deliberately
364
+ * returns to 5/3 is migrated back to 8/8 on the next launch, for ever. Absent
365
+ * means "not yet migrated"; it is never written as `false`.
366
+ */
367
+ fanoutMigrated?: boolean;
346
368
  /**
347
369
  * One child's wall clock before its own abort. `0` (the default) means NO
348
370
  * ceiling: a subagent runs until it finishes or is aborted, exactly like
@@ -465,6 +487,40 @@ export declare const LEGACY_TEAM_CEILING_DEFAULTS: Readonly<{
465
487
  * equality test lives beside the constants it reads.
466
488
  */
467
489
  export declare function legacyTeamCeilingPatch(raw: unknown): Partial<TeamConfig> | null;
490
+ /**
491
+ * The fan-out defaults cli <= 0.6.x persisted as its OWN defaults
492
+ * (subagent-fanout F-2 / R-1).
493
+ *
494
+ * THE SAME TRAP AS THE CEILINGS ABOVE, ONE SECTION OVER. `mergePersistedConfig`
495
+ * writes the WHOLE `team` section on every save, so a user who has ever touched
496
+ * any setting carries this exact pair in `config.json` - changing
497
+ * `DEFAULT_TEAM_CONFIG` alone would never reach them, and they would keep
498
+ * seeing "only 3 subagents at once" for ever.
499
+ */
500
+ export declare const LEGACY_TEAM_FANOUT_DEFAULTS: {
501
+ readonly maxSubagents: 5;
502
+ readonly maxConcurrent: 3;
503
+ };
504
+ /**
505
+ * The `{ maxSubagents, maxConcurrent, fanoutMigrated }` patch for the legacy
506
+ * default pair, or `null` when nothing matches (subagent-fanout D-3).
507
+ *
508
+ * EXACT-EQUALITY ONLY, the rule `legacyTeamCeilingPatch` records: a value that
509
+ * differs was a deliberate user choice and survives untouched. Two differences
510
+ * from that function, both deliberate:
511
+ *
512
+ * - `fanoutMigrated: true` short-circuits to `null`. That is what makes "the
513
+ * user changed their mind and went back to 5/3" stick: without it the next
514
+ * launch would migrate them again, every launch, for ever.
515
+ * - `maxConcurrent` is allowed to be ABSENT as well as 3, because a
516
+ * hand-written section may name only one key and a half-written pair is
517
+ * still the legacy intent, not a user decision.
518
+ *
519
+ * Pure on purpose: `loadConfig` decides WHERE the patch is applied and
520
+ * persisted; this function only answers WHICH keys match, so the equality test
521
+ * lives beside the constants it reads.
522
+ */
523
+ export declare function legacyTeamFanoutPatch(raw: unknown): Partial<TeamConfig> | null;
468
524
  export interface TodoConfig {
469
525
  /** Register `todo_write` at all. Default TRUE. */
470
526
  enabled: boolean;
@@ -1064,8 +1120,6 @@ export interface PersistedConfig {
1064
1120
  scrollResumeMs: number;
1065
1121
  /** Start every session in PLAN mode (plan-mode §4.4). */
1066
1122
  planModeDefault: boolean;
1067
- /** `ask_user` rounds allowed per user turn; clamped to [1, 10]. */
1068
- planModeMaxAskRounds: number;
1069
1123
  /** Ceiling on a single human wait in ms; clamped to [60_000, 7_200_000]. */
1070
1124
  planModeHumanTimeoutMs: number;
1071
1125
  /** Skill system settings. The SECOND nested object in this file after apiKeys. */
@@ -1118,6 +1172,29 @@ export declare const LEGACY_STATE_KEYS: readonly ["promptHistory", "submitCount"
1118
1172
  * one door for both directions.
1119
1173
  */
1120
1174
  export declare function stripLegacyStateKeys<T extends object>(raw: T): T;
1175
+ /**
1176
+ * Config keys whose MEANING was removed, mapped to the explanation a user gets if
1177
+ * they still try to set one.
1178
+ *
1179
+ * SEPARATE FROM `LEGACY_STATE_KEYS`, because the disposition differs: those keys'
1180
+ * values were MOVED somewhere else, while these have nowhere to go — the feature
1181
+ * they configured no longer exists. Merging the two lists would erase that
1182
+ * difference at exactly the moment someone is reading the file to find out what
1183
+ * happened to their setting.
1184
+ *
1185
+ * Stripped on the READ side for the same reason `stripLegacyStateKeys` is:
1186
+ * `loadPersistedConfig()` merges `{ ...DEFAULT_CONFIG, ...partial }`, so a key
1187
+ * left on disk rides that spread into memory and `updatePersistedConfig` writes it
1188
+ * straight back. The user deletes the line, launches once, and watches it return.
1189
+ */
1190
+ export declare const RETIRED_CONFIG_KEYS: Readonly<Record<string, string>>;
1191
+ /**
1192
+ * THE single gate that keeps retired keys out of `config.json`. Applied on the
1193
+ * read side, so it is self-healing: every write is read-strip-merge-write, and a
1194
+ * hand-edited file, a downgrade to an older CLI and back, or a half-finished
1195
+ * migration all converge on a clean file at the next write.
1196
+ */
1197
+ export declare function stripRetiredConfigKeys<T extends object>(raw: T): T;
1121
1198
  export interface CliConfig {
1122
1199
  /** A committed model snapshot failed to apply; no further requests may start. */
1123
1200
  modelSettingsRestartRequired?: boolean;
@@ -1312,7 +1389,6 @@ export interface CliConfig {
1312
1389
  * live mode from here on belongs to the controller.
1313
1390
  */
1314
1391
  startInPlanMode: boolean;
1315
- planModeMaxAskRounds: number;
1316
1392
  planModeHumanTimeoutMs: number;
1317
1393
  skills: SkillsConfig;
1318
1394
  /** Flag/env-only skill switches for this run (§7.5). Never persisted. */
@@ -273,20 +273,23 @@ export function clampScrollResumeMs(v, fallback) {
273
273
  // nested-object merge is hand-written and capped at one level; three scalars do
274
274
  // not justify a second `clampXConfig` gate plus two more merge sites, and flat
275
275
  // keys mean `store.ts` needs ZERO changes.
276
+ //
277
+ // ONE OF THOSE KEYS IS GONE: `planModeMaxAskRounds` capped `ask_user` at four
278
+ // rounds per turn. There is no cap any more, so there is no number to store, no
279
+ // range to clamp and nothing to configure. It is retired through
280
+ // `RETIRED_CONFIG_KEYS` below rather than defaulted to a large number — a
281
+ // ninety-nine in the config file is the same second source of truth as a four.
276
282
  // ---------------------------------------------------------------------------
277
- /** `ask_user` calls allowed per user turn. */
278
- export const DEFAULT_PLAN_MAX_ASK_ROUNDS = 4;
279
- const ASK_ROUNDS_RANGE = { min: 1, max: 10 };
280
283
  /**
281
284
  * Ceiling on ONE human wait, in ms. Thirty minutes is far outside "the user
282
285
  * went to get coffee", but a ceiling has to exist: an unattended terminal must
283
286
  * not wedge a CI job or a stale ssh session forever.
287
+ *
288
+ * THIS IS NOT A ROUND LIMIT and the distinction matters. It bounds a single wait
289
+ * (liveness); it says nothing about how many times the model may ask.
284
290
  */
285
291
  export const DEFAULT_PLAN_HUMAN_TIMEOUT_MS = 1_800_000;
286
292
  const HUMAN_TIMEOUT_RANGE = { min: 60_000, max: 7_200_000 };
287
- export function clampAskRounds(v, fallback) {
288
- return clampInt(v, fallback, ASK_ROUNDS_RANGE);
289
- }
290
293
  export function clampHumanTimeout(v, fallback) {
291
294
  return clampInt(v, fallback, HUMAN_TIMEOUT_RANGE);
292
295
  }
@@ -322,6 +325,9 @@ export const DEFAULT_SKILLS_CONFIG = {
322
325
  integrity: 'warn',
323
326
  usageTracking: true,
324
327
  toolPolicy: 'enforce',
328
+ // ON by default: the capability this key controls is the feature's whole point,
329
+ // and it costs zero model calls (local matching over in-memory records).
330
+ triage: true,
325
331
  };
326
332
  const CATALOG_BYTES_RANGE = { min: 500, max: 40_000 };
327
333
  /**
@@ -406,6 +412,7 @@ export function clampSkillsConfig(raw) {
406
412
  integrity: clampSkillsIntegrity(src.integrity, DEFAULT_SKILLS_CONFIG.integrity),
407
413
  usageTracking: bool(src.usageTracking, DEFAULT_SKILLS_CONFIG.usageTracking),
408
414
  toolPolicy: clampSkillsToolPolicy(src.toolPolicy, DEFAULT_SKILLS_CONFIG.toolPolicy),
415
+ triage: bool(src.triage, DEFAULT_SKILLS_CONFIG.triage),
409
416
  };
410
417
  }
411
418
  export const DEFAULT_SKILLS_RUNTIME = { forcedSkills: [], approveAll: false };
@@ -474,8 +481,12 @@ export function clampLogConfig(raw) {
474
481
  export const HARD_MAX_SUBAGENTS = TEAM_LIMITS.hardMaxSubagents;
475
482
  export const DEFAULT_TEAM_CONFIG = {
476
483
  enabled: true,
477
- maxSubagents: 5,
478
- maxConcurrent: 3,
484
+ // 8 IS THE DEFAULT, 10 IS THE CEILING, and they are not the same number
485
+ // (subagent-fanout D-1 / D-2). `maxConcurrent` moves WITH `maxSubagents`
486
+ // because a wider population behind a 3-wide pool shows the user nothing:
487
+ // children 4..8 simply queue (`team-bus.test.ts` records the queued phase).
488
+ maxSubagents: 8,
489
+ maxConcurrent: 8,
479
490
  // 0 = NO LIMIT. Subagents run under the same regime as the lead: no
480
491
  // wall clock, no turn cap; only the idle watchdog and the user's Esc.
481
492
  subagentTimeoutMs: 0,
@@ -534,6 +545,13 @@ export function clampTeamConfig(raw) {
534
545
  && [...src.modelProfileId].length <= 128
535
546
  ? src.modelProfileId
536
547
  : null,
548
+ // TRANSPARENT, and only ever `true` (subagent-fanout D-3 / R-6). The flag is
549
+ // not a setting: it is the one-shot marker the migration leaves behind, so a
550
+ // clamp that dropped it would re-migrate the user on every launch. Written
551
+ // as a spread rather than `=== true ? true : undefined` so the key stays
552
+ // ABSENT (not `undefined`) in the clamped result - `toEqual(DEFAULT_TEAM_CONFIG)`
553
+ // and the on-disk shape both depend on that.
554
+ ...(src.fanoutMigrated === true ? { fanoutMigrated: true } : {}),
537
555
  };
538
556
  }
539
557
  /**
@@ -574,6 +592,56 @@ export function legacyTeamCeilingPatch(raw) {
574
592
  }
575
593
  return matched ? patch : null;
576
594
  }
595
+ /**
596
+ * The fan-out defaults cli <= 0.6.x persisted as its OWN defaults
597
+ * (subagent-fanout F-2 / R-1).
598
+ *
599
+ * THE SAME TRAP AS THE CEILINGS ABOVE, ONE SECTION OVER. `mergePersistedConfig`
600
+ * writes the WHOLE `team` section on every save, so a user who has ever touched
601
+ * any setting carries this exact pair in `config.json` - changing
602
+ * `DEFAULT_TEAM_CONFIG` alone would never reach them, and they would keep
603
+ * seeing "only 3 subagents at once" for ever.
604
+ */
605
+ export const LEGACY_TEAM_FANOUT_DEFAULTS = {
606
+ maxSubagents: 5,
607
+ maxConcurrent: 3,
608
+ };
609
+ /**
610
+ * The `{ maxSubagents, maxConcurrent, fanoutMigrated }` patch for the legacy
611
+ * default pair, or `null` when nothing matches (subagent-fanout D-3).
612
+ *
613
+ * EXACT-EQUALITY ONLY, the rule `legacyTeamCeilingPatch` records: a value that
614
+ * differs was a deliberate user choice and survives untouched. Two differences
615
+ * from that function, both deliberate:
616
+ *
617
+ * - `fanoutMigrated: true` short-circuits to `null`. That is what makes "the
618
+ * user changed their mind and went back to 5/3" stick: without it the next
619
+ * launch would migrate them again, every launch, for ever.
620
+ * - `maxConcurrent` is allowed to be ABSENT as well as 3, because a
621
+ * hand-written section may name only one key and a half-written pair is
622
+ * still the legacy intent, not a user decision.
623
+ *
624
+ * Pure on purpose: `loadConfig` decides WHERE the patch is applied and
625
+ * persisted; this function only answers WHICH keys match, so the equality test
626
+ * lives beside the constants it reads.
627
+ */
628
+ export function legacyTeamFanoutPatch(raw) {
629
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
630
+ return null;
631
+ const src = raw;
632
+ if (src.fanoutMigrated === true)
633
+ return null;
634
+ if (src.maxSubagents !== LEGACY_TEAM_FANOUT_DEFAULTS.maxSubagents)
635
+ return null;
636
+ if (src.maxConcurrent !== undefined
637
+ && src.maxConcurrent !== LEGACY_TEAM_FANOUT_DEFAULTS.maxConcurrent)
638
+ return null;
639
+ return {
640
+ maxSubagents: DEFAULT_TEAM_CONFIG.maxSubagents,
641
+ maxConcurrent: DEFAULT_TEAM_CONFIG.maxConcurrent,
642
+ fanoutMigrated: true,
643
+ };
644
+ }
577
645
  export const DEFAULT_TODO_CONFIG = {
578
646
  enabled: true,
579
647
  panel: true,
@@ -1069,7 +1137,6 @@ export const DEFAULT_CONFIG = {
1069
1137
  keyboardEnhancement: true,
1070
1138
  scrollResumeMs: DEFAULT_SCROLL_RESUME_MS,
1071
1139
  planModeDefault: false,
1072
- planModeMaxAskRounds: DEFAULT_PLAN_MAX_ASK_ROUNDS,
1073
1140
  planModeHumanTimeoutMs: DEFAULT_PLAN_HUMAN_TIMEOUT_MS,
1074
1141
  skills: DEFAULT_SKILLS_CONFIG,
1075
1142
  log: DEFAULT_LOG_CONFIG,
@@ -1162,6 +1229,37 @@ export function stripLegacyStateKeys(raw) {
1162
1229
  delete out[key];
1163
1230
  return out;
1164
1231
  }
1232
+ /**
1233
+ * Config keys whose MEANING was removed, mapped to the explanation a user gets if
1234
+ * they still try to set one.
1235
+ *
1236
+ * SEPARATE FROM `LEGACY_STATE_KEYS`, because the disposition differs: those keys'
1237
+ * values were MOVED somewhere else, while these have nowhere to go — the feature
1238
+ * they configured no longer exists. Merging the two lists would erase that
1239
+ * difference at exactly the moment someone is reading the file to find out what
1240
+ * happened to their setting.
1241
+ *
1242
+ * Stripped on the READ side for the same reason `stripLegacyStateKeys` is:
1243
+ * `loadPersistedConfig()` merges `{ ...DEFAULT_CONFIG, ...partial }`, so a key
1244
+ * left on disk rides that spread into memory and `updatePersistedConfig` writes it
1245
+ * straight back. The user deletes the line, launches once, and watches it return.
1246
+ */
1247
+ export const RETIRED_CONFIG_KEYS = {
1248
+ planModeMaxAskRounds: 'ask_user has no round limit any more, so this key is gone. Nothing to set; ' +
1249
+ 'the next config write removes it from config.json.',
1250
+ };
1251
+ /**
1252
+ * THE single gate that keeps retired keys out of `config.json`. Applied on the
1253
+ * read side, so it is self-healing: every write is read-strip-merge-write, and a
1254
+ * hand-edited file, a downgrade to an older CLI and back, or a half-finished
1255
+ * migration all converge on a clean file at the next write.
1256
+ */
1257
+ export function stripRetiredConfigKeys(raw) {
1258
+ const out = { ...raw };
1259
+ for (const key of Object.keys(RETIRED_CONFIG_KEYS))
1260
+ delete out[key];
1261
+ return out;
1262
+ }
1165
1263
  // ---------------------------------------------------------------------------
1166
1264
  // Coercion / validation helpers
1167
1265
  // ---------------------------------------------------------------------------
@@ -17,7 +17,7 @@ import { getConfigPath, getSessionsDir } from './app-paths.js';
17
17
  import { registerProfileSecrets, registerSecretsFrom } from '../logging/secret-registry.js';
18
18
  import { adaptLegacyConnectionPatch, ModelProfileConfigError, readModelSettingsDisk } from './model-profile-store.js';
19
19
  import { normalizeModelProfiles } from './model-profiles.js';
20
- import { clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
20
+ import { clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, stripRetiredConfigKeys, } from './schema.js';
21
21
  /**
22
22
  * Normalize `maxTokens` for both directions of the config file.
23
23
  *
@@ -124,7 +124,12 @@ export function normalizePersistedConfig(input) {
124
124
  // on disk would otherwise ride the spread into `merged` and be written back
125
125
  // out by `updatePersistedConfig` — the "I deleted it and it grew back" bug
126
126
  // that `stripLegacyStateKeys` exists to make impossible (§4.5).
127
- const partial = stripLegacyStateKeys(input);
127
+ //
128
+ // TWO PASSES, DELIBERATELY: `stripLegacyStateKeys` handles keys whose value
129
+ // MOVED, `stripRetiredConfigKeys` handles keys whose FEATURE was removed
130
+ // (`planModeMaxAskRounds`). Same door, different reasons, and each list keeps
131
+ // growing on its own schedule.
132
+ const partial = stripRetiredConfigKeys(stripLegacyStateKeys(input));
128
133
  delete partial.fullscreen;
129
134
  return {
130
135
  ...DEFAULT_CONFIG,
@@ -268,8 +273,9 @@ export function mergePersistedConfig(current, patch) {
268
273
  // replace the whole section and quietly revert `maxFiles`, `previewChars`
269
274
  // and — worst of all — `redactSecrets`.
270
275
  log: clampLogConfig({ ...current.log, ...(patch.log ?? {}) }),
271
- // The fourth section, and it needs the merge for the same reason: `/team max
272
- // 4` sends `{ team: { maxSubagents: 4 } }`, which a shallow spread would
276
+ // The fourth section, and it needs the merge for the same reason: the settings
277
+ // `Max subagents` row sends `{ team: { maxSubagents: 4 } }`, which a shallow
278
+ // spread would
273
279
  // turn into "changing the fan-out width silently reverted every timeout".
274
280
  team: clampTeamConfig({ ...current.team, ...(patch.team ?? {}) }),
275
281
  // The fifth section, and its omission is the worst of the five (P0-1):