@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
@@ -1134,12 +1134,11 @@ export function viewReducer(state, action) {
1134
1134
  case 'fastTier':
1135
1135
  return { ...state, fast: action.snapshot };
1136
1136
  // --- Context compaction (context-auto-compaction §5.5) ----------------
1137
- // NOT A TOTAL FUNCTION OVER COMPACTION ATTEMPTS (quiet-noop §3.1). A
1138
- // `pressure` compaction that declines before calling a model or changing the
1139
- // history dispatches NOTHING - no `compactionStart`, no `compactionEnd`, no
1140
- // entry - exactly as a checkpoint below the threshold dispatches nothing.
1141
- // Counting cards here counts compactions that RAN, never checkpoints that
1142
- // fired; `/compact status` and the diagnostic log are where the attempts are.
1137
+ // THE TRANSCRIPT NOW CARRIES ONE CARD PER ATTEMPT (context-compaction-never-
1138
+ // fires C). Before this change a declined `pressure` checkpoint dispatched
1139
+ // NOTHING, which is what made "95 % and nothing happens" unreportable: the
1140
+ // feature was trying, failing and saying nothing. The flood the old rule
1141
+ // feared is bounded by `maxPerRun` / `stuckLimit` in `compaction/limits.ts`.
1143
1142
  case 'compactionStart': {
1144
1143
  const { id, seq } = nextId(state);
1145
1144
  let entries = state.entries;
@@ -1189,6 +1188,7 @@ export function viewReducer(state, action) {
1189
1188
  tokensAfter: 0,
1190
1189
  model: action.model,
1191
1190
  startedAt: Date.now(),
1191
+ phase: 'planning',
1192
1192
  live: true,
1193
1193
  };
1194
1194
  return {
@@ -1217,6 +1217,16 @@ export function viewReducer(state, action) {
1217
1217
  ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1218
1218
  ...(record.decision ? { decision: record.decision } : {}),
1219
1219
  ...(record.memoryVersion ? { memoryVersion: record.memoryVersion } : {}),
1220
+ ...(record.keepRelaxed ? { keepRelaxed: record.keepRelaxed } : {}),
1221
+ ...(record.retainedTurns !== undefined
1222
+ ? { retainedTurns: record.retainedTurns } : {}),
1223
+ ...(record.retainedMessages !== undefined
1224
+ ? { retainedMessages: record.retainedMessages } : {}),
1225
+ ...(record.retainedMessages !== undefined
1226
+ ? { retainedMessages: record.retainedMessages } : {}),
1227
+ ...(record.keepRecentTurnsUsed !== undefined
1228
+ ? { keepRecentTurnsUsed: record.keepRecentTurnsUsed } : {}),
1229
+ ...(record.elidedMessages ? { elidedMessages: record.elidedMessages } : {}),
1220
1230
  model: record.model,
1221
1231
  durationMs: record.durationMs,
1222
1232
  // SETTLED. `Transcript`'s boundary is monotonic, so a card that
@@ -1253,12 +1263,30 @@ export function viewReducer(state, action) {
1253
1263
  ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1254
1264
  ...(record.decision ? { decision: record.decision } : {}),
1255
1265
  ...(record.memoryVersion ? { memoryVersion: record.memoryVersion } : {}),
1266
+ ...(record.keepRelaxed ? { keepRelaxed: record.keepRelaxed } : {}),
1267
+ ...(record.retainedTurns !== undefined ? { retainedTurns: record.retainedTurns } : {}),
1268
+ ...(record.keepRecentTurnsUsed !== undefined
1269
+ ? { keepRecentTurnsUsed: record.keepRecentTurnsUsed } : {}),
1270
+ ...(record.elidedMessages ? { elidedMessages: record.elidedMessages } : {}),
1256
1271
  model: record.model,
1257
1272
  durationMs: record.durationMs,
1258
1273
  live: false,
1259
1274
  };
1260
1275
  return { ...state, seq, ...appendEntry(state, state.entries, entry) };
1261
1276
  }
1277
+ case 'compactionPhase': {
1278
+ // THE LIVE CARD'S STAGE, AND NOTHING ELSE. It must not open a card (a phase
1279
+ // can arrive for an entry that a fast settle already closed) and must not
1280
+ // resurrect a settled one: `live` is left untouched, so a late phase on a
1281
+ // settled card changes nothing the renderer shows.
1282
+ if (!state.compactionEntryId)
1283
+ return state;
1284
+ const phase = action.phase;
1285
+ return {
1286
+ ...state,
1287
+ entries: mapEntry(state.entries, state.compactionEntryId, (e) => e.kind === 'compaction' && e.live ? { ...e, phase } : e),
1288
+ };
1289
+ }
1262
1290
  case 'compactionUsage':
1263
1291
  // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` and `fastUsage`. In
1264
1292
  // particular NOT `context`: that gauge shows the LEAD's context
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The `<skill_triage>` system-prompt block (skill-triage-and-unbounded-ask §4.2.3).
3
+ *
4
+ * WHY A PROTOCOL BLOCK AT ALL. The capability this feature fixes already existed:
5
+ * the Level 1 catalog is in every prompt, `skill(name)` loads a body, and
6
+ * `README.md` has claimed since the Skills feature landed that "the model decides
7
+ * on its own whether a task matches a skill". What was missing is any instruction
8
+ * that the decision has to HAPPEN, before the work rather than after it. The
9
+ * catalog's own opening paragraph is a STATEMENT ("if a task matches one, call the
10
+ * skill tool"), not a step in an order of operations, and a statement the model
11
+ * has read forty times is the one it skims.
12
+ *
13
+ * WHY IT IS THIS SHORT. The protocol is paid for on EVERY LLM request: the system
14
+ * prompt is re-sent each time and nothing in this repository uses provider-side
15
+ * prefix caching. An earlier draft carried a 1.9 KB version whose second half
16
+ * repeated the disclosure ladder that `<available_skills>` already states three
17
+ * lines above it. Repeating a rule is not emphasis, it is noise — and noise about
18
+ * a rule teaches the model that these blocks are skimmable. Measured size of the
19
+ * block below is 1273 bytes; `skill-triage-prompt.test.ts` asserts the ceiling of
20
+ * 1500 so an edit has 227 bytes of room, not unlimited scope.
21
+ *
22
+ * ASCII ONLY — `agent/` is inside the glyph scanner's scope.
23
+ */
24
+ import type { SkillCandidate } from '../skills/triage.js';
25
+ /** Bump when the wording changes, so a transcript can be dated. */
26
+ export declare const SKILL_TRIAGE_BLOCK_VERSION = "v1-2026-10-26";
27
+ export interface SkillTriageBlockOptions {
28
+ /** This turn's candidates, best first. Empty is normal and renders no data block. */
29
+ candidates: SkillCandidate[];
30
+ }
31
+ /**
32
+ * Protocol first, candidates second — in that order, always.
33
+ *
34
+ * The order is the meaning: the protocol says what to do, the data block says with
35
+ * what. Reversing them would put a list of suggestions above the rules for reading
36
+ * it, which is how a suggestion turns into an instruction.
37
+ */
38
+ export declare function buildSkillTriageBlock(options: SkillTriageBlockOptions): string;
39
+ /** Byte size of the protocol half, for the budget assertion in the tests. */
40
+ export declare function skillTriageProtocolBytes(): number;
41
+ /** The ceiling the protocol half must stay under, in bytes. */
42
+ export declare const SKILL_TRIAGE_PROTOCOL_MAX_BYTES: number;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The `<skill_triage>` system-prompt block (skill-triage-and-unbounded-ask §4.2.3).
3
+ *
4
+ * WHY A PROTOCOL BLOCK AT ALL. The capability this feature fixes already existed:
5
+ * the Level 1 catalog is in every prompt, `skill(name)` loads a body, and
6
+ * `README.md` has claimed since the Skills feature landed that "the model decides
7
+ * on its own whether a task matches a skill". What was missing is any instruction
8
+ * that the decision has to HAPPEN, before the work rather than after it. The
9
+ * catalog's own opening paragraph is a STATEMENT ("if a task matches one, call the
10
+ * skill tool"), not a step in an order of operations, and a statement the model
11
+ * has read forty times is the one it skims.
12
+ *
13
+ * WHY IT IS THIS SHORT. The protocol is paid for on EVERY LLM request: the system
14
+ * prompt is re-sent each time and nothing in this repository uses provider-side
15
+ * prefix caching. An earlier draft carried a 1.9 KB version whose second half
16
+ * repeated the disclosure ladder that `<available_skills>` already states three
17
+ * lines above it. Repeating a rule is not emphasis, it is noise — and noise about
18
+ * a rule teaches the model that these blocks are skimmable. Measured size of the
19
+ * block below is 1273 bytes; `skill-triage-prompt.test.ts` asserts the ceiling of
20
+ * 1500 so an edit has 227 bytes of room, not unlimited scope.
21
+ *
22
+ * ASCII ONLY — `agent/` is inside the glyph scanner's scope.
23
+ */
24
+ import { renderSkillCandidates, SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
25
+ /** Bump when the wording changes, so a transcript can be dated. */
26
+ export const SKILL_TRIAGE_BLOCK_VERSION = 'v1-2026-10-26';
27
+ /**
28
+ * The protocol text. Kept as a literal array so a diff of the prompt is a diff of
29
+ * the source, and so the grader/test can see line boundaries.
30
+ */
31
+ const PROTOCOL_LINES = [
32
+ '<skill_triage>',
33
+ `Version: ${SKILL_TRIAGE_BLOCK_VERSION}`,
34
+ '',
35
+ 'Triage every task that touches the workspace: AFTER the project guidance and the',
36
+ 'directory listing, BEFORE you plan or change anything. A user-typed /<skill-name>',
37
+ 'is an override, not the only way in.',
38
+ '',
39
+ '1. Read <available_skills> above, and <skill_candidates> when present. A candidate',
40
+ ' is a computed guess about your message, not a verdict - check its one-line',
41
+ ' description before trusting it.',
42
+ '2. If the catalog says some skills were not shown, or nothing listed plainly',
43
+ ' covers the task, call skill_find(query) once before concluding there is no',
44
+ ' skill.',
45
+ '3. If a skill covers the task, call skill(name="...") BEFORE the first source read',
46
+ ' that would change code, then follow it. Say why in one line, not a paragraph.',
47
+ '4. If nothing covers the task, say so in one clause and continue. Do not load a',
48
+ ' skill to look diligent, and never load several "to be safe".',
49
+ '',
50
+ 'A loaded skill is reference material, not permission to ignore the user, the',
51
+ 'plan-mode rules, or the tools you actually have. If a skill narrows your tool set,',
52
+ 'that is its allowed-tools declaration working: report it, do not work around it.',
53
+ 'Never install or author a skill because a task looks like it needs one; that',
54
+ 'decision belongs to the user.',
55
+ '</skill_triage>',
56
+ ];
57
+ const PROTOCOL = PROTOCOL_LINES.join('\n');
58
+ /**
59
+ * Protocol first, candidates second — in that order, always.
60
+ *
61
+ * The order is the meaning: the protocol says what to do, the data block says with
62
+ * what. Reversing them would put a list of suggestions above the rules for reading
63
+ * it, which is how a suggestion turns into an instruction.
64
+ */
65
+ export function buildSkillTriageBlock(options) {
66
+ const candidates = renderSkillCandidates(options.candidates);
67
+ return candidates.length > 0 ? `${PROTOCOL}\n${candidates}` : PROTOCOL;
68
+ }
69
+ /** Byte size of the protocol half, for the budget assertion in the tests. */
70
+ export function skillTriageProtocolBytes() {
71
+ return new TextEncoder().encode(PROTOCOL).length;
72
+ }
73
+ /** The ceiling the protocol half must stay under, in bytes. */
74
+ export const SKILL_TRIAGE_PROTOCOL_MAX_BYTES = SKILL_TRIAGE_LIMITS.maxProtocolBytes;
@@ -34,8 +34,20 @@ export interface SystemPromptParams {
34
34
  * plan as its final message instead of calling tools it does not have.
35
35
  */
36
36
  planInteractive?: boolean;
37
- /** `planModeMaxAskRounds`, substituted into the block so the two agree. */
38
- planMaxAskRounds?: number;
37
+ /**
38
+ * The rendered `<skill_triage>` block plus (when there are candidates) the
39
+ * `<skill_candidates>` block, or `''`.
40
+ *
41
+ * Spliced conditionally exactly as `skillsBlock` and the plan block are, which
42
+ * is what keeps invariant I-T1 true: `skills.triage: false`, a zero-skill
43
+ * install and `--no-skills` all produce the pre-feature prompt BYTE FOR BYTE.
44
+ *
45
+ * POSITION IS THE SEMANTICS. It goes immediately AFTER `skillsBlock`, because
46
+ * its first instruction refers to `<available_skills> above`, and a block
47
+ * separated from what it points at reads as a dangling reference. It also goes
48
+ * BEFORE the plan block, whose first step defers to it.
49
+ */
50
+ skillTriageBlock?: string;
39
51
  /**
40
52
  * The rendered `<team_mode>` block for a LEAD with team mode on, or `''`.
41
53
  *
@@ -33,11 +33,11 @@ export function buildSystemPrompt(params) {
33
33
  const skillsBlock = params.skillsBlock ?? '';
34
34
  const hasSkills = skillsBlock.length > 0;
35
35
  const planBlock = params.agentMode === 'plan'
36
- ? buildPlanModeBlock({
37
- interactive: params.planInteractive !== false,
38
- maxAskRounds: params.planMaxAskRounds ?? 4,
39
- })
36
+ ? buildPlanModeBlock({ interactive: params.planInteractive !== false })
40
37
  : '';
38
+ // `''` when the caller has no candidates or the feature is off — which is what
39
+ // keeps this block's absence byte-identical to the pre-feature prompt (I-T1).
40
+ const skillTriageBlock = params.skillTriageBlock ?? '';
41
41
  const teamBlock = params.teamBlock ?? '';
42
42
  const subagentBlock = params.subagentBlock ?? '';
43
43
  const todoBlock = params.todoBlock ?? '';
@@ -61,6 +61,9 @@ export function buildSystemPrompt(params) {
61
61
  '',
62
62
  buildProjectGuidanceBlock(),
63
63
  ...(hasSkills ? ['', skillsBlock] : []),
64
+ // AFTER <available_skills> (it refers to it as "above") and BEFORE <plan_mode>
65
+ // (whose step 1 defers to it). Conditional like every other optional block.
66
+ ...(skillTriageBlock ? ['', skillTriageBlock] : []),
64
67
  ...(planBlock ? ['', planBlock] : []),
65
68
  ...(teamBlock ? ['', teamBlock] : []),
66
69
  ...(subagentBlock ? ['', subagentBlock] : []),
package/dist/cli.js CHANGED
@@ -29,7 +29,7 @@ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS,
29
29
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
30
30
  import { getLogger } from './logging/logger.js';
31
31
  import { runLogsCommand } from './logging/cli-commands.js';
32
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_MODE, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
32
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, RETIRED_CONFIG_KEYS, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_SCROLL_MODE, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
33
33
  import { makeGetApiKey } from './config/load.js';
34
34
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
35
35
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -1559,7 +1559,6 @@ const CONFIG_SET_KEYS = new Set([
1559
1559
  'scrollResumeMs',
1560
1560
  'historyEnabled',
1561
1561
  'planModeDefault',
1562
- 'planModeMaxAskRounds',
1563
1562
  'planModeHumanTimeoutMs',
1564
1563
  // Dotted keys route into the nested `skills` object; see the switch below.
1565
1564
  'skills.enabled',
@@ -1569,6 +1568,7 @@ const CONFIG_SET_KEYS = new Set([
1569
1568
  'skills.integrity',
1570
1569
  'skills.usageTracking',
1571
1570
  'skills.toolPolicy',
1571
+ 'skills.triage',
1572
1572
  // `log.*` keys are handled by `applyLogConfigSet` below, NOT by the switch:
1573
1573
  // that switch is already past this package's complexity ceiling.
1574
1574
  ...LOG_CONFIG_SET_KEYS,
@@ -1660,6 +1660,18 @@ function runConfigSet(key, value) {
1660
1660
  }
1661
1661
  }
1662
1662
  function runConfigSetValue(key, value) {
1663
+ // RETIRED KEYS ARE REFUSED BY NAME, BEFORE THE UNKNOWN-KEY CHECK. Falling
1664
+ // through to `Unknown config key` would be technically true and practically
1665
+ // useless: a script that has run `config set planModeMaxAskRounds 5` for months
1666
+ // needs to be told what happened to it, not that it never existed. Exit 2 for
1667
+ // the same reason a bad value is 2 — the caller's argument is wrong, the file
1668
+ // is not.
1669
+ const retired = RETIRED_CONFIG_KEYS[key];
1670
+ if (retired !== undefined) {
1671
+ process.stderr.write(`"${key}" was removed: ${retired}\n`);
1672
+ process.exitCode = 2;
1673
+ return;
1674
+ }
1663
1675
  if (!CONFIG_SET_KEYS.has(key)) {
1664
1676
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
1665
1677
  process.exitCode = 2;
@@ -1852,9 +1864,6 @@ function runConfigSetValue(key, value) {
1852
1864
  case 'planModeDefault':
1853
1865
  patch.planModeDefault = value === 'true' || value === '1';
1854
1866
  break;
1855
- case 'planModeMaxAskRounds':
1856
- patch.planModeMaxAskRounds = clampAskRounds(value, DEFAULT_PLAN_MAX_ASK_ROUNDS);
1857
- break;
1858
1867
  case 'planModeHumanTimeoutMs':
1859
1868
  patch.planModeHumanTimeoutMs = clampHumanTimeout(value, DEFAULT_PLAN_HUMAN_TIMEOUT_MS);
1860
1869
  break;
@@ -1893,6 +1902,13 @@ function runConfigSetValue(key, value) {
1893
1902
  toolPolicy: clampSkillsToolPolicy(value, DEFAULT_SKILLS_CONFIG.toolPolicy),
1894
1903
  };
1895
1904
  break;
1905
+ // The triage master switch. `false` restores the pre-feature prompt BYTE FOR
1906
+ // BYTE (invariant I-T1), which is the reason it is a key rather than a flag.
1907
+ case 'skills.triage':
1908
+ patch.skills = {
1909
+ triage: value === 'true' || value === '1',
1910
+ };
1911
+ break;
1896
1912
  }
1897
1913
  updatePersistedConfig(patch);
1898
1914
  process.stdout.write(`Set ${key} = ${value}\n`);
@@ -27,6 +27,7 @@ import { registerQueueCommand } from './queue-command.js';
27
27
  import { registerPromptCommands } from '../prompts/command.js';
28
28
  import { registerKitCommand } from './kit-command.js';
29
29
  import { registerAgentsCommand } from './agents-command.js';
30
+ import { registerSubagentCommand } from './subagent-command.js';
30
31
  // THE DRAWER AND THE HEADER LABEL (tui-shortcut-drawer-and-task-summary §4.7):
31
32
  // two single-purpose commands in their own modules, registered from here. Both are
32
33
  // UNCONDITIONAL, even with `taskSummary.enabled=false`, because they are the only
@@ -412,8 +413,10 @@ const COMMANDS = [
412
413
  if (arg === 'status') {
413
414
  const s = ctx.controller.getPlanStatus();
414
415
  const pending = s.pending ? ` (pending ${MODE_LABEL[s.pending]} after this run)` : '';
415
- ctx.notify('info', `Mode: ${MODE_LABEL[s.effective]}${pending}. ` +
416
- `Ask rounds used: ${s.askRoundsUsed}/${s.maxAskRounds}.`);
416
+ // NO ROUND COUNT. The old line ended with `Ask rounds used: 3/4.`, which
417
+ // told the user their agent was rationing questions - a limit that no
418
+ // longer exists. Nothing replaced it: "0 of unlimited" is noise.
419
+ ctx.notify('info', `Mode: ${MODE_LABEL[s.effective]}${pending}.`);
417
420
  return;
418
421
  }
419
422
  if (arg.length > 0 && arg !== 'on' && arg !== 'off') {
@@ -1508,6 +1511,11 @@ export function registerBuiltinCommands(registry) {
1508
1511
  // The read-only subagent board (tui-subagent-panel §6.4). Same shape again: the
1509
1512
  // body is `commands/agents-command.ts` and the registration is this one line.
1510
1513
  registerAgentsCommand(registry);
1514
+ // `/subagent` (subagent-fanout 3.5). REGISTERED UNCONDITIONALLY, like `/prompts`
1515
+ // and for the same reason: a `--no-team` session must still be able to ask the
1516
+ // command why it cannot force anything (I-7 / AC-15) - so registering it may
1517
+ // not read config, build a connection or send a request.
1518
+ registerSubagentCommand(registry);
1511
1519
  // The keyboard drawer (`Ctrl+G` / `/keys`) and the task label (`/task`). Two
1512
1520
  // registrations, no new mechanism: `/keys` sets the panel's pre-filter and opens
1513
1521
  // the same overlay `Ctrl+G` opens, and `/task` reports what the header cell says.
@@ -1522,7 +1530,8 @@ export function registerBuiltinCommands(registry) {
1522
1530
  * `registry.get(name)` instead, because that is the only check that stays
1523
1531
  * correct when commands are registered dynamically (§7.1 / P1-2).
1524
1532
  */
1525
- export const BUILTIN_COMMAND_NAMES = ['queue', 'prompts', 'agents', 'keys', 'task',
1533
+ export const BUILTIN_COMMAND_NAMES = ['queue', 'prompts', 'agents', 'subagent',
1534
+ 'keys', 'task',
1526
1535
  ...COMMANDS.flatMap((c) => [
1527
1536
  c.name,
1528
1537
  ...(c.aliases ?? []),
@@ -12,6 +12,7 @@ import { checkManifest } from '../skills/manifest.js';
12
12
  import { updateAllSkills, updateSkill, isHardFailure } from '../skills/updater.js';
13
13
  import { currentPlatform } from '../skills/node-host.js';
14
14
  import { listUsage, resetUsage, getUsagePath } from '../skills/usage.js';
15
+ import { SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
15
16
  import { HOST_TOOL_NAMES, SKILL_TOOL_FLOOR } from '../tools/index.js';
16
17
  import { SKILLS_TOOL_POLICY_MODES, isSkillsToolPolicyMode } from '../config/schema.js';
17
18
  import { pickGlyphs } from '../ui/glyphs.js';
@@ -92,12 +93,96 @@ async function runSkills(ctx, service, version) {
92
93
  return unloadFrames(ctx, service);
93
94
  case 'usage':
94
95
  return usageFromCommand(ctx, service, argument);
96
+ case 'triage':
97
+ return triageFromCommand(ctx, service, argument);
98
+ case 'route':
99
+ return routeFromCommand(ctx, service, argument);
95
100
  default:
96
101
  ctx.notify('warn', `Unknown subcommand "/skills ${sub}". Try: list, info, install, update, remove, enable, ` +
97
- 'disable, reload, create, trust, untrust, policy, unload, usage.');
102
+ 'disable, reload, create, trust, untrust, policy, unload, usage, triage, route.');
98
103
  }
99
104
  }
100
105
  // ---------------------------------------------------------------------------
106
+ // /skills triage · route (skill-triage-and-unbounded-ask §4.2.8)
107
+ // ---------------------------------------------------------------------------
108
+ /**
109
+ * `/skills triage [on|off]`.
110
+ *
111
+ * The report names the switch AND the last turn's result, because "on" alone does
112
+ * not answer the question a user actually has, which is "is it finding anything?".
113
+ * `lastSkillRouting` comes from the controller — it is per-turn session state, not
114
+ * skill-service state, and the service must not start owning a copy of it.
115
+ */
116
+ function triageFromCommand(ctx, service, argument) {
117
+ const requested = argument.trim().toLowerCase();
118
+ if (requested.length === 0) {
119
+ const on = service.getConfig().triage === true;
120
+ const last = ctx.controller.getSkillRouting();
121
+ const summary = last === null
122
+ ? 'No turn has been triaged yet.'
123
+ : last.candidates.length === 0
124
+ ? `Last turn: no candidate (${last.eligible} eligible skill(s) scored).`
125
+ : `Last turn: ${last.candidates.length} candidate(s), best ${last.candidates[0].name} (${last.candidates[0].score}).`;
126
+ ctx.notify('info', `Skill triage: ${on ? 'on' : 'off'}. ${summary}`);
127
+ return;
128
+ }
129
+ if (requested !== 'on' && requested !== 'off') {
130
+ ctx.notify('warn', `Unknown argument "${argument}" - use /skills triage [on|off].`);
131
+ return;
132
+ }
133
+ const enabled = requested === 'on';
134
+ // `setTriageEnabled` persists AND fires `onChange`, which rebuilds the prompt in
135
+ // this session. Without that second half the switch would look applied and take
136
+ // effect only after a restart (AC-T23).
137
+ service.setTriageEnabled(enabled);
138
+ // A toast, not a transcript notice: flipping a switch is an acknowledgement, and
139
+ // `toast` is this package's channel for exactly that (see `toggleSkill`). The
140
+ // level still differs by direction, so "off" does not read as a win.
141
+ ctx.toast(enabled ? 'success' : 'info', `Skill triage: ${enabled ? 'on' : 'off'}. ` +
142
+ (enabled
143
+ ? 'Each new task gets a short candidate list.'
144
+ : 'The system prompt no longer carries the triage block.'));
145
+ }
146
+ /**
147
+ * `/skills route [text]`.
148
+ *
149
+ * Two modes, one output shape. With text it routes that text; without text it
150
+ * replays the previous turn's real routing, which is the case a user is most often
151
+ * in — they just watched the model ignore a suggestion. The "never triaged" text
152
+ * names the form that works instead of leaving them to guess.
153
+ */
154
+ function routeFromCommand(ctx, service, argument) {
155
+ const task = argument.trim();
156
+ if (task.length === 0) {
157
+ const last = ctx.controller.getSkillRouting();
158
+ if (last === null) {
159
+ ctx.notify('info', 'No turn has been triaged yet. Run /skills route <text> to try one.');
160
+ return;
161
+ }
162
+ ctx.notify('info', renderRoutingReport(last));
163
+ return;
164
+ }
165
+ // A fresh computation, NOT the cached one: `/skills route <text>` is how a user
166
+ // tests whether a phrasing would have matched.
167
+ ctx.notify('info', renderRoutingReport(service.route(task)));
168
+ }
169
+ /** One `info` notification, one line per candidate, ASCII only (`commands/`). */
170
+ function renderRoutingReport(report) {
171
+ if (report.candidates.length === 0) {
172
+ const close = report.topUnqualified === null
173
+ ? ''
174
+ : ` Closest was ${report.topUnqualified.name} (${report.topUnqualified.score}); ` +
175
+ `the threshold is ${SKILL_TRIAGE_LIMITS.minScore}.`;
176
+ return (`No skill matched "${report.task}". The catalog has ${report.eligible} eligible skills.` +
177
+ close);
178
+ }
179
+ const lines = report.candidates.map((candidate) => {
180
+ const reasons = candidate.reasons.length > 0 ? candidate.reasons.join('+') : 'none';
181
+ return `${candidate.name} (${candidate.scope}) score ${candidate.score} [${reasons}]`;
182
+ });
183
+ return [`Routed "${report.task}" (${report.tokenCount} token(s)):`, ...lines].join('\n');
184
+ }
185
+ // ---------------------------------------------------------------------------
101
186
  // /skills policy · unload · usage (§5.5 / §9)
102
187
  // ---------------------------------------------------------------------------
103
188
  /**
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `/subagent` — force the next turn to fan out into a parallel team
3
+ * (subagent-fanout 3.5 / 5.2).
4
+ *
5
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHAT THIS COMMAND IS AND IS NOT (D-8 / D-9 / D-10). It does not dispatch: it
8
+ * parses the user's intent, refuses honestly when forcing cannot work, prints
9
+ * ONE line saying what will happen next, and submits a normal user turn. The
10
+ * lead then calls `task` on the one dispatch path that already exists, so Esc,
11
+ * the watchdog pause, the report, spend accounting and the concurrency lock all
12
+ * keep working without a second implementation. The consequence the user must
13
+ * be told about is that forcing is INSTRUCTION-LEVEL: the notice says "the next
14
+ * turn dispatches them", never "N subagents started".
15
+ *
16
+ * THE GUARD ORDER IS A CONTRACT (R-P1-10 / R-P1-11): status -> capability ->
17
+ * parse. A `--no-team` session typing `/subagent -n x` is told the session has
18
+ * no team capability, because a syntax hint would send them to fix the one thing
19
+ * that is not wrong.
20
+ */
21
+ import type { CommandContext, CommandRegistry } from './registry.js';
22
+ /** The command body; `registerSubagentCommand` is the only registration site. */
23
+ export declare function runSubagentCommand(ctx: CommandContext): void;
24
+ /** Registered from `builtins.ts` with one line, next to every other subsystem. */
25
+ export declare function registerSubagentCommand(registry: CommandRegistry): void;