@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
@@ -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,30 @@
1
+ /**
2
+ * `/subagent`: a DEPRECATED ALIAS of `/team` (team-command-forced-dispatch
3
+ * §3.10 / D-12 / I-8).
4
+ *
5
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY IT STILL EXISTS. It shipped, it is in five places in the README, and it is
8
+ * in the muscle memory of everyone who used the forced fan-out before `/team`
9
+ * grew its meaning. Removing the name would be a breaking change that buys
10
+ * nothing the alias does not buy for one `toast`.
11
+ *
12
+ * WHY IT HAS NO BODY. `subagent-fanout-and-forced-dispatch` implemented the
13
+ * whole grammar - control words, two ceilings, four parse errors, plan-mode
14
+ * disclosure - and every one of those bytes is now `/team`'s. A second copy here
15
+ * would drift on the first refinement, and the drift would be invisible: both
16
+ * commands would keep working, one of them would quietly be the old one. The
17
+ * file therefore holds ONE delegation and one line of deprecation text, and
18
+ * `subagent-command.test.ts` asserts both (I-8).
19
+ */
20
+ import type { CommandContext, CommandRegistry } from './registry.js';
21
+ /**
22
+ * Run `/team`, having first said out loud that this is the same thing.
23
+ *
24
+ * THE TOAST COMES FIRST ON PURPOSE: it is one ephemeral line above whatever
25
+ * `/team` prints, so the sequence reads "you used the old name; here is the
26
+ * answer" rather than a full answer followed by a correction nobody notices.
27
+ */
28
+ export declare function runSubagentCommand(ctx: CommandContext): void;
29
+ /** Registered from `builtins.ts`: the name, and nothing else, lives here. */
30
+ export declare function registerSubagentCommand(registry: CommandRegistry): void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `/subagent`: a DEPRECATED ALIAS of `/team` (team-command-forced-dispatch
3
+ * §3.10 / D-12 / I-8).
4
+ *
5
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY IT STILL EXISTS. It shipped, it is in five places in the README, and it is
8
+ * in the muscle memory of everyone who used the forced fan-out before `/team`
9
+ * grew its meaning. Removing the name would be a breaking change that buys
10
+ * nothing the alias does not buy for one `toast`.
11
+ *
12
+ * WHY IT HAS NO BODY. `subagent-fanout-and-forced-dispatch` implemented the
13
+ * whole grammar - control words, two ceilings, four parse errors, plan-mode
14
+ * disclosure - and every one of those bytes is now `/team`'s. A second copy here
15
+ * would drift on the first refinement, and the drift would be invisible: both
16
+ * commands would keep working, one of them would quietly be the old one. The
17
+ * file therefore holds ONE delegation and one line of deprecation text, and
18
+ * `subagent-command.test.ts` asserts both (I-8).
19
+ */
20
+ import { runTeamCommand } from './team-command.js';
21
+ /**
22
+ * Run `/team`, having first said out loud that this is the same thing.
23
+ *
24
+ * THE TOAST COMES FIRST ON PURPOSE: it is one ephemeral line above whatever
25
+ * `/team` prints, so the sequence reads "you used the old name; here is the
26
+ * answer" rather than a full answer followed by a correction nobody notices.
27
+ */
28
+ export function runSubagentCommand(ctx) {
29
+ ctx.toast('info', '/subagent is now /team - same arguments.');
30
+ runTeamCommand(ctx);
31
+ }
32
+ /** Registered from `builtins.ts`: the name, and nothing else, lives here. */
33
+ export function registerSubagentCommand(registry) {
34
+ registry.register({
35
+ name: 'subagent',
36
+ description: 'Deprecated alias of /team - same arguments',
37
+ run: (ctx) => { runSubagentCommand(ctx); },
38
+ });
39
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `/team`: force the session into team mode and start work, or leave it
3
+ * (team-command-forced-dispatch §3.3 / §3.4 / §5.1).
4
+ *
5
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHAT THIS COMMAND IS. `/team <brief>` parses the user's intent, ARMS a
8
+ * session-level forced mode and submits ONE normal user turn. The lead analyses
9
+ * and plans that turn and then has to call `task` - three layers say so: the
10
+ * directive it is handed, the `<forced_team_mode>` block in every later system
11
+ * prompt, and the mechanical gate in `ui/App.tsx` that nudges once if a whole
12
+ * turn went by without a dispatch. This command never dispatches itself (I-1):
13
+ * Esc, the watchdog, the report, spend accounting and the concurrency lock all
14
+ * stay on the one path that already has them.
15
+ *
16
+ * WHAT IT IS NOT ANY MORE. It used to be the CONFIG command - `status | on | off
17
+ * | max <n> | overseer on|off | model <name>`. All five verbs now live in the
18
+ * settings screen (`Settings > Team`, plus the root `Subagent model` row), and
19
+ * this command writes not one byte of `config.json` (I-4). The reason is the one
20
+ * the requirement names: a command that both changes configuration and starts
21
+ * work leaves the user unable to predict what it did. `on` and `off` survive as
22
+ * SESSION CONTROL words - a mode needs enter, leave and report - and they never
23
+ * persist; the settings screen is the only persistent writer (I-4 / D-11).
24
+ *
25
+ * THE GUARD ORDER IS A CONTRACT (§3.4, R-P1-10 / R-P1-11) and it is not
26
+ * alphabetical:
27
+ *
28
+ * status (no guard) - "what is on?" is a question, answerable even in a
29
+ * `--no-team` session, which is where users ask it.
30
+ * off (no guard) - leaving is always possible, even mid-dispatch.
31
+ * registered? - a `--no-team` session cannot grow the tool (I-7).
32
+ * enabled? - NOT a refusal: it is turned on for this session
33
+ * (D-5), because "start now" must not detour through a
34
+ * settings screen. Nothing is persisted.
35
+ * not busy / idle - two separate sentences, because both would otherwise
36
+ * surface as `task`'s own refusal card.
37
+ * key present? - names the missing provider.
38
+ * on - arm only; the next message dispatches.
39
+ *
40
+ * EVERY `armForcedTeam()` / `disarmForcedTeam()` CALL SITE BELOW IS FOLLOWED BY A
41
+ * `ctx.notify` / `ctx.toast` IN THE SAME BRANCH (P1-6 / AC-28), because arming
42
+ * itself produces no `AgentEvent` and therefore no render: without the notice the
43
+ * `Team 4` chip would wait for an unrelated state change before appearing.
44
+ */
45
+ import type { CommandContext, CommandRegistry } from './registry.js';
46
+ /** The command body; `registerTeamCommand` is the only registration site. */
47
+ export declare function runTeamCommand(ctx: CommandContext): void;
48
+ /** Registered from `builtins.ts` with one line, next to every other subsystem. */
49
+ export declare function registerTeamCommand(registry: CommandRegistry): void;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * `/team`: force the session into team mode and start work, or leave it
3
+ * (team-command-forced-dispatch §3.3 / §3.4 / §5.1).
4
+ *
5
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHAT THIS COMMAND IS. `/team <brief>` parses the user's intent, ARMS a
8
+ * session-level forced mode and submits ONE normal user turn. The lead analyses
9
+ * and plans that turn and then has to call `task` - three layers say so: the
10
+ * directive it is handed, the `<forced_team_mode>` block in every later system
11
+ * prompt, and the mechanical gate in `ui/App.tsx` that nudges once if a whole
12
+ * turn went by without a dispatch. This command never dispatches itself (I-1):
13
+ * Esc, the watchdog, the report, spend accounting and the concurrency lock all
14
+ * stay on the one path that already has them.
15
+ *
16
+ * WHAT IT IS NOT ANY MORE. It used to be the CONFIG command - `status | on | off
17
+ * | max <n> | overseer on|off | model <name>`. All five verbs now live in the
18
+ * settings screen (`Settings > Team`, plus the root `Subagent model` row), and
19
+ * this command writes not one byte of `config.json` (I-4). The reason is the one
20
+ * the requirement names: a command that both changes configuration and starts
21
+ * work leaves the user unable to predict what it did. `on` and `off` survive as
22
+ * SESSION CONTROL words - a mode needs enter, leave and report - and they never
23
+ * persist; the settings screen is the only persistent writer (I-4 / D-11).
24
+ *
25
+ * THE GUARD ORDER IS A CONTRACT (§3.4, R-P1-10 / R-P1-11) and it is not
26
+ * alphabetical:
27
+ *
28
+ * status (no guard) - "what is on?" is a question, answerable even in a
29
+ * `--no-team` session, which is where users ask it.
30
+ * off (no guard) - leaving is always possible, even mid-dispatch.
31
+ * registered? - a `--no-team` session cannot grow the tool (I-7).
32
+ * enabled? - NOT a refusal: it is turned on for this session
33
+ * (D-5), because "start now" must not detour through a
34
+ * settings screen. Nothing is persisted.
35
+ * not busy / idle - two separate sentences, because both would otherwise
36
+ * surface as `task`'s own refusal card.
37
+ * key present? - names the missing provider.
38
+ * on - arm only; the next message dispatches.
39
+ *
40
+ * EVERY `armForcedTeam()` / `disarmForcedTeam()` CALL SITE BELOW IS FOLLOWED BY A
41
+ * `ctx.notify` / `ctx.toast` IN THE SAME BRANCH (P1-6 / AC-28), because arming
42
+ * itself produces no `AgentEvent` and therefore no render: without the notice the
43
+ * `Team 4` chip would wait for an unrelated state change before appearing.
44
+ */
45
+ import { buildForcedDirective } from '../team/forced-dispatch.js';
46
+ import { DEFAULT_FORCED_COUNT, planForced, planNotice, readTeamIntent, } from '../team/forced-mode.js';
47
+ import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
48
+ /**
49
+ * The subagent model line, phrased by the same authority the settings root row
50
+ * uses (`subagentModelSummary`), fed the six inputs only this layer can gather.
51
+ *
52
+ * IT IS A FUNCTION AND NOT AN INLINE CALL because the six inputs are exactly
53
+ * what a second copy gets wrong, and the settings root row feeds the same
54
+ * authority (`subagentModelSummary`) the same six - which is what keeps the two
55
+ * surfaces from describing one setting differently.
56
+ */
57
+ function modelLine(ctx) {
58
+ const config = ctx.controller.getConfig();
59
+ const team = ctx.controller.getTeamConfig();
60
+ const fast = config.fast;
61
+ return subagentModelSummary({
62
+ mode: team.model,
63
+ modelProfileId: team.modelProfileId,
64
+ mainConnection: `${config.provider}:${config.model}`,
65
+ fastConnection: fast.enabled && fast.model
66
+ ? `${fast.provider || config.provider}:${fast.model}` : null,
67
+ profiles: config.modelProfiles,
68
+ hasKey: (id) => ctx.controller.hasApiKey(id),
69
+ });
70
+ }
71
+ /**
72
+ * The no-argument answer: five lines, honest in every session state (§5.1).
73
+ *
74
+ * IT RUNS BEFORE EVERY GUARD. "How do I use this?" is a question, not a request,
75
+ * and it is answerable in a `--no-team` session - which is exactly where the user
76
+ * most needs the answer.
77
+ */
78
+ function reportStatus(ctx) {
79
+ const team = ctx.controller.getTeamConfig();
80
+ const forced = ctx.controller.getForcedTeam();
81
+ const registered = ctx.controller.isTeamRegistered();
82
+ const lines = [
83
+ forced
84
+ ? `Forced team mode: on, ${forced.count} subagents per turn.`
85
+ : 'Forced team mode: off.',
86
+ registered
87
+ ? `Team: ${ctx.controller.isTeamEnabled() ? 'on' : 'off'}; `
88
+ + `${team.maxSubagents} per dispatch, ${team.maxConcurrent} at once; `
89
+ + `supervisor ${team.overseer ? 'on' : 'off'}.`
90
+ : 'Team: off for this session (started with --no-team); /team cannot dispatch.',
91
+ `Subagent model: ${modelLine(ctx)}`,
92
+ 'Usage: /team <brief> arms it and starts now; /team off leaves it.',
93
+ 'Settings > Team has the persistent switches (mode, width, supervisor).',
94
+ ];
95
+ // DISCLOSED, NEVER REFUSED (§5.1 third degraded form): plan mode narrows what a
96
+ // child may do, and a user who is told so will not read the restraint as a bug.
97
+ if (forced && ctx.controller.getAgentMode() === 'plan') {
98
+ lines.push('Plan mode is on: dispatch read-only research, then submit_plan.');
99
+ }
100
+ ctx.notify('info', lines.join('\n'));
101
+ }
102
+ /**
103
+ * Guard 5's sentence. The provider is named because "no API key" alone leaves the
104
+ * user guessing which of their configured providers is the empty one.
105
+ */
106
+ function reportMissingKey(ctx, team) {
107
+ const config = ctx.controller.getConfig();
108
+ const resolution = ctx.controller.subagentModelResolution();
109
+ const provider = resolution.effective === 'main'
110
+ ? config.provider : resolution.ref.providerId;
111
+ const note = team.model === 'fast' && !ctx.controller.hasApiKey(undefined, 'fast')
112
+ ? ' Fast tier has no key either; children will fail and the report will say so.'
113
+ : '';
114
+ ctx.notify('warn', `No API key for ${provider}. Set one with /settings or an environment variable.${note}`);
115
+ }
116
+ /**
117
+ * The capability guards, in §3.4's exact order: registered -> enabled -> busy ->
118
+ * running -> key.
119
+ *
120
+ * RETURNS `true` WHEN THE CALLER MAY CONTINUE. Step 5 is the one place a guard
121
+ * becomes an ACTION (D-5) and it sits at its documented position rather than at
122
+ * the end for a behavioural reason: the user's own sentence is "start now", so
123
+ * the switch is turned on before any sentence about *why we cannot* is composed.
124
+ * The action is still followed by a notice in the same branch (P1-6 / AC-28),
125
+ * because `setTeamEnabled()` rebuilds the prompt and produces no `AgentEvent`, so
126
+ * without it the `Team N` chip would wait for an unrelated render.
127
+ */
128
+ function passGuards(ctx, team) {
129
+ if (!ctx.controller.isTeamRegistered()) {
130
+ ctx.notify('warn', 'Team mode is off for this session (started with --no-team). '
131
+ + 'Restart without --no-team to use /team.');
132
+ return false;
133
+ }
134
+ // STEP 5, THE ONE GUARD THAT IS NOT A REFUSAL: "start now" must not detour
135
+ // through a settings screen. Nothing is persisted here (I-4) - `Settings >
136
+ // Team` stays the only persistent writer.
137
+ if (!ctx.controller.isTeamEnabled()) {
138
+ ctx.controller.setTeamEnabled(true);
139
+ ctx.notify('info', 'Team mode was off; turned on for this session (Settings > Team saves it).');
140
+ }
141
+ // The team lock and the lead's turn are separate refusals for one reason: both
142
+ // would collide with `task`'s own BUSY_REFUSAL, and the user would see a failed
143
+ // tool card instead of a sentence explaining why nothing ran.
144
+ if (ctx.controller.isTeamBusy()) {
145
+ ctx.notify('warn', 'A team dispatch is already running. '
146
+ + 'Wait for its report, or press Esc to stop it.');
147
+ return false;
148
+ }
149
+ if (ctx.controller.isRunning()) {
150
+ ctx.notify('warn', 'A turn is in progress. Press Esc to stop it, then run /team again.');
151
+ return false;
152
+ }
153
+ if (!ctx.controller.hasApiKey()) {
154
+ reportMissingKey(ctx, team);
155
+ return false;
156
+ }
157
+ return true;
158
+ }
159
+ /** The four parse failures, each with the one sentence that fixes it. */
160
+ function reportParseError(ctx, intent) {
161
+ if (intent.error === 'count') {
162
+ ctx.notify('warn', '/team: -n needs a number of 1 or more, e.g. -n 4.');
163
+ return;
164
+ }
165
+ if (intent.error === 'conflict') {
166
+ ctx.notify('warn', '/team: use -n or --parts, not both.');
167
+ return;
168
+ }
169
+ if (intent.error === 'flag') {
170
+ ctx.notify('warn', `/team: unknown option "${intent.token ?? ''}". Try /team for usage.`);
171
+ return;
172
+ }
173
+ ctx.notify('warn', '/team <brief> - describe the work; add -n <count> to fix the fan-out.');
174
+ }
175
+ /**
176
+ * `/team off`: disarm the session, and say plainly that the CAPABILITY was not
177
+ * touched.
178
+ *
179
+ * THE SECOND HALF OF THE SENTENCE IS THE POINT (D-2 / D-11): with the switch
180
+ * moved to settings, "off" can only ever mean "this conversation stops being
181
+ * forced". A user who reads it as "team mode is now disabled everywhere" would
182
+ * next find `/team` still working tomorrow, and no test would describe that as a
183
+ * defect because no single surface would be wrong.
184
+ */
185
+ function leaveForcedMode(ctx) {
186
+ if (!ctx.controller.getForcedTeam()) {
187
+ // IDEMPOTENT, and it says so rather than reporting a change it did not make.
188
+ ctx.notify('info', 'Forced team mode was not on.');
189
+ return;
190
+ }
191
+ ctx.controller.disarmForcedTeam();
192
+ ctx.notify('info', 'Forced team mode off. Team capability unchanged (Settings > Team).');
193
+ }
194
+ /** `/team on`: arm with the default count and stop there - no turn is submitted. */
195
+ function enterForcedMode(ctx) {
196
+ if (!passGuards(ctx, ctx.controller.getTeamConfig()))
197
+ return;
198
+ const adopted = ctx.controller.armForcedTeam(DEFAULT_FORCED_COUNT);
199
+ ctx.notify('info', `Forced team mode on: ${adopted} subagents per turn. `
200
+ + 'Your next message dispatches.');
201
+ }
202
+ /** `/team [-n n|--parts a,b] [--read-only] <brief>`: arm and submit in one act. */
203
+ function armAndSubmit(ctx, intent) {
204
+ const team = ctx.controller.getTeamConfig();
205
+ if (!passGuards(ctx, team))
206
+ return;
207
+ const planned = planForced(intent.args, team);
208
+ // ARMED BEFORE THE TURN IS SUBMITTED, and with the SAME narrowed number the
209
+ // directive names: the later turns reuse this count, so the first turn and the
210
+ // rest of the session cannot disagree about how wide the fan-out is (D-7).
211
+ ctx.controller.armForcedTeam(planned.count);
212
+ ctx.notify('info', planNotice(planned, team));
213
+ const directive = buildForcedDirective({
214
+ brief: planned.brief,
215
+ count: planned.count,
216
+ readOnly: planned.readOnly,
217
+ parts: planned.parts,
218
+ maxSubagents: team.maxSubagents,
219
+ clamped: planned.requested > planned.count,
220
+ requested: planned.requested,
221
+ planMode: ctx.controller.getAgentMode() === 'plan',
222
+ });
223
+ // `userInitiated: false`: a directive the user did not type belongs in neither
224
+ // the prompt history nor the hint-fade counter (todo-plan-followthrough C-12).
225
+ ctx.submit(directive, { userInitiated: false });
226
+ }
227
+ /** The command body; `registerTeamCommand` is the only registration site. */
228
+ export function runTeamCommand(ctx) {
229
+ const intent = readTeamIntent(ctx.args);
230
+ if (intent.kind === 'status') {
231
+ reportStatus(ctx);
232
+ return;
233
+ }
234
+ if (intent.kind === 'off') {
235
+ leaveForcedMode(ctx);
236
+ return;
237
+ }
238
+ if (intent.kind === 'on') {
239
+ enterForcedMode(ctx);
240
+ return;
241
+ }
242
+ if (intent.kind === 'error') {
243
+ // PARSED BEFORE THE CAPABILITY GUARDS, unlike `on` / `<brief>`. A syntax hint
244
+ // is the answer to a syntax mistake in ANY session: sending a `--no-team` user
245
+ // to fix their flags first would send them to repair the one thing that is
246
+ // not broken.
247
+ reportParseError(ctx, intent);
248
+ return;
249
+ }
250
+ armAndSubmit(ctx, intent);
251
+ }
252
+ /** Registered from `builtins.ts` with one line, next to every other subsystem. */
253
+ export function registerTeamCommand(registry) {
254
+ registry.register({
255
+ name: 'team',
256
+ description: 'Force team mode and start now: /team <brief>; /team [on|off|status]',
257
+ run: (ctx) => { runTeamCommand(ctx); },
258
+ });
259
+ }
@@ -16,6 +16,7 @@ import { parseThresholdInput } from '../config/schema.js';
16
16
  import { formatCost, formatTokens } from '../agent/usage.js';
17
17
  import { computeCost } from '../agent/usage.js';
18
18
  import { COMPACTION_LIMITS } from './limits.js';
19
+ import { compactionReasonLine } from './reason-text.js';
19
20
  import { findArchive, listArchives } from './archive.js';
20
21
  /**
21
22
  * `/compact status` — THE GUARANTEED REPORTING SURFACE (§6.5 / AC-17).
@@ -46,7 +47,7 @@ export function formatCompactionStatus(ctx) {
46
47
  return lines.join('\n');
47
48
  }
48
49
  const state = snapshot.selfDisabled
49
- ? `self-disabled (${snapshot.selfDisabledReason ?? 'no progress'})`
50
+ ? `self-disabled (${compactionReasonLine(snapshot.selfDisabledReason ?? 'no progress')})`
50
51
  : controller.isCompactionEnabled()
51
52
  ? snapshot.live
52
53
  ? 'on'
@@ -61,7 +62,13 @@ export function formatCompactionStatus(ctx) {
61
62
  `[${p.source === 'usage' ? 'measured' : 'estimated'}]`);
62
63
  lines.push(` Automatic trigger: occupancy >= ${(config.threshold * 100).toFixed(2)}%; ` +
63
64
  `manual: /compact (${Math.ceil(p.contextWindow * config.threshold)} tokens)`);
65
+ // TWO LINES, BECAUSE THE TAIL GUARANTEE IS NOW CONDITIONAL (never-fires B).
66
+ // `keepRecentTurns` stays the promise; the second line is what happens when a run
67
+ // has too few user turns for that promise to be satisfiable, and saying it here
68
+ // is what makes the card's `kept M of N` readable rather than surprising.
64
69
  lines.push(` Keeps: the original task + ${config.keepRecentTurns} recent turns, verbatim`);
70
+ lines.push(` Relaxes to: the last ${COMPACTION_LIMITS.minRetainedMessages} messages ` +
71
+ 'when a run has too few user turns for that');
65
72
  lines.push(` Summarizer: ${snapshot.model || '(none)'}` +
66
73
  (config.useFastTier ? ' (fast tier preferred)' : ' (fast tier not used)'));
67
74
  lines.push(' On summarize failure: ' + (config.onFailure === 'truncate'
@@ -79,8 +86,14 @@ export function formatCompactionStatus(ctx) {
79
86
  if (declined > 0) {
80
87
  sessionNotes.push(`${declined} ${declined === 1 ? 'checkpoint' : 'checkpoints'} declined`);
81
88
  }
82
- lines.push(` This session: ${snapshot.compactions} ` +
83
- `${snapshot.compactions === 1 ? 'compaction' : 'compactions'}` +
89
+ // ATTEMPTS, THEN APPLIED (never-fires C). `compactions` counts every card the
90
+ // user saw, which now includes the ones that declined, so reporting it alone
91
+ // would overstate how much the feature actually achieved.
92
+ const attempts = snapshot.applied === undefined
93
+ ? `${snapshot.compactions} ${snapshot.compactions === 1 ? 'compaction' : 'compactions'}`
94
+ : `${snapshot.compactions} ${snapshot.compactions === 1 ? 'attempt' : 'attempts'}` +
95
+ ` (${snapshot.applied} applied)`;
96
+ lines.push(` This session: ${attempts}` +
84
97
  (sessionNotes.length > 0 ? ` (${sessionNotes.join(', ')})` : '') +
85
98
  `, ${formatTokens(snapshot.tokensReclaimed)} tokens reclaimed`);
86
99
  const cost = snapshot.pricingUnknown
@@ -93,6 +93,7 @@ export declare class Compactor implements ContextManager {
93
93
  private compactionsThisRun;
94
94
  private lastCompactionTurn;
95
95
  private compactions;
96
+ private appliedCompactions;
96
97
  private declined;
97
98
  private tokensReclaimed;
98
99
  private sessionUsage;
@@ -132,8 +133,31 @@ export declare class Compactor implements ContextManager {
132
133
  private callOnce;
133
134
  private checkCandidate;
134
135
  private isCurrent;
136
+ /**
137
+ * Open the card for THIS attempt (C).
138
+ *
139
+ * CALLED FROM `compact()`'S ENTRY, so the two pre-model rejections are visible
140
+ * too. The old position - inside `callOnce` - made an attempt that never called
141
+ * a model indistinguishable from a checkpoint that never fired, which is exactly
142
+ * how this feature spent 96 attempts without a single user-visible symptom.
143
+ *
144
+ * `state.index !== undefined` keeps it idempotent: `callOnce` no longer calls it,
145
+ * but the guard stays so a future re-announce cannot open a second card for one
146
+ * attempt.
147
+ */
135
148
  private announce;
149
+ /** Publish the current phase, for the live card only. */
150
+ private phase;
136
151
  private keep;
152
+ /**
153
+ * The relaxation disclosure, as record fields.
154
+ *
155
+ * ONE PLACE BUILDS IT, so the card, the reducer and the diagnostic log cannot
156
+ * describe one relaxed cut three different ways. Absent entirely when the first
157
+ * planning pass found a cut, which is what keeps a normal compaction's record
158
+ * byte-identical to the round before this change.
159
+ */
160
+ private relaxedFields;
137
161
  private finishRecord;
138
162
  /** Commit only the engine/host verdict; candidates have no authority before this point. */
139
163
  settleOperation(verdict: {
@@ -152,6 +176,7 @@ export declare class Compactor implements ContextManager {
152
176
  sessionTotals(): {
153
177
  selfDisabledReason?: string | undefined;
154
178
  compactions: number;
179
+ applied: number;
155
180
  declined: number;
156
181
  tokensReclaimed: number;
157
182
  usage: TokenUsage;