@aragon-agent/cli 0.6.40 → 0.6.43

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 (51) hide show
  1. package/CHANGELOG.md +2632 -2589
  2. package/README.md +3720 -3706
  3. package/dist/agent/controller.d.ts +36 -5
  4. package/dist/agent/controller.js +112 -33
  5. package/dist/agent/headless.js +6 -2
  6. package/dist/agent/system-prompt.d.ts +19 -0
  7. package/dist/agent/system-prompt.js +5 -0
  8. package/dist/commands/builtins.js +16 -182
  9. package/dist/commands/subagent-command.d.ts +21 -16
  10. package/dist/commands/subagent-command.js +22 -225
  11. package/dist/commands/team-command.d.ts +49 -0
  12. package/dist/commands/team-command.js +259 -0
  13. package/dist/config/model-kits.d.ts +2 -1
  14. package/dist/config/store.js +3 -2
  15. package/dist/exec/runner.js +2 -1
  16. package/dist/team/bus.d.ts +4 -0
  17. package/dist/team/bus.js +12 -2
  18. package/dist/team/comm-tools.js +3 -0
  19. package/dist/team/dispatch-gate.d.ts +86 -0
  20. package/dist/team/dispatch-gate.js +75 -0
  21. package/dist/team/forced-mode.d.ts +116 -0
  22. package/dist/team/forced-mode.js +156 -0
  23. package/dist/team/forced-prompt.d.ts +48 -0
  24. package/dist/team/forced-prompt.js +84 -0
  25. package/dist/team/limits.d.ts +41 -1
  26. package/dist/team/limits.js +49 -1
  27. package/dist/team/model.d.ts +22 -6
  28. package/dist/team/model.js +19 -5
  29. package/dist/team/overseer.d.ts +4 -0
  30. package/dist/team/overseer.js +38 -6
  31. package/dist/team/prompt.js +3 -3
  32. package/dist/team/report.d.ts +21 -0
  33. package/dist/team/report.js +40 -9
  34. package/dist/team/runtime.d.ts +3 -0
  35. package/dist/team/runtime.js +61 -5
  36. package/dist/team/subagent.js +25 -7
  37. package/dist/ui/App.js +140 -20
  38. package/dist/ui/StatusBar.d.ts +14 -0
  39. package/dist/ui/StatusBar.js +1 -0
  40. package/dist/ui/layout/status-layout.d.ts +17 -0
  41. package/dist/ui/layout/status-layout.js +9 -0
  42. package/dist/ui/overlays/HelpOverlay.js +8 -4
  43. package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
  44. package/dist/ui/overlays/SettingsScreen.js +17 -7
  45. package/dist/ui/overlays/TeamFanoutPage.d.ts +17 -1
  46. package/dist/ui/overlays/TeamFanoutPage.js +23 -11
  47. package/dist/ui/overlays/kit-settings-state.js +3 -2
  48. package/dist/ui/overlays/model-profile-state.d.ts +1 -1
  49. package/dist/ui/overlays/team-fanout-state.d.ts +46 -13
  50. package/dist/ui/overlays/team-fanout-state.js +78 -20
  51. package/package.json +81 -81
@@ -15,6 +15,7 @@
15
15
  * same `--session-id` must not race the writer of the file it is about to read.
16
16
  */
17
17
  import { formatStreamError } from '../agent/reducer.js';
18
+ import { runDurationMs } from '../team/report.js';
18
19
  import { setSignalTerminator } from '../logging/install.js';
19
20
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
20
21
  import { EXEC_SCHEMA_VERSION, buildRequestEndEvent } from './events.js';
@@ -1622,7 +1623,7 @@ export class ExecRunner {
1622
1623
  label: run.label,
1623
1624
  phase: run.phase,
1624
1625
  description: run.description,
1625
- durationMs: Math.max(0, (run.endedAt ?? 0) - (run.startedAt ?? 0)),
1626
+ durationMs: runDurationMs(run),
1626
1627
  toolCalls: run.toolCalls,
1627
1628
  });
1628
1629
  return;
@@ -36,6 +36,10 @@ export type SendVerdict = {
36
36
  ok: false;
37
37
  reason: 'unknown_recipient';
38
38
  recipients: string[];
39
+ } | {
40
+ ok: false;
41
+ reason: 'recipient_finished';
42
+ who: string[];
39
43
  } | {
40
44
  ok: false;
41
45
  reason: 'agent_quota';
package/dist/team/bus.js CHANGED
@@ -103,10 +103,20 @@ export class TeamBus {
103
103
  send(from, to, subjectRaw, bodyRaw) {
104
104
  const target = to.trim().toLowerCase();
105
105
  const cost = target === ALL_KEY ? TEAM_LIMITS.broadcastCost : 1;
106
- const recipients = this.resolveRecipients(from, target);
107
- if (recipients.length === 0) {
106
+ const addressed = this.resolveRecipients(from, target);
107
+ if (addressed.length === 0) {
108
108
  return { ok: false, reason: 'unknown_recipient', recipients: this.participants() };
109
109
  }
110
+ // A finished peer has no loop left to drain its mailbox, so "Delivered" would be
111
+ // a false receipt and the quota spent on it a pure loss. Same fact `checkWaitable`
112
+ // already honours. The lead is always addressable: its mail is collected for the
113
+ // report, not read by a loop. Refused like every other refusal: nothing is spent
114
+ // and `lastSentAt` does not move. A peer that is about to finish but has not yet is
115
+ // still delivered to; that window cannot be closed without a lock.
116
+ const recipients = addressed.filter((r) => r === LEAD_KEY || this.canSend(r));
117
+ if (recipients.length === 0) {
118
+ return { ok: false, reason: 'recipient_finished', who: addressed };
119
+ }
110
120
  const used = this.sentCount(from);
111
121
  if (used + cost > TEAM_LIMITS.messagesPerAgent) {
112
122
  return { ok: false, reason: 'agent_quota', used, max: TEAM_LIMITS.messagesPerAgent };
@@ -64,6 +64,9 @@ export function makeTeamSend(bus, selfKey) {
64
64
  case 'unknown_recipient':
65
65
  return textResult(`Not sent: no subagent labelled "${to.trim()}". Teammates: ` +
66
66
  `${verdict.recipients.filter((r) => r !== selfKey).join(', ')}.`);
67
+ case 'recipient_finished':
68
+ return textResult(`Not sent: ${verdict.who.join(', ')} already finished and will not read it. ` +
69
+ 'Put what matters in your own final summary instead.');
67
70
  case 'agent_quota':
68
71
  return textResult(`Not sent: message quota spent (${verdict.used}/${verdict.max}). Finish your work ` +
69
72
  'and put it in your final summary.');
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The dispatch gate: the THIRD and only MECHANICAL layer of forced team mode
3
+ * (team-command-forced-dispatch §3.7 / §6.4).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * WHY A MECHANICAL LAYER EXISTS AT ALL. The directive and the prompt block are
8
+ * both requests, and a request can be declined. The requirement behind `/team`
9
+ * is a COMMITMENT - "after the lead plans, subagents must do the work" - so the
10
+ * one part of it that does not depend on the model's cooperation is a decision
11
+ * taken by the host after the turn has already ended. Everything here is a pure
12
+ * function of four facts the caller counted, which is what keeps `ui/App.tsx`
13
+ * to wiring rather than policy.
14
+ *
15
+ * THREE BOUNDS, AND NONE OF THEM IS DECORATION (D-8):
16
+ * - `toolsUsed > 0` - a pure question ("what did those two children find?")
17
+ * must be answerable from context; nudging it would buy a
18
+ * second dispatch of a fact already in the transcript.
19
+ * - `nudgesUsed < 1` - a model that will not dispatch must not turn into an
20
+ * unbounded paid loop. `FORCED_NUDGE_LIMIT` is the whole
21
+ * defence, and I-10 pins WHERE it is reset (the user's own
22
+ * submit, never `agent_start`) because a per-turn reset
23
+ * would make this bound disappear while every test stayed
24
+ * green.
25
+ * - `!aborted && !errored` - Esc and a provider failure are not disobedience.
26
+ * Urging a re-dispatch after an error would spend money to
27
+ * reproduce the error.
28
+ *
29
+ * `dispatchAttempted` IS NOT `dispatched` (P1-7). The `task` tool refuses for
30
+ * three reasons that never emit `dispatch_start` - team off, dispatch busy, no
31
+ * API key - and each of those refusals is the upstream telling the model "no".
32
+ * Counting only real dispatches would have the gate say "you dispatched nothing,
33
+ * dispatch now", the model comply, and the same refusal arrive twice: two turns
34
+ * of noise around a fact both layers already knew.
35
+ */
36
+ /** The four facts the caller counts, and the run's own verdict on itself. */
37
+ export interface ForcedDispatchProbe {
38
+ /** `getForcedTeam() !== null`. */
39
+ armed: boolean;
40
+ /**
41
+ * The armed fan-out count, echoed into the nudge's first sentence.
42
+ *
43
+ * NOT IN §3.7's FIVE-FIELD PROBE, and the omission does not survive contact
44
+ * with §5.2: the decision carries the MESSAGE (`{ kind: 'nudge'; message }`)
45
+ * and `App` submits `gate.message` verbatim, so the number the nudge names has
46
+ * to reach this function somehow. Reading it here - from the same probe the
47
+ * gate already receives - keeps the decision a pure function of its argument
48
+ * instead of forcing the caller to rebuild the message and losing the guarantee
49
+ * that `buildDispatchNudge()` is the only author of those bytes. Ignored when
50
+ * `armed` is false (see `team-command-forced-dispatch` spec,
51
+ * `## 实施过程发现的方案缺陷`).
52
+ */
53
+ count: number;
54
+ /** This turn emitted `dispatch_start` OR called `task` at least once (even refused). */
55
+ dispatchAttempted: boolean;
56
+ /** Tool calls the MODEL made this turn. Zero means a pure answer. */
57
+ toolsUsed: number;
58
+ /** Fallback nudges already spent. Reset only by a user-initiated submit (I-10). */
59
+ nudgesUsed: number;
60
+ runEnd: {
61
+ aborted: boolean;
62
+ errored: boolean;
63
+ };
64
+ }
65
+ export type ForcedDispatchDecision = {
66
+ kind: 'none';
67
+ } | {
68
+ kind: 'nudge';
69
+ message: string;
70
+ };
71
+ /** The line written to the USER before the nudge is submitted (§6.4). */
72
+ export declare const FORCED_NUDGE_NOTICE = "Forced team mode: no dispatch last turn - asking the agent to dispatch now.";
73
+ /**
74
+ * The nudge itself, verbatim from §6.4.
75
+ *
76
+ * It names a NUMBER and then immediately permits fewer, because the one thing
77
+ * worse than a missing dispatch is four children inventing work to justify
78
+ * themselves. The closing sentence repeats the directive's prohibition: this
79
+ * message is the model's whole context for the turn it is about to run.
80
+ */
81
+ export declare function buildDispatchNudge(count: number): string;
82
+ /**
83
+ * Should the host ask for one more turn? Every clause must hold, short-circuiting
84
+ * in the order the header records so a cheap fact answers before a costly one.
85
+ */
86
+ export declare function decideForcedDispatch(probe: ForcedDispatchProbe): ForcedDispatchDecision;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The dispatch gate: the THIRD and only MECHANICAL layer of forced team mode
3
+ * (team-command-forced-dispatch §3.7 / §6.4).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * WHY A MECHANICAL LAYER EXISTS AT ALL. The directive and the prompt block are
8
+ * both requests, and a request can be declined. The requirement behind `/team`
9
+ * is a COMMITMENT - "after the lead plans, subagents must do the work" - so the
10
+ * one part of it that does not depend on the model's cooperation is a decision
11
+ * taken by the host after the turn has already ended. Everything here is a pure
12
+ * function of four facts the caller counted, which is what keeps `ui/App.tsx`
13
+ * to wiring rather than policy.
14
+ *
15
+ * THREE BOUNDS, AND NONE OF THEM IS DECORATION (D-8):
16
+ * - `toolsUsed > 0` - a pure question ("what did those two children find?")
17
+ * must be answerable from context; nudging it would buy a
18
+ * second dispatch of a fact already in the transcript.
19
+ * - `nudgesUsed < 1` - a model that will not dispatch must not turn into an
20
+ * unbounded paid loop. `FORCED_NUDGE_LIMIT` is the whole
21
+ * defence, and I-10 pins WHERE it is reset (the user's own
22
+ * submit, never `agent_start`) because a per-turn reset
23
+ * would make this bound disappear while every test stayed
24
+ * green.
25
+ * - `!aborted && !errored` - Esc and a provider failure are not disobedience.
26
+ * Urging a re-dispatch after an error would spend money to
27
+ * reproduce the error.
28
+ *
29
+ * `dispatchAttempted` IS NOT `dispatched` (P1-7). The `task` tool refuses for
30
+ * three reasons that never emit `dispatch_start` - team off, dispatch busy, no
31
+ * API key - and each of those refusals is the upstream telling the model "no".
32
+ * Counting only real dispatches would have the gate say "you dispatched nothing,
33
+ * dispatch now", the model comply, and the same refusal arrive twice: two turns
34
+ * of noise around a fact both layers already knew.
35
+ */
36
+ import { FORCED_NUDGE_LIMIT } from './limits.js';
37
+ /** The line written to the USER before the nudge is submitted (§6.4). */
38
+ export const FORCED_NUDGE_NOTICE = 'Forced team mode: no dispatch last turn - asking the agent to dispatch now.';
39
+ /**
40
+ * The nudge itself, verbatim from §6.4.
41
+ *
42
+ * It names a NUMBER and then immediately permits fewer, because the one thing
43
+ * worse than a missing dispatch is four children inventing work to justify
44
+ * themselves. The closing sentence repeats the directive's prohibition: this
45
+ * message is the model's whole context for the turn it is about to run.
46
+ */
47
+ export function buildDispatchNudge(count) {
48
+ return [
49
+ 'Forced team mode is on and the last turn dispatched nothing. Call the `task` tool now',
50
+ `with ${count} subagents - or fewer if the work genuinely does not split - then report `
51
+ + 'their',
52
+ 'combined result. Do not do the work yourself.',
53
+ ].join('\n');
54
+ }
55
+ /**
56
+ * Should the host ask for one more turn? Every clause must hold, short-circuiting
57
+ * in the order the header records so a cheap fact answers before a costly one.
58
+ */
59
+ export function decideForcedDispatch(probe) {
60
+ if (!probe.armed)
61
+ return { kind: 'none' };
62
+ // A REFUSED `task` STILL COUNTS (P1-7): "attempted" is the honest predicate.
63
+ if (probe.dispatchAttempted)
64
+ return { kind: 'none' };
65
+ if (!(probe.toolsUsed > 0))
66
+ return { kind: 'none' };
67
+ if (probe.runEnd.aborted || probe.runEnd.errored)
68
+ return { kind: 'none' };
69
+ if (probe.nudgesUsed >= FORCED_NUDGE_LIMIT)
70
+ return { kind: 'none' };
71
+ // THE MESSAGE IS BUILT HERE, from the same probe, so `buildDispatchNudge()`
72
+ // stays the only author of these bytes and the caller can submit
73
+ // `decision.message` without reconstructing it.
74
+ return { kind: 'nudge', message: buildDispatchNudge(probe.count) };
75
+ }
@@ -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 };