@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
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `/subagent` — parse the user's arguments and compile them into ONE user turn
3
+ * (subagent-fanout 3.5.2 / 3.5.4).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope, and this module
6
+ * also produces text a model reads in a `cmd.exe` terminal.
7
+ *
8
+ * WHY A PROMPT AND NOT A DIRECT `dispatch()` CALL (D-8 / 3.5.1, option C). The
9
+ * alternative — the command splits the brief itself and calls
10
+ * `TeamRuntime.dispatch()` — needs a SECOND dispatch entry point (breaking I-1),
11
+ * a run lifecycle the command does not own (Esc, the lead's idle watchdog, the
12
+ * report rendered into the transcript, spend accounting, the concurrency lock),
13
+ * and a planner that cannot see the conversation. Compiling the user's intent
14
+ * into a normal user turn keeps all of that on the one path that already has it.
15
+ * The cost is that forcing is INSTRUCTION-LEVEL rather than mechanical, which is
16
+ * why the command body prints a plan ("the next turn dispatches them") instead
17
+ * of claiming results (D-10).
18
+ *
19
+ * TWO PURE FUNCTIONS, NO IO: parse and compile. Both are deterministic, so the
20
+ * whole surface is unit-testable without a terminal, a config file or a model.
21
+ */
22
+ import { TEAM_LIMITS } from './limits.js';
23
+ import { toSlug } from './normalize.js';
24
+ /**
25
+ * Bumped whenever the directive's wording changes; the string appears VERBATIM
26
+ * on the directive's first line so a behaviour report ties to one revision with
27
+ * a single grep (D-15). The `-YYYY-MM` suffix is part of the version, not a
28
+ * comment: `v1` alone is easy to confuse with the other counters in this package.
29
+ */
30
+ export const FORCED_DISPATCH_VERSION = 'v1-2026-10';
31
+ /**
32
+ * A DECIMAL POSITIVE INTEGER, and nothing else.
33
+ *
34
+ * `0` is deliberately NOT accepted (R-P1-2). Writing `/subagent -n 0` means the
35
+ * user mistyped; silently clamping it to 1 would rewrite their intent, which is
36
+ * the one thing a clamp must never do. `-n 12` is different: 12 is unambiguous
37
+ * and merely exceeds the ceiling, so it is accepted and DISCLOSED as narrowed.
38
+ */
39
+ const COUNT_PATTERN = /^[1-9][0-9]*$/;
40
+ /** The flag name of a token, or `null` for a plain word (part of the brief). */
41
+ function flagName(word) {
42
+ if (word.length < 2 || !word.startsWith('-'))
43
+ return null;
44
+ return word.replace(/^--?/, '').toLowerCase();
45
+ }
46
+ /** The brief, clipped with a VISIBLE marker (`forcedBriefChars`). */
47
+ function clippedBrief(brief) {
48
+ const limit = TEAM_LIMITS.forcedBriefChars;
49
+ return brief.length > limit ? `${brief.slice(0, limit)}\n[brief truncated]` : brief;
50
+ }
51
+ /**
52
+ * Parse `/subagent` arguments. ONE parameter (R-P1-1): the structural bounds are
53
+ * constants this module imports, not a caller's opinion.
54
+ *
55
+ * NEVER THROWS and never guesses. Every malformed input maps to one of the four
56
+ * errors the command body has a sentence for, because "we did something
57
+ * reasonable with your typo" is indistinguishable from a bug at the terminal.
58
+ */
59
+ export function parseSubagentArgs(raw) {
60
+ const words = raw.split(/\s+/).filter((word) => word.length > 0);
61
+ const brief = [];
62
+ const parts = [];
63
+ let count = null;
64
+ let readOnly = false;
65
+ // `--parts` was SEEN, even if every item it carried cleaned away. The conflict
66
+ // rule is about the two flags appearing together, not about the usable parts
67
+ // that survived: `-n 3 --parts ,,,` still names two sources for one number, and
68
+ // silently obeying the count would drop a flag the user typed.
69
+ let sawParts = false;
70
+ for (let index = 0; index < words.length;) {
71
+ const word = words[index];
72
+ const flag = flagName(word);
73
+ if (flag === null) {
74
+ brief.push(word);
75
+ index += 1;
76
+ continue;
77
+ }
78
+ if (flag === 'n' || flag === 'count') {
79
+ const value = words[index + 1];
80
+ if (value === undefined || !COUNT_PATTERN.test(value))
81
+ return { ok: false, error: 'count' };
82
+ count = Number(value);
83
+ index += 2;
84
+ continue;
85
+ }
86
+ if (flag === 'parts') {
87
+ const value = words[index + 1];
88
+ // A flag with no value is unusable rather than guessable, so it is
89
+ // reported as a bad option instead of being read as "no parts, then".
90
+ if (value === undefined)
91
+ return { ok: false, error: 'flag', token: word };
92
+ sawParts = true;
93
+ // A part that cleans to nothing is DROPPED rather than kept as an empty
94
+ // label; if every part cleans away the flag is treated as absent, because
95
+ // an empty part list cannot narrow a dispatch the brief still describes.
96
+ for (const item of value.split(',')) {
97
+ const slug = toSlug(item);
98
+ if (slug.length > 0 && !parts.includes(slug))
99
+ parts.push(slug);
100
+ }
101
+ index += 2;
102
+ continue;
103
+ }
104
+ if (flag === 'read-only') {
105
+ readOnly = true;
106
+ index += 1;
107
+ continue;
108
+ }
109
+ // `-n=6` lands here: ONE spelling per option, so the `=` form is an unknown
110
+ // token rather than a second grammar with its own edge cases.
111
+ return { ok: false, error: 'flag', token: word };
112
+ }
113
+ // Count and parts answer the same question, so a request carrying both is
114
+ // refused instead of resolved in one direction the user did not choose.
115
+ if (count !== null && sawParts)
116
+ return { ok: false, error: 'conflict' };
117
+ const text = brief.join(' ').trim();
118
+ if (text.length === 0)
119
+ return { ok: false, error: 'brief' };
120
+ return {
121
+ ok: true,
122
+ args: {
123
+ brief: text,
124
+ count,
125
+ parts: parts.slice(0, TEAM_LIMITS.forcedPartsMax),
126
+ partsRequested: parts.length,
127
+ readOnly,
128
+ },
129
+ };
130
+ }
131
+ /**
132
+ * The sentence naming how many children to start.
133
+ *
134
+ * THE PERMISSION CLAUSE IS NOT OPTIONAL IN EITHER BRANCH. `--parts` REPLACES the
135
+ * count sentence, so a `readOnly` qualifier written only into the count branch
136
+ * disappears exactly when both flags are used - and `/subagent --read-only
137
+ * --parts api,ui <brief>` is one of the three examples the README advertises. The
138
+ * children would then be writeable while the notice said a read-only fan-out was
139
+ * forced (AC-13).
140
+ */
141
+ function callSentence(request) {
142
+ const { count, readOnly, parts } = request;
143
+ if (parts.length > 0) {
144
+ const labels = 'Use those exact labels';
145
+ return `Call the \`task\` tool ONCE with one subagent per part: ${parts.join(', ')}. `
146
+ + (readOnly ? `${labels}; every spec readOnly: true.` : `${labels}.`);
147
+ }
148
+ const noun = count === 1 ? 'subagent' : 'subagents';
149
+ const permission = readOnly ? ', all readOnly: true' : '';
150
+ return `Call the \`task\` tool ONCE with exactly ${count} ${noun}${permission}.`;
151
+ }
152
+ /**
153
+ * The ONE user turn a `/subagent` invocation submits. Same input, same bytes -
154
+ * which is what makes the snapshot tests meaningful.
155
+ *
156
+ * THE ORDER OF THE SENTENCES IS THE ORDER OF THE MODEL'S DECISIONS: what to do
157
+ * (call `task` ONCE), what it must not do instead (work alone), then the lucky
158
+ * case (the brief does not split), then the brief itself, then the disclosure
159
+ * lines, then the per-spec rules.
160
+ */
161
+ export function buildForcedDirective(request) {
162
+ const { brief, count, parts, maxSubagents, clamped, requested, planMode } = request;
163
+ // ONE SENTENCE PER LINE, and deliberately not hard-wrapped: the contract in
164
+ // 6.4 is checked by grepping phrases (`Call the `task` tool ONCE`, `Use those
165
+ // exact labels`), and a wrapper that split one of them across a newline would
166
+ // make the document and the artifact disagree about the same bytes.
167
+ const lines = [
168
+ `[forced team dispatch ${FORCED_DISPATCH_VERSION}] The user is forcing a parallel team `
169
+ + 'on this brief.',
170
+ callSentence(request),
171
+ 'Then act on the combined report. Do not do the work yourself; the subagents exist to do it.',
172
+ ];
173
+ // NO "at least 2" FALLBACK FOR A SINGLE CHILD (R-P1-4): a sentence asking for
174
+ // at least two would be the louder instruction in a directive that asked for
175
+ // exactly one, and the model would follow it.
176
+ if (count > 1 && parts.length === 0) {
177
+ lines.push(`If the brief genuinely cannot be split into ${count} independent parts, use as many `
178
+ + 'as it splits into (at least 2) and say why in one line.');
179
+ }
180
+ lines.push('', 'Brief:', clippedBrief(brief), '');
181
+ // A PARTS LIST CAN BE NARROWED BY TWO DIFFERENT CEILINGS (R-P1-3), and the
182
+ // spec's sentence - "the configured maximum is N" - is only true when the
183
+ // POLICY ceiling is the one that bit. `parts.length >= maxSubagents` says it
184
+ // is; below that the structural `forcedPartsMax` did the cutting, and naming
185
+ // the configured maximum would point the user at a number they can change to
186
+ // fix a limit they cannot.
187
+ if (clamped) {
188
+ if (parts.length > 0) {
189
+ lines.push(parts.length >= maxSubagents
190
+ ? `You asked for ${requested} parts; the configured maximum is ${maxSubagents}, `
191
+ + 'so the remaining parts are folded into these.'
192
+ : `You asked for ${requested} parts; only ${parts.length} fit in one directive, `
193
+ + 'so the remaining parts are folded into these.');
194
+ }
195
+ else {
196
+ lines.push(`You asked for ${requested}; the configured maximum is ${maxSubagents}, `
197
+ + `so use ${count}.`);
198
+ }
199
+ }
200
+ if (planMode) {
201
+ lines.push('Plan mode is on; if the task tool is unavailable this turn, say so instead of '
202
+ + 'doing the work alone.');
203
+ }
204
+ lines.push('Rules for each spec: a self-contained prompt (the child has no memory of this '
205
+ + `conversation), a description of at most ${TEAM_LIMITS.descriptionChars} chars, a short `
206
+ + 'slug label, and a disjoint set of files per child.');
207
+ return lines.join('\n');
208
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Forced team mode: the SESSION STATE type and the PURE half of `/team`
3
+ * (team-command-forced-dispatch §3.3 / §3.5 / §5.2).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope, and this module
6
+ * also produces text a model reads in a `cmd.exe` terminal.
7
+ *
8
+ * WHAT IS AND IS NOT HERE. Every export below is a deterministic function of its
9
+ * arguments: no `CommandContext`, no controller, no IO, no React. That is what
10
+ * lets the whole grammar - control words, the quote escape hatch, the two
11
+ * ceilings, the disclosure line - be tested without a terminal, a config file or
12
+ * a model. The command body (`commands/team-command.ts`) keeps the ctx-dependent
13
+ * half: speaking to the user, arming the session, submitting the turn.
14
+ *
15
+ * THE INTENT IS PARSED IN ONE STEP (§3.3), because the three rules interact:
16
+ *
17
+ * 1. CONTROL WORDS ARE WHOLE-ARGUMENT ONLY. `off the record` is a brief whose
18
+ * first word happens to be `off`; treating it as "turn off, then take notes"
19
+ * would delete a legitimate brief from the grammar.
20
+ * 2. THE QUOTE ESCAPE HATCH IS CHECKED BEFORE THE CONTROL WORDS. `/team "off"`
21
+ * asks for a fan-out on the brief `off`. Without this rule the three control
22
+ * words would be permanently unavailable as briefs, and a user working on a
23
+ * feature called "off" would have no way to say so.
24
+ * 3. FLAG PARSING IS NOT REIMPLEMENTED. `parseSubagentArgs` owns the four error
25
+ * classes and both disclosure ceilings, and its bytes are pinned by
26
+ * `subagent-command.test.ts`; a second grammar here would drift from it on
27
+ * the first `-n=6` someone tried.
28
+ */
29
+ import type { SubagentCommandArgs } from './forced-dispatch.js';
30
+ import type { TeamConfig } from '../config/schema.js';
31
+ /**
32
+ * The default count when the user names none (D-7).
33
+ *
34
+ * FOUR, not `maxSubagents`: `<team_mode>` tells the model two to four is usually
35
+ * enough, so "force one dispatch" must not silently become the most expensive
36
+ * call the session can make. The status line advertises the real ceiling so the
37
+ * user who wants the widest fan-out knows which flag to add.
38
+ */
39
+ export declare const DEFAULT_FORCED_COUNT = 4;
40
+ /**
41
+ * What one `/team ...` argument string means. A TOTAL union: every input the
42
+ * lexer can see maps to exactly one member, which is what lets the command body
43
+ * be a flat dispatch rather than a chain of "did we already answer?" flags.
44
+ */
45
+ export type TeamIntent = {
46
+ kind: 'status';
47
+ } | {
48
+ kind: 'off';
49
+ } | {
50
+ kind: 'on';
51
+ } | {
52
+ kind: 'brief';
53
+ args: SubagentCommandArgs;
54
+ quoted: boolean;
55
+ } | {
56
+ kind: 'error';
57
+ error: 'count' | 'conflict' | 'flag' | 'brief';
58
+ token?: string;
59
+ };
60
+ /**
61
+ * Session-level forced team mode: the state `/team` writes and `<forced_team_mode>`
62
+ * plus the status chip read (team-command-forced-dispatch §3.5 / §6.1).
63
+ *
64
+ * `null` MEANS "NOT ARMED" and is the constructor's value. It is never persisted
65
+ * (D-4): no `config.json` key, no session-file field, no checkpoint. A persisted
66
+ * "always fan out" would be inherited by `aragon exec` and by every launch after
67
+ * one experiment, at N children a turn - and the whole point of the bound in
68
+ * `dispatch-gate.ts` is that nobody may widen it by accident.
69
+ */
70
+ export interface ForcedTeamState {
71
+ /** Children per turn. Already clamped to `[1, team.maxSubagents]`. */
72
+ count: number;
73
+ }
74
+ /**
75
+ * Parse the whole argument of `/team`. NEVER THROWS and never guesses.
76
+ *
77
+ * The empty argument is the STATUS question, not an error: "what is on?" is the
78
+ * one thing a user asks a mode command most often, and it must answer even in a
79
+ * session that cannot dispatch at all (that is exactly where they ask).
80
+ */
81
+ export declare function readTeamIntent(raw: string): TeamIntent;
82
+ /**
83
+ * How many children the ONE directive text must ask for, and why the count
84
+ * shrank. Every field is read by either the directive or the disclosure line.
85
+ */
86
+ export interface ForcedPlan {
87
+ brief: string;
88
+ /** Already narrowed to `[1, team.maxSubagents]`. */
89
+ count: number;
90
+ /** What the request implied BEFORE `maxSubagents` narrowed it. */
91
+ requested: number;
92
+ /** Already narrowed to `maxSubagents`; may be empty. */
93
+ parts: string[];
94
+ /** Parts that survived the structural cap, before the policy cap applied. */
95
+ partsAdmitted: number;
96
+ /** Parts the user asked for, before the structural `forcedPartsMax` cap. */
97
+ partsRequested: number;
98
+ readOnly: boolean;
99
+ }
100
+ /**
101
+ * Narrow the parsed intent against the LIVE width.
102
+ *
103
+ * TWO CEILINGS, IN THIS ORDER (R-P1-3): the structural `forcedPartsMax` bounds
104
+ * what one argument may carry (the parser already applied it), and the policy
105
+ * `maxSubagents` bounds what this session may dispatch - which only this layer
106
+ * can apply, because only it can read the live config.
107
+ */
108
+ export declare function planForced(args: SubagentCommandArgs, team: TeamConfig): ForcedPlan;
109
+ /**
110
+ * The one line that says what the NEXT turn will do - a plan, not a result.
111
+ *
112
+ * IT IS PRINTED BEFORE THE TURN IS SUBMITTED and it never claims children
113
+ * started: forcing is instruction-level for the first turn, and a notice that
114
+ * said "4 subagents started" would be a promise the command cannot keep.
115
+ */
116
+ export declare function planNotice(planned: ForcedPlan, team: TeamConfig): string;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Forced team mode: the SESSION STATE type and the PURE half of `/team`
3
+ * (team-command-forced-dispatch §3.3 / §3.5 / §5.2).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope, and this module
6
+ * also produces text a model reads in a `cmd.exe` terminal.
7
+ *
8
+ * WHAT IS AND IS NOT HERE. Every export below is a deterministic function of its
9
+ * arguments: no `CommandContext`, no controller, no IO, no React. That is what
10
+ * lets the whole grammar - control words, the quote escape hatch, the two
11
+ * ceilings, the disclosure line - be tested without a terminal, a config file or
12
+ * a model. The command body (`commands/team-command.ts`) keeps the ctx-dependent
13
+ * half: speaking to the user, arming the session, submitting the turn.
14
+ *
15
+ * THE INTENT IS PARSED IN ONE STEP (§3.3), because the three rules interact:
16
+ *
17
+ * 1. CONTROL WORDS ARE WHOLE-ARGUMENT ONLY. `off the record` is a brief whose
18
+ * first word happens to be `off`; treating it as "turn off, then take notes"
19
+ * would delete a legitimate brief from the grammar.
20
+ * 2. THE QUOTE ESCAPE HATCH IS CHECKED BEFORE THE CONTROL WORDS. `/team "off"`
21
+ * asks for a fan-out on the brief `off`. Without this rule the three control
22
+ * words would be permanently unavailable as briefs, and a user working on a
23
+ * feature called "off" would have no way to say so.
24
+ * 3. FLAG PARSING IS NOT REIMPLEMENTED. `parseSubagentArgs` owns the four error
25
+ * classes and both disclosure ceilings, and its bytes are pinned by
26
+ * `subagent-command.test.ts`; a second grammar here would drift from it on
27
+ * the first `-n=6` someone tried.
28
+ */
29
+ import { parseSubagentArgs } from './forced-dispatch.js';
30
+ /**
31
+ * The default count when the user names none (D-7).
32
+ *
33
+ * FOUR, not `maxSubagents`: `<team_mode>` tells the model two to four is usually
34
+ * enough, so "force one dispatch" must not silently become the most expensive
35
+ * call the session can make. The status line advertises the real ceiling so the
36
+ * user who wants the widest fan-out knows which flag to add.
37
+ */
38
+ export const DEFAULT_FORCED_COUNT = 4;
39
+ /** The three words that command the session rather than describe work. */
40
+ const CONTROL_WORDS = new Set(['on', 'off', 'status']);
41
+ /**
42
+ * The argument with one pair of wrapping double quotes removed, or `null`.
43
+ *
44
+ * DELIBERATELY ONE PAIR AND NOT A LEXER. `/team "off"` and `/team "the off
45
+ * switch"` both work; nested or unbalanced quotes fall through to the ordinary
46
+ * parser, where the text simply keeps its quotes - the honest outcome for input
47
+ * the grammar does not claim.
48
+ */
49
+ function unquoted(raw) {
50
+ const trimmed = raw.trim();
51
+ if (trimmed.length < 2)
52
+ return null;
53
+ if (!trimmed.startsWith('"') || !trimmed.endsWith('"'))
54
+ return null;
55
+ return trimmed.slice(1, -1);
56
+ }
57
+ /**
58
+ * Parse the whole argument of `/team`. NEVER THROWS and never guesses.
59
+ *
60
+ * The empty argument is the STATUS question, not an error: "what is on?" is the
61
+ * one thing a user asks a mode command most often, and it must answer even in a
62
+ * session that cannot dispatch at all (that is exactly where they ask).
63
+ */
64
+ export function readTeamIntent(raw) {
65
+ const trimmed = raw.trim();
66
+ if (trimmed.length === 0)
67
+ return { kind: 'status' };
68
+ // RULE 2, FIRST: the escape hatch has to win, or the control words could never
69
+ // be briefs at all.
70
+ const inner = unquoted(trimmed);
71
+ if (inner !== null) {
72
+ const parsed = parseSubagentArgs(inner);
73
+ if (!parsed.ok)
74
+ return { kind: 'error', error: parsed.error, token: parsed.token };
75
+ return { kind: 'brief', args: parsed.args, quoted: true };
76
+ }
77
+ // RULE 1: whole-argument equality, compared case-insensitively.
78
+ const word = trimmed.toLowerCase();
79
+ if (CONTROL_WORDS.has(word))
80
+ return { kind: word };
81
+ // RULE 3: everything else is a brief, parsed by the one grammar there is.
82
+ const parsed = parseSubagentArgs(trimmed);
83
+ if (!parsed.ok)
84
+ return { kind: 'error', error: parsed.error, token: parsed.token };
85
+ return { kind: 'brief', args: parsed.args, quoted: false };
86
+ }
87
+ /**
88
+ * Narrow the parsed intent against the LIVE width.
89
+ *
90
+ * TWO CEILINGS, IN THIS ORDER (R-P1-3): the structural `forcedPartsMax` bounds
91
+ * what one argument may carry (the parser already applied it), and the policy
92
+ * `maxSubagents` bounds what this session may dispatch - which only this layer
93
+ * can apply, because only it can read the live config.
94
+ */
95
+ export function planForced(args, team) {
96
+ const max = team.maxSubagents;
97
+ if (args.parts.length > 0) {
98
+ // A PARTS LIST STARTS WITH A CHILD PER SURVIVING PART, and `requested` is the
99
+ // number the user TYPED - not the structural 8 the parser already applied.
100
+ // Quoting the post-truncation count made the old notice tell a user who asked
101
+ // for eleven parts that they had asked for eight (R-4: the notice and the
102
+ // directive must both name the user's number).
103
+ const parts = args.parts.slice(0, max);
104
+ return {
105
+ brief: args.brief,
106
+ parts,
107
+ partsAdmitted: args.parts.length,
108
+ partsRequested: args.partsRequested,
109
+ requested: args.partsRequested,
110
+ count: parts.length,
111
+ readOnly: args.readOnly,
112
+ };
113
+ }
114
+ const requested = args.count ?? Math.min(DEFAULT_FORCED_COUNT, max);
115
+ return {
116
+ brief: args.brief,
117
+ parts: [],
118
+ partsAdmitted: 0,
119
+ partsRequested: 0,
120
+ requested,
121
+ count: Math.max(1, Math.min(requested, max)),
122
+ readOnly: args.readOnly,
123
+ };
124
+ }
125
+ /**
126
+ * The one line that says what the NEXT turn will do - a plan, not a result.
127
+ *
128
+ * IT IS PRINTED BEFORE THE TURN IS SUBMITTED and it never claims children
129
+ * started: forcing is instruction-level for the first turn, and a notice that
130
+ * said "4 subagents started" would be a promise the command cannot keep.
131
+ */
132
+ export function planNotice(planned, team) {
133
+ const noun = planned.count === 1 ? 'subagent' : 'subagents';
134
+ const head = `Forcing a fan-out of ${planned.count} ${noun}`;
135
+ const followUp = `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
136
+ + 'Esc stops the turn.';
137
+ // TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may
138
+ // carry and `team.maxSubagents` bounds what may be dispatched; naming the wrong
139
+ // one sends the user to change a number that is not the reason.
140
+ if (planned.parts.length > 0) {
141
+ if (planned.parts.length < planned.partsAdmitted) {
142
+ return `${head} (asked for ${planned.partsRequested} parts; `
143
+ + `team.maxSubagents is ${team.maxSubagents}).`;
144
+ }
145
+ if (planned.partsAdmitted < planned.partsRequested) {
146
+ return `${head} (asked for ${planned.partsRequested} parts, `
147
+ + `the first ${planned.parts.length} are used).`;
148
+ }
149
+ return followUp;
150
+ }
151
+ if (planned.requested > planned.count) {
152
+ return `${head} (asked for ${planned.requested}; `
153
+ + `team.maxSubagents is ${team.maxSubagents}).`;
154
+ }
155
+ return followUp;
156
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `<forced_team_mode>`: the SECOND of the three layers that make `/team <brief>`
3
+ * mean something (team-command-forced-dispatch §3.6 / §6.3).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * WHY A BLOCK AT ALL. The first layer - the directive `buildForcedDirective()`
8
+ * submits - is a single user turn, and it dies with that turn. The session stays
9
+ * in forced mode until `/team off`, so every LATER turn needs the same
10
+ * constraint, and the only surface that survives a turn is the system prompt.
11
+ *
12
+ * WHY NOT SIMPLY WIDEN `<team_mode>`. That block says "you CAN dispatch"; this
13
+ * one says "you MUST". Merging them would make the permission and the obligation
14
+ * one string, and a session that only needs the first (an ordinary team session)
15
+ * would start paying for the second's words and, worse, inheriting its tone.
16
+ *
17
+ * THE LAST PARAGRAPH IS A SAFETY VALVE AND MUST STAY LAST: "If the task tool is
18
+ * unavailable this turn, say so in one line" is what remains when all three
19
+ * layers fail, and it has to be the last sentence the model read. The plan-mode
20
+ * variant is therefore spliced BEFORE `Answer without dispatching`, never at the
21
+ * end (§6.3, P1-8).
22
+ */
23
+ import { FORCED_TEAM_BLOCK_VERSION } from './limits.js';
24
+ /**
25
+ * What one `<forced_team_mode>` rendering needs, and NOTHING ELSE.
26
+ *
27
+ * NO CEILING FIELD (§3.6 lists `maxSubagents`; §6.3 never spends it). The block
28
+ * names one number twice - the armed count - and `forced-prompt.test.ts` asserts
29
+ * that no ceiling appears in it: a sentence offering "up to 8" beside "Count: 4"
30
+ * is read as permission for eight, which is the cost D-7 caps at four. Carrying
31
+ * an unused ceiling here would invite exactly that sentence back.
32
+ */
33
+ export interface ForcedTeamBlockParams {
34
+ /** Already narrowed to `[1, team.maxSubagents]` by `armForcedTeam()`. */
35
+ count: number;
36
+ /** Plan mode variant sentence: dispatch read-only research, then `submit_plan`. */
37
+ planMode: boolean;
38
+ }
39
+ /**
40
+ * Render the block for one prompt build.
41
+ *
42
+ * NO TRUNCATION PATH (unlike the team report): the text is a constant with two
43
+ * numbers in it, so its length is knowable at build time, and
44
+ * `forced-prompt.test.ts` asserts both the ASCII-only rule and the ceiling. A
45
+ * silent elision would delete the safety valve this block ends with.
46
+ */
47
+ export declare function buildForcedTeamBlock(params: ForcedTeamBlockParams): string;
48
+ export { FORCED_TEAM_BLOCK_VERSION };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `<forced_team_mode>`: the SECOND of the three layers that make `/team <brief>`
3
+ * mean something (team-command-forced-dispatch §3.6 / §6.3).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * WHY A BLOCK AT ALL. The first layer - the directive `buildForcedDirective()`
8
+ * submits - is a single user turn, and it dies with that turn. The session stays
9
+ * in forced mode until `/team off`, so every LATER turn needs the same
10
+ * constraint, and the only surface that survives a turn is the system prompt.
11
+ *
12
+ * WHY NOT SIMPLY WIDEN `<team_mode>`. That block says "you CAN dispatch"; this
13
+ * one says "you MUST". Merging them would make the permission and the obligation
14
+ * one string, and a session that only needs the first (an ordinary team session)
15
+ * would start paying for the second's words and, worse, inheriting its tone.
16
+ *
17
+ * THE LAST PARAGRAPH IS A SAFETY VALVE AND MUST STAY LAST: "If the task tool is
18
+ * unavailable this turn, say so in one line" is what remains when all three
19
+ * layers fail, and it has to be the last sentence the model read. The plan-mode
20
+ * variant is therefore spliced BEFORE `Answer without dispatching`, never at the
21
+ * end (§6.3, P1-8).
22
+ */
23
+ import { FORCED_TEAM_BLOCK_MAX_CHARS, FORCED_TEAM_BLOCK_VERSION, } from './limits.js';
24
+ /** The plan-mode sentence, spliced before the "answer without dispatching" line. */
25
+ const PLAN_SENTENCE = 'Plan mode is on: dispatch the read-only research '
26
+ + '(every spec readOnly: true), then call submit_plan.';
27
+ /**
28
+ * The block's body, in the exact order and wording §6.3 fixes.
29
+ *
30
+ * `{count}` appears TWICE (the `Count:` line and the "more than N" clause) and
31
+ * both are the same number on purpose: a block that allowed "up to maxSubagents"
32
+ * in one sentence and "N" in the other would be read as authorising the wider
33
+ * number, which is exactly the cost D-7 caps at four.
34
+ */
35
+ function body(count, planMode) {
36
+ const wide = [
37
+ `<forced_team_mode ${FORCED_TEAM_BLOCK_VERSION}>`,
38
+ 'The user put this session in forced team mode. Work is done by subagents, not by you.',
39
+ 'For every turn that produces or changes something, call the `task` tool ONCE and let',
40
+ 'the children do it. Never do that work yourself, and never end such a turn having done',
41
+ 'it alone: a turn that did the work itself is a failed turn here.',
42
+ `Count: ${count} subagents unless the work genuinely splits into fewer. `
43
+ + 'Two to four is usual;',
44
+ `more than ${count} only when the parts are truly independent. Each spec needs a `
45
+ + 'self-contained',
46
+ 'prompt (the child has no memory of this conversation), a short slug label, a description',
47
+ 'of at most 60 chars, and a disjoint set of files.',
48
+ 'You may plan first: research with read_file / glob / grep, or write the plan with',
49
+ 'todo_write. Planning is yours; executing the plan is theirs. Relay the combined report',
50
+ 'in your own words and say which child did what.',
51
+ ];
52
+ // SPLICED HERE, BEFORE the closing sentence, for the reason the header records.
53
+ const plan = planMode ? [PLAN_SENTENCE] : [];
54
+ return [
55
+ ...wide,
56
+ ...plan,
57
+ 'Answer without dispatching only when the turn is a question this conversation already',
58
+ 'answers - no new files, no new commands, no new findings.',
59
+ 'If the task tool is unavailable this turn, say so in one line instead of doing the work',
60
+ 'alone.',
61
+ '</forced_team_mode>',
62
+ ];
63
+ }
64
+ /**
65
+ * Render the block for one prompt build.
66
+ *
67
+ * NO TRUNCATION PATH (unlike the team report): the text is a constant with two
68
+ * numbers in it, so its length is knowable at build time, and
69
+ * `forced-prompt.test.ts` asserts both the ASCII-only rule and the ceiling. A
70
+ * silent elision would delete the safety valve this block ends with.
71
+ */
72
+ export function buildForcedTeamBlock(params) {
73
+ const count = Math.max(1, Math.floor(params.count));
74
+ const text = body(count, params.planMode).join('\n');
75
+ // A CEILING, NOT A CLIP. Every sentence in this block is load-bearing, so a
76
+ // length overrun is a wording bug to fix in the source rather than a paragraph
77
+ // to lose at run time - and this branch is unreachable today (1143 chars).
78
+ if (text.length > FORCED_TEAM_BLOCK_MAX_CHARS) {
79
+ throw new Error(`forced team block is ${text.length} chars, ceiling is `
80
+ + `${FORCED_TEAM_BLOCK_MAX_CHARS} (team/forced-prompt.ts)`);
81
+ }
82
+ return text;
83
+ }
84
+ export { FORCED_TEAM_BLOCK_VERSION };