@aragon-agent/cli 0.6.39 → 0.6.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +2589 -2523
  2. package/README.md +3706 -3614
  3. package/dist/agent/controller.d.ts +103 -9
  4. package/dist/agent/controller.js +196 -19
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +29 -1
  9. package/dist/agent/reducer.js +34 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +14 -2
  13. package/dist/agent/system-prompt.js +7 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +12 -3
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +25 -0
  18. package/dist/commands/subagent-command.js +242 -0
  19. package/dist/compaction/command.js +16 -3
  20. package/dist/compaction/compactor.d.ts +25 -0
  21. package/dist/compaction/compactor.js +111 -10
  22. package/dist/compaction/digest.d.ts +24 -1
  23. package/dist/compaction/digest.js +139 -17
  24. package/dist/compaction/limits.d.ts +21 -0
  25. package/dist/compaction/limits.js +21 -0
  26. package/dist/compaction/reason-text.d.ts +36 -0
  27. package/dist/compaction/reason-text.js +78 -0
  28. package/dist/compaction/types.d.ts +82 -14
  29. package/dist/compaction/wiring.d.ts +13 -8
  30. package/dist/compaction/wiring.js +1 -0
  31. package/dist/config/load.js +50 -3
  32. package/dist/config/schema.d.ts +82 -6
  33. package/dist/config/schema.js +107 -9
  34. package/dist/config/store.js +7 -2
  35. package/dist/proc/log-mirror.d.ts +21 -4
  36. package/dist/proc/log-mirror.js +29 -7
  37. package/dist/skills/service.d.ts +41 -0
  38. package/dist/skills/service.js +62 -0
  39. package/dist/skills/triage.d.ts +194 -0
  40. package/dist/skills/triage.js +393 -0
  41. package/dist/team/forced-dispatch.d.ts +95 -0
  42. package/dist/team/forced-dispatch.js +208 -0
  43. package/dist/team/limits.d.ts +21 -1
  44. package/dist/team/limits.js +21 -1
  45. package/dist/team/normalize.d.ts +15 -0
  46. package/dist/team/normalize.js +21 -4
  47. package/dist/team/prompt.d.ts +6 -3
  48. package/dist/team/prompt.js +9 -5
  49. package/dist/team/task-tool.js +11 -7
  50. package/dist/tools/human-input.d.ts +35 -2
  51. package/dist/tools/human-input.js +38 -0
  52. package/dist/tools/plan-tools.d.ts +20 -12
  53. package/dist/tools/plan-tools.js +24 -17
  54. package/dist/ui/App.js +14 -1
  55. package/dist/ui/StatusBar.d.ts +1 -0
  56. package/dist/ui/StatusBar.js +1 -0
  57. package/dist/ui/Transcript.js +1 -1
  58. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  59. package/dist/ui/entries/CompactionCard.js +65 -4
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +18 -1
  62. package/dist/ui/overlays/SettingsScreen.js +29 -5
  63. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  64. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  65. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  66. package/dist/ui/overlays/model-profile-state.js +12 -0
  67. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  68. package/dist/ui/overlays/team-fanout-state.js +97 -0
  69. package/package.json +2 -2
@@ -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
+ }
@@ -29,6 +29,11 @@ export declare const TEAM_LIMITS: {
29
29
  * The requirement's ceiling (R-g). Enforced in `clampTeamConfig` AND in
30
30
  * `normalizeSubagentSpecs`, so neither a hand-edited file nor a model asking
31
31
  * for thirty can exceed it.
32
+ *
33
+ * 10 IS THE CEILING, 8 IS THE DEFAULT (`DEFAULT_TEAM_CONFIG`): different
34
+ * numbers on purpose (subagent-fanout D-1 / D-2). The default is a policy
35
+ * choice the user may move in either direction from the settings screen; this
36
+ * bound is what no user may move.
32
37
  */
33
38
  readonly hardMaxSubagents: 10;
34
39
  /** `SubagentSpec.description`, clamped (never rejected). */
@@ -36,6 +41,21 @@ export declare const TEAM_LIMITS: {
36
41
  /** `SubagentSpec.prompt`, clamped. The child cannot see the conversation, so
37
42
  * this has to be generous enough to carry a whole brief. */
38
43
  readonly promptChars: 8000;
44
+ /**
45
+ * The brief `/subagent <brief>` copies into its forced-directive turn
46
+ * (subagent-fanout 3.5.4 / R-10). CHARACTERS, not bytes, because this bound
47
+ * feeds a user-visible truncation marker rather than a wire budget: past it
48
+ * the directive says `[brief truncated]` so a clipped instruction is never
49
+ * mistaken for a complete one.
50
+ */
51
+ readonly forcedBriefChars: 4000;
52
+ /**
53
+ * Parts `/subagent --parts a,b,c` may name BEFORE `maxSubagents` narrows them
54
+ * (subagent-fanout 3.5.2 rule 3). The second, smaller ceiling is the policy
55
+ * number and belongs to the command body; this one only bounds what one
56
+ * argument can carry.
57
+ */
58
+ readonly forcedPartsMax: 8;
39
59
  /** Slugified `SubagentSpec.label`. Long enough to be meaningful in the panel,
40
60
  * short enough that five of them fit one row of an 80-column terminal. */
41
61
  readonly labelChars: 12;
@@ -245,4 +265,4 @@ export declare const TEAM_SUBAGENT_TOOL_NAMES: ReadonlySet<string>;
245
265
  * Bumped whenever the wording of either prompt block changes, so a behaviour
246
266
  * report can be tied to a block revision with one grep.
247
267
  */
248
- export declare const TEAM_BLOCK_VERSION = "v1-2026-07";
268
+ export declare const TEAM_BLOCK_VERSION = "v2-2026-10";
@@ -36,6 +36,11 @@ export const TEAM_LIMITS = {
36
36
  * The requirement's ceiling (R-g). Enforced in `clampTeamConfig` AND in
37
37
  * `normalizeSubagentSpecs`, so neither a hand-edited file nor a model asking
38
38
  * for thirty can exceed it.
39
+ *
40
+ * 10 IS THE CEILING, 8 IS THE DEFAULT (`DEFAULT_TEAM_CONFIG`): different
41
+ * numbers on purpose (subagent-fanout D-1 / D-2). The default is a policy
42
+ * choice the user may move in either direction from the settings screen; this
43
+ * bound is what no user may move.
39
44
  */
40
45
  hardMaxSubagents: HARD_MAX_SUBAGENTS,
41
46
  // --- Spec normalization (§3.3.1) ---
@@ -44,6 +49,21 @@ export const TEAM_LIMITS = {
44
49
  /** `SubagentSpec.prompt`, clamped. The child cannot see the conversation, so
45
50
  * this has to be generous enough to carry a whole brief. */
46
51
  promptChars: 8000,
52
+ /**
53
+ * The brief `/subagent <brief>` copies into its forced-directive turn
54
+ * (subagent-fanout 3.5.4 / R-10). CHARACTERS, not bytes, because this bound
55
+ * feeds a user-visible truncation marker rather than a wire budget: past it
56
+ * the directive says `[brief truncated]` so a clipped instruction is never
57
+ * mistaken for a complete one.
58
+ */
59
+ forcedBriefChars: 4000,
60
+ /**
61
+ * Parts `/subagent --parts a,b,c` may name BEFORE `maxSubagents` narrows them
62
+ * (subagent-fanout 3.5.2 rule 3). The second, smaller ceiling is the policy
63
+ * number and belongs to the command body; this one only bounds what one
64
+ * argument can carry.
65
+ */
66
+ forcedPartsMax: 8,
47
67
  /** Slugified `SubagentSpec.label`. Long enough to be meaningful in the panel,
48
68
  * short enough that five of them fit one row of an 80-column terminal. */
49
69
  labelChars: 12,
@@ -259,4 +279,4 @@ export const TEAM_SUBAGENT_TOOL_NAMES = new Set(['team_send', 'team_wait']);
259
279
  * Bumped whenever the wording of either prompt block changes, so a behaviour
260
280
  * report can be tied to a block revision with one grep.
261
281
  */
262
- export const TEAM_BLOCK_VERSION = 'v1-2026-07';
282
+ export const TEAM_BLOCK_VERSION = 'v2-2026-10';
@@ -30,6 +30,21 @@ export interface NormalizedSpecs {
30
30
  /** How many entries the model asked for, before dropping and capping. */
31
31
  requested: number;
32
32
  }
33
+ /**
34
+ * The slug rule for a subagent label, EXPORTED because a second caller needs the
35
+ * same bytes: `/subagent --parts api,UI,tests` cleans its parts through this
36
+ * function rather than through a copy of the regex (subagent-fanout R-9 /
37
+ * R-P1-6). Two copies of `[^a-z0-9-]` would drift the first time either side
38
+ * gains a rule, and the user would see `UI` accepted by one and rewritten by the
39
+ * other.
40
+ *
41
+ * MAY RETURN THE EMPTY STRING. That is the difference from `slugLabel` below:
42
+ * an empty result is meaningful here ("this part cleans to nothing, drop it"),
43
+ * whereas a spec's label needs the positional fallback so a child is never
44
+ * nameless in the panel. The fallback is therefore the CALLER's decision, and
45
+ * this function stays a pure transformation.
46
+ */
47
+ export declare function toSlug(raw: unknown): string;
33
48
  /**
34
49
  * Repair and bound a raw `subagents` array.
35
50
  *
@@ -33,16 +33,33 @@ function clamp(text, maxChars) {
33
33
  return trimmed.length > maxChars ? trimmed.slice(0, maxChars) : trimmed;
34
34
  }
35
35
  /**
36
- * Slugify a label to `[a-z0-9-]{1,12}`. An empty result falls back to the
37
- * positional default so a child is never nameless in the panel.
36
+ * The slug rule for a subagent label, EXPORTED because a second caller needs the
37
+ * same bytes: `/subagent --parts api,UI,tests` cleans its parts through this
38
+ * function rather than through a copy of the regex (subagent-fanout R-9 /
39
+ * R-P1-6). Two copies of `[^a-z0-9-]` would drift the first time either side
40
+ * gains a rule, and the user would see `UI` accepted by one and rewritten by the
41
+ * other.
42
+ *
43
+ * MAY RETURN THE EMPTY STRING. That is the difference from `slugLabel` below:
44
+ * an empty result is meaningful here ("this part cleans to nothing, drop it"),
45
+ * whereas a spec's label needs the positional fallback so a child is never
46
+ * nameless in the panel. The fallback is therefore the CALLER's decision, and
47
+ * this function stays a pure transformation.
38
48
  */
39
- function slugLabel(raw, index) {
40
- const slug = asString(raw)
49
+ export function toSlug(raw) {
50
+ return asString(raw)
41
51
  .trim()
42
52
  .toLowerCase()
43
53
  .replace(/[^a-z0-9-]+/g, '-')
44
54
  .replace(/^-+|-+$/g, '')
45
55
  .slice(0, TEAM_LIMITS.labelChars);
56
+ }
57
+ /**
58
+ * Slugify a label to `[a-z0-9-]{1,12}`. An empty result falls back to the
59
+ * positional default so a child is never nameless in the panel.
60
+ */
61
+ function slugLabel(raw, index) {
62
+ const slug = toSlug(raw);
46
63
  return slug.length > 0 ? slug : `a${index + 1}`;
47
64
  }
48
65
  /**
@@ -24,10 +24,13 @@ export interface TeamBlockParams {
24
24
  * for on EVERY turn of every team-enabled session, so anything that does not
25
25
  * change a delegation decision does not belong in it.
26
26
  *
27
- * "Two to four is the usual size" is where the requirement's "shu liang zi shi
27
+ * "Two to four is usually enough" is where the requirement's "shu liang zi shi
28
28
  * ying" (adaptive count) actually lives. The ceiling is mechanical and enforced
29
- * elsewhere; the CHOICE is the model's, and this paragraph is the only thing
30
- * that informs it.
29
+ * elsewhere (`team.maxSubagents`, quoted in the first paragraph); the CHOICE is
30
+ * the model's, and this paragraph is the only thing that informs it. It names a
31
+ * USUAL size rather than the maximum on purpose: the default fan-out moved to 8
32
+ * (subagent-fanout D-1), and a block that told the model "eight is normal"
33
+ * would turn every two-part job into an eight-agent bill.
31
34
  */
32
35
  export declare function buildTeamBlock(params: TeamBlockParams): string;
33
36
  export interface SubagentBlockParams {
@@ -20,10 +20,13 @@ export { TEAM_BLOCK_VERSION };
20
20
  * for on EVERY turn of every team-enabled session, so anything that does not
21
21
  * change a delegation decision does not belong in it.
22
22
  *
23
- * "Two to four is the usual size" is where the requirement's "shu liang zi shi
23
+ * "Two to four is usually enough" is where the requirement's "shu liang zi shi
24
24
  * ying" (adaptive count) actually lives. The ceiling is mechanical and enforced
25
- * elsewhere; the CHOICE is the model's, and this paragraph is the only thing
26
- * that informs it.
25
+ * elsewhere (`team.maxSubagents`, quoted in the first paragraph); the CHOICE is
26
+ * the model's, and this paragraph is the only thing that informs it. It names a
27
+ * USUAL size rather than the maximum on purpose: the default fan-out moved to 8
28
+ * (subagent-fanout D-1), and a block that told the model "eight is normal"
29
+ * would turn every two-part job into an eight-agent bill.
27
30
  */
28
31
  export function buildTeamBlock(params) {
29
32
  const max = Math.max(1, Math.floor(params.maxSubagents));
@@ -39,8 +42,9 @@ export function buildTeamBlock(params) {
39
42
  '',
40
43
  'Do not delegate when: one part needs another part\'s result (do those in order',
41
44
  'yourself); two parts would edit the same file; the whole job is a few edits.',
42
- 'One well-scoped subagent is better than four vague ones. Two to four is the',
43
- 'usual size; more than that is rarely faster.',
45
+ 'One well-scoped subagent is better than four vague ones. Two to four is',
46
+ 'usually enough; go wider only when the parts are genuinely independent - a',
47
+ 'narrow fan-out is cheaper and easier to read.',
44
48
  '',
45
49
  'Each subagent starts with no memory of this conversation, so its prompt must',
46
50
  'carry everything it needs: the goal, the files or areas it owns, and what to',
@@ -49,11 +49,11 @@ const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get
49
49
  'result (do those yourself, in order), when two parts would edit the same file, or for ' +
50
50
  'a change small enough to just make. Subagents cannot dispatch further subagents and ' +
51
51
  'cannot ask the user anything. Give each a description of at most ' +
52
- `${TEAM_LIMITS.descriptionChars} characters and a complete prompt. Extra entries beyond the ` +
53
- 'configured maximum are dropped, so keep within it. A supervisor watches the ' +
54
- 'fan-out: a child that stalls or runs very long may be nudged with guidance ' +
55
- 'or replaced by a fresh attempt, and the report\'s Supervisor section ' +
56
- 'records every intervention.';
52
+ `${TEAM_LIMITS.descriptionChars} characters and a complete prompt. The current fan-out ` +
53
+ 'maximum and concurrency are stated in <team_mode>; extra entries beyond it are ' +
54
+ 'dropped, so keep within it. A supervisor watches the fan-out: a child that ' +
55
+ 'stalls or runs very long may be nudged with guidance or replaced by a fresh ' +
56
+ 'attempt, and the report\'s Supervisor section records every intervention.';
57
57
  export function createTaskTool(deps) {
58
58
  return {
59
59
  name: 'task',
@@ -64,8 +64,12 @@ export function createTaskTool(deps) {
64
64
  properties: {
65
65
  subagents: {
66
66
  type: 'array',
67
- description: `2-5 independent subagents to run in parallel (hard maximum ${TEAM_LIMITS.hardMaxSubagents}; ` +
68
- 'extras are dropped).',
67
+ // NO NUMBER HERE (subagent-fanout D-14): the width is a user setting
68
+ // now (`team.maxSubagents`, 8 by default, 10 at most), so a literal
69
+ // "2-5" in the schema would contradict the block the model reads in
70
+ // the very same request. `<team_mode>` is the single authority.
71
+ description: 'Independent subagents to run in parallel in ONE call. See <team_mode> for ' +
72
+ 'the current maximum and concurrency; extras are dropped.',
69
73
  items: {
70
74
  type: 'object',
71
75
  properties: {
@@ -137,6 +137,23 @@ export declare const PLAN_LIMITS: {
137
137
  readonly maxRisks: 10;
138
138
  readonly maxOpenQuestions: 10;
139
139
  };
140
+ /**
141
+ * The STORAGE bound of the `ask_user` de-duplication guard — NOT a refusal
142
+ * condition, and the distinction is the whole point.
143
+ *
144
+ * Storage is memory hygiene: a turn that asks a thousand DISTINCT question sets
145
+ * is pathological, but it is still the user's turn, and Esc interrupts it. The
146
+ * refusal rule is semantic: "never ask the same answered questions twice". Folding
147
+ * the bound into the refusal rule would restore the round cap this feature
148
+ * deletes, with a different number written on it.
149
+ *
150
+ * Owned here rather than in `plan-tools.ts` (the caller) or `controller.ts` (the
151
+ * state holder) because this file is already the single authority on the
152
+ * structural limits of human input, beside `QUESTION_LIMITS` / `PLAN_LIMITS`.
153
+ */
154
+ export declare const ASK_GUARD_LIMITS: {
155
+ readonly maxRemembered: 128;
156
+ };
140
157
  /**
141
158
  * Trim to `max` characters, preferring a word boundary within the last
142
159
  * `WORD_BOUNDARY_WINDOW` characters. Cutting a sentence mid-word reads as
@@ -165,13 +182,29 @@ export declare function normalizePlan(raw: unknown): NormalizedPlan | null;
165
182
  export interface AnswersResultShape {
166
183
  answers: Answer[];
167
184
  cancelled: boolean;
168
- roundsUsed: number;
169
- roundsRemaining: number;
170
185
  }
186
+ /**
187
+ * A question SET's identity: the question texts, trimmed, lowercased, whitespace
188
+ * collapsed, sorted, joined.
189
+ *
190
+ * `id` and `header` are excluded on purpose — a model that re-asks the same thing
191
+ * under a new id is asking the same thing, and the ids are the model's own
192
+ * bookkeeping rather than part of what the user reads.
193
+ *
194
+ * The fingerprint is stable because `normalizeQuestions` already ran on the TOOL
195
+ * side: header truncation and option trimming cannot make the same question look
196
+ * different here. Worst case is 5 questions x 300 chars = 1,500 chars.
197
+ */
198
+ export declare function questionFingerprint(questions: NormalizedQuestion[]): string;
171
199
  /**
172
200
  * Render the `ask_user` result: one human-readable line so the transcript
173
201
  * preview is legible without parsing, then the JSON the model reads.
174
202
  *
203
+ * CARRIES NO ROUND COUNTER, deliberately (D-4). The model used to be told
204
+ * `roundsUsed` / `roundsRemaining` on every call, which is a per-turn reminder
205
+ * that it is running out — and an agent that reads "you have 1 round left"
206
+ * behaves like one. There is no limit any more, so there is nothing to report.
207
+ *
175
208
  * `asked` is passed separately rather than derived from `answers.length`
176
209
  * because a cancelled wizard returns fewer answers than it asked for, and
177
210
  * "answered 1 of 3" is the whole information content of that line.
@@ -51,6 +51,21 @@ export const PLAN_LIMITS = {
51
51
  maxRisks: 10,
52
52
  maxOpenQuestions: 10,
53
53
  };
54
+ /**
55
+ * The STORAGE bound of the `ask_user` de-duplication guard — NOT a refusal
56
+ * condition, and the distinction is the whole point.
57
+ *
58
+ * Storage is memory hygiene: a turn that asks a thousand DISTINCT question sets
59
+ * is pathological, but it is still the user's turn, and Esc interrupts it. The
60
+ * refusal rule is semantic: "never ask the same answered questions twice". Folding
61
+ * the bound into the refusal rule would restore the round cap this feature
62
+ * deletes, with a different number written on it.
63
+ *
64
+ * Owned here rather than in `plan-tools.ts` (the caller) or `controller.ts` (the
65
+ * state holder) because this file is already the single authority on the
66
+ * structural limits of human input, beside `QUESTION_LIMITS` / `PLAN_LIMITS`.
67
+ */
68
+ export const ASK_GUARD_LIMITS = { maxRemembered: 128 };
54
69
  /** How far back a truncation will look for a space before cutting hard. */
55
70
  const WORD_BOUNDARY_WINDOW = 12;
56
71
  // ---------------------------------------------------------------------------
@@ -208,10 +223,33 @@ export function normalizePlan(raw) {
208
223
  openQuestions: nonEmptyStrings(src.openQuestions, PLAN_LIMITS.maxOpenQuestions),
209
224
  };
210
225
  }
226
+ /**
227
+ * A question SET's identity: the question texts, trimmed, lowercased, whitespace
228
+ * collapsed, sorted, joined.
229
+ *
230
+ * `id` and `header` are excluded on purpose — a model that re-asks the same thing
231
+ * under a new id is asking the same thing, and the ids are the model's own
232
+ * bookkeeping rather than part of what the user reads.
233
+ *
234
+ * The fingerprint is stable because `normalizeQuestions` already ran on the TOOL
235
+ * side: header truncation and option trimming cannot make the same question look
236
+ * different here. Worst case is 5 questions x 300 chars = 1,500 chars.
237
+ */
238
+ export function questionFingerprint(questions) {
239
+ return questions
240
+ .map((q) => q.question.trim().toLowerCase().replace(/\s+/g, ' '))
241
+ .sort()
242
+ .join(' | ');
243
+ }
211
244
  /**
212
245
  * Render the `ask_user` result: one human-readable line so the transcript
213
246
  * preview is legible without parsing, then the JSON the model reads.
214
247
  *
248
+ * CARRIES NO ROUND COUNTER, deliberately (D-4). The model used to be told
249
+ * `roundsUsed` / `roundsRemaining` on every call, which is a per-turn reminder
250
+ * that it is running out — and an agent that reads "you have 1 round left"
251
+ * behaves like one. There is no limit any more, so there is nothing to report.
252
+ *
215
253
  * `asked` is passed separately rather than derived from `answers.length`
216
254
  * because a cancelled wizard returns fewer answers than it asked for, and
217
255
  * "answered 1 of 3" is the whole information content of that line.
@@ -17,22 +17,30 @@
17
17
  * selected by whether an optional install step succeeded.
18
18
  */
19
19
  import { type AgentTool } from '@aragon-agent/core';
20
- import { type HumanInputGate } from './human-input.js';
21
- /** The outcome of trying to spend one `ask_user` round. */
22
- export interface AskRoundTicket {
23
- ok: boolean;
24
- used: number;
25
- remaining: number;
26
- max: number;
27
- }
20
+ import { type HumanInputGate, type NormalizedQuestion } from './human-input.js';
28
21
  export interface PlanToolsOptions {
29
22
  gate: HumanInputGate;
30
23
  /**
31
- * Spend one `ask_user` round. The counter is owned by the controller because
32
- * it resets per user turn, and the controller is the only object that sees a
33
- * turn begin.
24
+ * Read-only: has this exact question SET already been ANSWERED in this turn?
25
+ *
26
+ * Called BEFORE `gate.request`, and it must have no side effects — that is why
27
+ * the guard is two callbacks rather than one. A single read-and-write function
28
+ * can only be placed before the question (which records an answer nobody gave)
29
+ * or after it (which cannot prevent a repeat). Both are wrong, and the second
30
+ * one silently turns "cancelled rounds stay askable" into a lie.
31
+ */
32
+ hasAnsweredQuestions: (questions: NormalizedQuestion[]) => boolean;
33
+ /**
34
+ * Write-only: remember that this question SET was answered. Called AFTER
35
+ * `gate.request`, and NEVER when the round was cancelled.
36
+ *
37
+ * Cancellation is not consent. `Esc` may be a mis-touch, and turning a
38
+ * mis-touch into "this question may never be asked again" forces the model to
39
+ * guess exactly when it most needs to ask — the behaviour this feature exists
40
+ * to remove. The cheap failure (asking once more) is preferable to the
41
+ * expensive one (information lost for the rest of the turn).
34
42
  */
35
- takeAskRound: () => AskRoundTicket;
43
+ noteAnsweredQuestions: (questions: NormalizedQuestion[]) => void;
36
44
  /**
37
45
  * Run `fn` with the idle watchdog paused. `try/finally` inside, so a throw in
38
46
  * the overlay path cannot leave the watchdog disarmed for the rest of the run.