@aragon-agent/cli 0.5.4 → 0.5.6

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 (60) hide show
  1. package/CHANGELOG.md +670 -577
  2. package/README.md +1105 -995
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/headless.d.ts +29 -0
  6. package/dist/agent/headless.js +86 -1
  7. package/dist/agent/reducer.d.ts +45 -0
  8. package/dist/agent/reducer.js +80 -1
  9. package/dist/agent/system-prompt.d.ts +8 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +40 -1
  12. package/dist/commands/builtins.js +166 -0
  13. package/dist/commands/registry.d.ts +27 -1
  14. package/dist/config/env.js +43 -0
  15. package/dist/config/load.d.ts +27 -0
  16. package/dist/config/load.js +25 -1
  17. package/dist/config/schema.d.ts +47 -0
  18. package/dist/config/schema.js +38 -0
  19. package/dist/config/store.js +10 -1
  20. package/dist/session/persist.d.ts +19 -1
  21. package/dist/session/persist.js +21 -2
  22. package/dist/team/subagent.js +11 -0
  23. package/dist/todo/follow-through.d.ts +117 -0
  24. package/dist/todo/follow-through.js +186 -0
  25. package/dist/todo/limits.d.ts +114 -0
  26. package/dist/todo/limits.js +114 -0
  27. package/dist/todo/normalize.d.ts +33 -0
  28. package/dist/todo/normalize.js +117 -0
  29. package/dist/todo/panel-rows.d.ts +53 -0
  30. package/dist/todo/panel-rows.js +103 -0
  31. package/dist/todo/prompt.d.ts +54 -0
  32. package/dist/todo/prompt.js +76 -0
  33. package/dist/todo/store.d.ts +86 -0
  34. package/dist/todo/store.js +183 -0
  35. package/dist/todo/todo-tool.d.ts +38 -0
  36. package/dist/todo/todo-tool.js +134 -0
  37. package/dist/todo/types.d.ts +62 -0
  38. package/dist/todo/types.js +12 -0
  39. package/dist/tools/index.d.ts +16 -2
  40. package/dist/tools/index.js +13 -0
  41. package/dist/ui/App.js +244 -7
  42. package/dist/ui/StatusBar.d.ts +18 -0
  43. package/dist/ui/StatusBar.js +5 -2
  44. package/dist/ui/TodoPanel.d.ts +48 -0
  45. package/dist/ui/TodoPanel.js +74 -0
  46. package/dist/ui/TodoStrip.d.ts +36 -0
  47. package/dist/ui/TodoStrip.js +32 -0
  48. package/dist/ui/Transcript.js +11 -1
  49. package/dist/ui/entries/TodoCard.d.ts +31 -0
  50. package/dist/ui/entries/TodoCard.js +35 -0
  51. package/dist/ui/glyphs.d.ts +12 -0
  52. package/dist/ui/glyphs.js +8 -0
  53. package/dist/ui/layout/AppShell.d.ts +34 -1
  54. package/dist/ui/layout/AppShell.js +3 -3
  55. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  56. package/dist/ui/layout/ScrollViewport.js +2 -2
  57. package/dist/ui/layout/rail.d.ts +56 -0
  58. package/dist/ui/layout/rail.js +68 -0
  59. package/dist/ui/overlays/HelpOverlay.js +4 -0
  60. package/package.json +3 -2
@@ -53,6 +53,14 @@ export interface SystemPromptParams {
53
53
  * mixing them a one-character mistake.
54
54
  */
55
55
  subagentBlock?: string;
56
+ /**
57
+ * The rendered `<todo_planning>` block, or `''` (todo-plan-execution §3.7).
58
+ *
59
+ * Spliced conditionally exactly as `skillsBlock`, the plan block and
60
+ * `teamBlock` are, which is what keeps C-10 / I-5 true: `todo.enabled: false`
61
+ * (and `--no-todo`) produce the pre-todo prompt BYTE FOR BYTE.
62
+ */
63
+ todoBlock?: string;
56
64
  }
57
65
  /** Describe the active shell so the model does not emit incompatible syntax. */
58
66
  export declare function describeShell(): {
@@ -39,6 +39,7 @@ export function buildSystemPrompt(params) {
39
39
  : '';
40
40
  const teamBlock = params.teamBlock ?? '';
41
41
  const subagentBlock = params.subagentBlock ?? '';
42
+ const todoBlock = params.todoBlock ?? '';
42
43
  return [
43
44
  'You are AragonAgent, an autonomous coding assistant operating inside a terminal (TUI).',
44
45
  'You help the user accomplish software-engineering tasks by reasoning step by step and using the provided tools.',
@@ -54,6 +55,7 @@ export function buildSystemPrompt(params) {
54
55
  ...(planBlock ? ['', planBlock] : []),
55
56
  ...(teamBlock ? ['', teamBlock] : []),
56
57
  ...(subagentBlock ? ['', subagentBlock] : []),
58
+ ...(todoBlock ? ['', todoBlock] : []),
57
59
  '',
58
60
  'Operating guidance:',
59
61
  '- Prefer reading files and inspecting the workspace before making changes.',
package/dist/cli.js CHANGED
@@ -86,6 +86,17 @@ function toFlags(opts) {
86
86
  // reach `CliFlags` and are inert, with no compile error and no runtime error.
87
87
  team: opts.team,
88
88
  teamMax: opts.teamMax,
89
+ // Site three of three for `--todo` / `--todo-panel`, and the one this
90
+ // package has now paid for twice (`mouse` P1-2, `team` P1-4): a flag needs
91
+ // the `.option()` declaration below, a field on `CliFlags`, AND a line here.
92
+ // Miss this one and the flag is inert with no compile error and no runtime
93
+ // error.
94
+ todo: opts.todo,
95
+ todoPanel: opts.todoPanel,
96
+ // Site three of three for `--todo-follow` as well (todo-plan-followthrough
97
+ // C-9). A value flag is no less inert than a boolean one when this line is
98
+ // missing.
99
+ todoFollow: opts.todoFollow,
89
100
  // `--verbose` is just a shorthand for `--log-level debug`; an explicit
90
101
  // `--log-level` still wins, so the two can be combined without surprise.
91
102
  logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
@@ -120,12 +131,21 @@ function makeController(flags, opts = { interactive: true }) {
120
131
  // resolved a bootstrap level — it ran before commander and before `.env` were
121
132
  // readable — and without this hand-off the fully resolved one never applies.
122
133
  getLogger().reconfigure(config.log);
134
+ // Whether this session could EVER show the todo rail (todo-plan-execution
135
+ // §3.7 / P1-6). It varies ONE sentence of `<todo_planning>`; `decideRenderMode`
136
+ // is pure and `runInteractive` calls it again with the same inputs, so
137
+ // computing it here rather than threading it back costs nothing and keeps the
138
+ // controller's construction self-contained.
139
+ const todoPanelCapable = opts.interactive &&
140
+ decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout) ===
141
+ 'fullscreen';
123
142
  const confirmBridge = { handler: null };
124
143
  // `cancelPending` is replaced by the App's own implementation on mount; this
125
144
  // no-op keeps the object total so nothing has to null-check it.
126
145
  const humanInputBridge = { handler: null, cancelPending: () => { } };
127
146
  const controller = new AgentController(config, {
128
147
  version: VERSION,
148
+ todoPanelCapable,
129
149
  confirm: (req) => confirmBridge.handler ? confirmBridge.handler(req) : Promise.resolve(true),
130
150
  approval: opts.interactive
131
151
  ? {
@@ -308,7 +328,13 @@ async function runOneShot(flags, prompt, quiet) {
308
328
  for (const dir of untrusted) {
309
329
  process.stderr.write(`[skills] project skills in ${dir} skipped (untrusted). Run: aragon skills trust ${dir}\n`);
310
330
  }
311
- const code = await runHeadless(controller, prompt, { quiet });
331
+ // `followThrough` is read off the REAL controller here rather than through
332
+ // `HeadlessController`, which is deliberately the minimal surface a test can
333
+ // satisfy with an object literal (todo-plan-followthrough §3.6).
334
+ const code = await runHeadless(controller, prompt, {
335
+ quiet,
336
+ followThrough: controller.getTodoConfig().followThrough,
337
+ });
312
338
  // Flushed HERE and not inside `runHeadless`: that module is deliberately
313
339
  // injectable (its own controller interface, its own stdout/stderr) and has an
314
340
  // early `return 2`, so a singleton dependency in there would be both a new
@@ -602,6 +628,19 @@ function buildProgram() {
602
628
  .option('--team', 'Enable team subagents for this session (the default)')
603
629
  .option('--no-team', 'Disable team subagents (the task tool is not registered at all)')
604
630
  .option('--team-max <n>', 'Max subagents per dispatch for this run (1-10)')
631
+ // BOTH PAIRS, positive first (todo-plan-execution §4.3 / P1-8). `--todo` is
632
+ // the `--team` story verbatim; `--todo-panel` needs the pair for a second
633
+ // reason on top of it — `panel` is PERSISTED, so a lone `--no-todo-panel`
634
+ // would make commander default `opts.todoPanel` to `true` and silently
635
+ // overwrite a stored `panel: false` on every run that passed no flag.
636
+ .option('--todo', 'Enable todo planning for this session (the default)')
637
+ .option('--no-todo', 'Disable todo planning (the todo_write tool is not registered at all)')
638
+ .option('--todo-panel', 'Show the todo plan in a right-hand rail (the default)')
639
+ .option('--no-todo-panel', 'Keep todo planning but do not render the rail')
640
+ // NOT A PAIR, and it does not need to be: a value flag is absent unless
641
+ // passed, so `!== undefined` in `resolveTodoConfig` already distinguishes it
642
+ // from silence. An unrecognized mode is clamped to `notify`, not rejected.
643
+ .option('--todo-follow <mode>', 'What to do when a run ends with unfinished steps: notify (default) | auto | off')
605
644
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
606
645
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
607
646
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
@@ -9,6 +9,8 @@ import { isAbsolute, resolve } from 'node:path';
9
9
  import process from 'node:process';
10
10
  import { resolveOutputTokens } from '@aragon-agent/core';
11
11
  import { clampThinkingLevel, clampTheme, isThemeName, parseMaxTokensInput, DEFAULT_MAX_TOKENS, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
12
+ import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
13
+ import { buildContinuationMessage } from '../todo/follow-through.js';
12
14
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
13
15
  import { loadConfig } from '../config/load.js';
14
16
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
@@ -220,6 +222,160 @@ const COMMANDS = [
220
222
  ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>].`);
221
223
  },
222
224
  },
225
+ {
226
+ name: 'todo',
227
+ description: 'Todo planning: status | on | off | panel on|off | follow <mode> | clear | continue',
228
+ /**
229
+ * `/todo on` CANNOT turn planning on in a session that started with
230
+ * `--no-todo`, for the reason `/team on` above records at length: the tool
231
+ * array is built ONCE and rebuilding it is forbidden (C-1), while
232
+ * `rebuildSystemPrompt()` IS a live path — so the naive reading produces a
233
+ * session where the model is told about a `todo_write` that was never
234
+ * registered.
235
+ *
236
+ * TWO CALLS, IN THIS ORDER, FOR BOTH `on|off` AND `panel on|off`, and
237
+ * neither pair is optional:
238
+ *
239
+ * `setTodoEnabled` re-splices `<todo_planning>` (P1-1). Without it the
240
+ * block survives `/todo off` and every call comes back
241
+ * refused while the instructions to keep calling are
242
+ * still in the model's context.
243
+ * `setTodoConfig` updates the object `App` reads at RENDER time (P1-2).
244
+ * `persistConfig` only writes the file, so without it
245
+ * `/todo panel off` reports success and changes nothing
246
+ * until the next launch.
247
+ * `persistConfig` is what survives the session, in both cases.
248
+ */
249
+ run: (ctx) => {
250
+ const arg = ctx.args.trim().toLowerCase();
251
+ const [verb, value] = arg.split(/\s+/, 2);
252
+ const controller = ctx.controller;
253
+ if (!verb || verb === 'status') {
254
+ const snapshot = controller.getTodoSnapshot();
255
+ const state = !controller.isTodoRegistered()
256
+ ? 'off for this session (started with --no-todo)'
257
+ : controller.isTodoEnabled()
258
+ ? 'on'
259
+ : 'off';
260
+ const todoCfg = controller.getTodoConfig();
261
+ const panel = todoCfg.panel ? 'on' : 'off';
262
+ // THE COUNTERS APPEAR ONLY IN `auto` MODE (§4.3): printing `0/25` under
263
+ // `notify` would advertise a budget nothing is spending.
264
+ const follow = todoCfg.followThrough === 'auto'
265
+ ? `auto (${ctx.followBudget?.used ?? 0}/` +
266
+ `${TODO_FOLLOW_LIMITS.maxAutoContinuesPerList} used)`
267
+ : todoCfg.followThrough;
268
+ if (!snapshot) {
269
+ ctx.notify('info', `No todo list. Todo planning: ${state}. Panel: ${panel}. ` +
270
+ `Follow-through: ${follow}.`);
271
+ return;
272
+ }
273
+ const active = snapshot.activeIndex >= 0
274
+ ? ` In progress: "${snapshot.items[snapshot.activeIndex]?.content ?? ''}".`
275
+ : '';
276
+ ctx.notify('info', `Todos: ${snapshot.doneCount}/${snapshot.total} done.${active} Panel: ${panel}. ` +
277
+ `Follow-through: ${follow}.`);
278
+ return;
279
+ }
280
+ if (verb === 'on' || verb === 'off') {
281
+ const enabled = verb === 'on';
282
+ // A PARTIAL `todo` section, safe only because `updatePersistedConfig`
283
+ // deep-merges it — the contract §4.2 spells out in full.
284
+ ctx.persistConfig({ todo: { enabled } });
285
+ if (!controller.isTodoRegistered()) {
286
+ ctx.notify('info', enabled
287
+ ? 'Todo planning is off for this session (started with --no-todo). Saved for next launch.'
288
+ : 'Todo planning is already off for this session. Saved for next launch.');
289
+ return;
290
+ }
291
+ controller.setTodoEnabled(enabled);
292
+ ctx.toast('success', `Todo planning ${enabled ? 'on' : 'off'}.`);
293
+ return;
294
+ }
295
+ if (verb === 'panel') {
296
+ if (value !== 'on' && value !== 'off') {
297
+ ctx.notify('warn', 'Usage: /todo panel <on|off>');
298
+ return;
299
+ }
300
+ const panel = value === 'on';
301
+ controller.setTodoConfig({ panel });
302
+ ctx.persistConfig({ todo: { panel } });
303
+ ctx.toast('success', `Todo panel ${panel ? 'on' : 'off'}.`);
304
+ return;
305
+ }
306
+ if (verb === 'follow') {
307
+ if (value !== 'notify' && value !== 'auto' && value !== 'off') {
308
+ ctx.notify('warn', 'Usage: /todo follow <notify|auto|off>');
309
+ return;
310
+ }
311
+ const followThrough = value;
312
+ // THE SAME TWO-CALL RULE AS `panel` ABOVE, minus `setTodoEnabled`:
313
+ // nothing in `<todo_planning>` mentions follow-through (D-3), so
314
+ // `todoEnabled` has no bearing here.
315
+ //
316
+ // `setTodoConfig` REBUILDS THE SYSTEM PROMPT UNCONDITIONALLY
317
+ // (`controller.ts`), and that is fine and must be left alone: the
318
+ // rebuild is idempotent for this key (the block does not mention
319
+ // follow-through, so the output is byte-identical), it costs one string
320
+ // composition PER COMMAND INVOCATION rather than per turn, and
321
+ // `setTodoConfig` is the ONLY method that updates the object `App` reads
322
+ // at decision time. A branch that bypassed it to avoid the rebuild would
323
+ // trade a free string concatenation for the exact "reports success,
324
+ // changes nothing" bug the method exists to prevent (P1-1 / P2-10).
325
+ controller.setTodoConfig({ followThrough });
326
+ ctx.persistConfig({ todo: { followThrough } });
327
+ ctx.toast('success', followThrough === 'auto'
328
+ ? `Follow-through: auto (up to ${TODO_FOLLOW_LIMITS.maxAutoContinuesPerList} ` +
329
+ 'continuations per plan).'
330
+ : `Follow-through: ${followThrough}.`);
331
+ return;
332
+ }
333
+ if (verb === 'clear') {
334
+ // REFUSED MID-RUN, which bounds the model/panel disagreement window to
335
+ // zero (§1.2): between turns the model's next `todo_write` is a full
336
+ // replacement anyway.
337
+ if (controller.isRunning()) {
338
+ ctx.notify('warn', 'Cannot clear the todo list while a run is in progress.');
339
+ return;
340
+ }
341
+ if (!controller.getTodoSnapshot()) {
342
+ ctx.notify('info', 'No todo list.');
343
+ return;
344
+ }
345
+ controller.clearTodos();
346
+ ctx.toast('info', 'Todo list cleared.');
347
+ return;
348
+ }
349
+ if (verb === 'continue') {
350
+ // REFUSED WHILE RUNNING TOO (P2-7). `ctx.submit` is the App's
351
+ // `submitMessage`, which routes to `controller.steer()` when the status
352
+ // is `running` — so the un-refused form would inject "continue the
353
+ // remaining items" into the run that is already doing exactly that.
354
+ if (controller.isRunning()) {
355
+ ctx.notify('warn', 'Already running.');
356
+ return;
357
+ }
358
+ const snapshot = controller.getTodoSnapshot();
359
+ if (!snapshot || snapshot.doneCount >= snapshot.total) {
360
+ ctx.notify('info', 'Nothing left to continue.');
361
+ return;
362
+ }
363
+ // THE SAME FUNCTION THE AUTO PATH USES, which is what keeps the two from
364
+ // drifting (D-11 / D-12). Round 1's fixed string "Continue with the
365
+ // remaining todo items." was a REFERENCE to a `todo_write` call that a
366
+ // long session may have scrolled out of view.
367
+ //
368
+ // `{ userInitiated: false }`: the user typed `/todo continue`, which the
369
+ // slash history already has. Putting this canned paragraph under the
370
+ // composer's up-arrow as well would be the same event recorded twice, in
371
+ // the more verbose of the two forms (AC-17).
372
+ ctx.submit(buildContinuationMessage(snapshot), { userInitiated: false });
373
+ return;
374
+ }
375
+ ctx.notify('warn', `Unknown argument "${verb}" - use ` +
376
+ '/todo [status|on|off|panel on|off|follow <mode>|clear|continue].');
377
+ },
378
+ },
223
379
  {
224
380
  name: 'expand',
225
381
  description: 'Expand / collapse a recent tool card (default: last)',
@@ -296,6 +452,9 @@ const COMMANDS = [
296
452
  },
297
453
  messages: ctx.controller.getMessages(),
298
454
  entries: ctx.state.entries,
455
+ // `/resume` restores `messages`, so the belief that justifies the
456
+ // panel comes back — and the panel has to come back with it (§3.13).
457
+ todos: ctx.controller.getTodoSnapshot()?.items ?? [],
299
458
  });
300
459
  ctx.notify('info', `Saved session to ${path}`);
301
460
  }
@@ -312,6 +471,13 @@ const COMMANDS = [
312
471
  const path = resolveSessionPath(ctx.args, ctx.controller.getCwd());
313
472
  const session = loadSession(path);
314
473
  ctx.controller.replaceMessages(session.messages);
474
+ // IN THE SAME `try`, IMMEDIATELY AFTER `replaceMessages`, so a malformed
475
+ // file fails the whole resume rather than half of it — and AN ABSENT
476
+ // `todos` CLEARS RATHER THAN SKIPPING (P0-2 / D-22). `replaceMessages`
477
+ // has just discarded the conversation the current list belonged to, so
478
+ // leaving the rail up produces a panel that disagrees with the model,
479
+ // which §1.2 ranks below having no panel at all.
480
+ ctx.controller.restoreTodos(session.todos ?? []);
315
481
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
316
482
  if (session.model?.providerId && session.model?.modelId) {
317
483
  ctx.controller.setModel(session.model.providerId, session.model.modelId, session.model.baseUrl);
@@ -6,6 +6,7 @@ import type { AgentController } from '../agent/controller.js';
6
6
  import type { AgentMode } from '../agent/agent-mode.js';
7
7
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
8
8
  import type { PersistedConfig } from '../config/schema.js';
9
+ import type { FollowThroughBudget } from '../todo/follow-through.js';
9
10
  export interface CommandContext {
10
11
  /** Raw argument string after the command word (may be empty). */
11
12
  args: string;
@@ -24,8 +25,33 @@ export interface CommandContext {
24
25
  * idle). Dynamic skill commands need this: they turn `/pdf-forms a.pdf` into a
25
26
  * real user message. It is a separate entry point from `handleSubmit` on
26
27
  * purpose — routing back through that would re-enter slash-command parsing.
28
+ *
29
+ * `userInitiated: false` suppresses `recordPrompt` (todo-plan-followthrough
30
+ * C-12 / AC-17), which skips TWO things and both are wanted: the prompt-history
31
+ * append, and the `submitCount` bump that drives the composer's hint fade. A
32
+ * canned string the user did not type belongs in neither. The parameter is
33
+ * named for the CAUSE rather than for one of its effects, because a flag
34
+ * called `recordHistory` would have made the second one look like a bug.
35
+ *
36
+ * WIDENING THIS SIGNATURE IS THE WHOLE REASON THIS FILE IS IN THE CHANGE PLAN
37
+ * (P1-5): `builtins.ts` reaches `submitMessage` only through here, so
38
+ * `/todo continue` cannot opt out of prompt history without it.
39
+ */
40
+ submit: (text: string, opts?: {
41
+ userInitiated?: boolean;
42
+ }) => void;
43
+ /**
44
+ * The live auto-continue budget, for `/todo status`'s `(2/25 used)` clause
45
+ * (todo-plan-followthrough §4.3).
46
+ *
47
+ * READ-ONLY AND OPTIONAL. It lives in an `App` ref rather than on the
48
+ * controller or in `ViewState` (§4.4 / §5.2) because it counts a SESSION'S
49
+ * INTERACTION WITH THE USER, not the model's plan — so this is the only way a
50
+ * command can see it, and `makeCtx` is the only place that supplies it.
51
+ * Optional so the many test contexts that build this object by literal need no
52
+ * change; absent means the clause degrades to naming the ceiling alone.
27
53
  */
28
- submit: (text: string) => void;
54
+ followBudget?: FollowThroughBudget;
29
55
  /** Rebuild the system prompt and the dynamic command list after a skill change. */
30
56
  refreshSkills: () => void;
31
57
  /**
@@ -134,6 +134,49 @@ export function readEnvConfig() {
134
134
  if (Object.keys(section).length > 0)
135
135
  partial.team = section;
136
136
  }
137
+ // ARAGON_TODO=0 turns todo planning off entirely (no tool, no rail). Parsed by
138
+ // the POSITIVE list, exactly as ARAGON_FULLSCREEN / ARAGON_MOUSE / ARAGON_PLAN
139
+ // / ARAGON_TEAM are — NOT by `envBool` below, which uses a negative list. The
140
+ // two readers disagree on values like `ARAGON_TODO=disable`, and this file
141
+ // already records that once for team mode.
142
+ //
143
+ // There is no `ARAGON_TODO_PANEL`: the panel is a per-machine display
144
+ // preference, which is what `config.json` is for, and every env var here
145
+ // exists because a flag and a config file are BOTH unreachable in the channel
146
+ // it serves (containers, SSH, being spawned by another tool).
147
+ //
148
+ // ARAGON_TODO_FOLLOW=notify|auto|off PASSES THAT SAME TEST (D-18), which is
149
+ // why it exists where `ARAGON_TODO_PANEL` does not: follow-through is exactly
150
+ // the knob an unattended `-p` run in a container with a fixed entrypoint needs
151
+ // and can reach no other way.
152
+ //
153
+ // ONE ACCUMULATED SECTION, ASSIGNED ONCE — the shape the `team` branch above
154
+ // already uses, and NOT two `if` blocks each assigning `partial.todo` (P1-4).
155
+ // THE CAST IS WHAT WOULD MAKE THAT MISTAKE SILENT: a second assignment
156
+ // compiles clean and drops `enabled`, so `ARAGON_TODO=0 ARAGON_TODO_FOLLOW=auto`
157
+ // would quietly re-enable the tool the user turned off.
158
+ const todo = process.env.ARAGON_TODO?.trim().toLowerCase();
159
+ const todoFollow = process.env.ARAGON_TODO_FOLLOW?.trim().toLowerCase();
160
+ if ((todo !== undefined && todo.length > 0) ||
161
+ (todoFollow !== undefined && todoFollow.length > 0)) {
162
+ // Keyed by `TodoConfig` but VALUED `unknown`, which is what lets the mode be
163
+ // written through unvalidated (below) while still failing to compile if a
164
+ // future key is misspelled.
165
+ const section = {};
166
+ if (todo !== undefined && todo.length > 0) {
167
+ section.enabled = todo === '1' || todo === 'true' || todo === 'on' || todo === 'yes';
168
+ }
169
+ // Written through unvalidated: `clampTodoConfig` clamps an unrecognized mode
170
+ // to the default rather than rejecting it, so validating here would be a
171
+ // second gate that can disagree with the first.
172
+ if (todoFollow !== undefined && todoFollow.length > 0) {
173
+ section.followThrough = todoFollow;
174
+ }
175
+ // A PARTIAL section, safe only because every consumer deep-merges it — the
176
+ // same contract `partial.skills`, `partial.log` and `partial.team` rely on.
177
+ if (Object.keys(section).length > 0)
178
+ partial.todo = section;
179
+ }
137
180
  const log = readEnvLogConfig();
138
181
  if (log)
139
182
  partial.log = log;
@@ -52,6 +52,33 @@ export interface CliFlags {
52
52
  team?: boolean;
53
53
  /** `--team-max <n>` — override `team.maxSubagents` for this run (still clamped). */
54
54
  teamMax?: string | number;
55
+ /**
56
+ * `--todo` ⇒ true, `--no-todo` ⇒ false, absent ⇒ undefined
57
+ * (todo-plan-execution §4.3).
58
+ */
59
+ todo?: boolean;
60
+ /**
61
+ * `--todo-panel` ⇒ true, `--no-todo-panel` ⇒ false, absent ⇒ undefined.
62
+ *
63
+ * DECLARED AS A PAIR, like `--todo` above, and unlike `--no-skills` (P1-8).
64
+ * `panel` is a PERSISTED setting, so "not passed" has to stay distinguishable
65
+ * from `true` — otherwise commander's lone-`--no-x` default silently
66
+ * overwrites a config-file `panel: false` on every run that passes no flag at
67
+ * all. `--no-skills` escapes the rule only because nothing needs to
68
+ * distinguish `--skills` from silence.
69
+ */
70
+ todoPanel?: boolean;
71
+ /**
72
+ * `--todo-follow <mode>` — `notify` | `auto` | `off`
73
+ * (todo-plan-followthrough §4.2).
74
+ *
75
+ * A VALUE FLAG, NOT A PAIR, so commander materializes it only when passed and
76
+ * the `!== undefined` rule above applies to its presence rather than to a
77
+ * synthesized `true`. Typed `string` and not `FollowThroughMode`: an
78
+ * unrecognized value is CLAMPED to the default by `clampTodoConfig`, never
79
+ * rejected, which is what every other key in this file does.
80
+ */
81
+ todoFollow?: string;
55
82
  /** `--log-level <lv>` — this run only, never persisted. */
56
83
  logLevel?: string;
57
84
  /** `--log-file` ⇒ true, `--no-log-file` ⇒ false, absent ⇒ undefined. */
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
@@ -97,6 +97,29 @@ function resolveTeamConfig(flags, env, file) {
97
97
  : {}),
98
98
  });
99
99
  }
100
+ /**
101
+ * Merge the `todo` section across all four layers (defaults › file › env ›
102
+ * flags), todo-plan-execution §4.3.
103
+ *
104
+ * `!== undefined` ON BOTH FLAGS, and that is the whole point (P1-8). `commander`
105
+ * materializes a lone `--no-x` as `opts.x = true` when the flag is absent, so a
106
+ * truthiness check cannot tell `--no-todo-panel` from "not passed" — and both
107
+ * keys here are persisted, so both need the distinction. This is the same rule
108
+ * `resolveTeamConfig` above states, one feature later.
109
+ */
110
+ function resolveTodoConfig(flags, env, file) {
111
+ return clampTodoConfig({
112
+ ...DEFAULT_CONFIG.todo,
113
+ ...(file.todo ?? {}),
114
+ ...(env.todo ?? {}),
115
+ ...(flags.todo !== undefined ? { enabled: flags.todo } : {}),
116
+ ...(flags.todoPanel !== undefined ? { panel: flags.todoPanel } : {}),
117
+ // `!== undefined` for the third time, though for a value flag the risk is
118
+ // milder: commander leaves `opts.todoFollow` absent unless it was passed.
119
+ // Written this way anyway so the three lines read as one rule.
120
+ ...(flags.todoFollow !== undefined ? { followThrough: flags.todoFollow } : {}),
121
+ });
122
+ }
100
123
  /** Merge the `log` section across all four layers (defaults › file › env › flags). */
101
124
  function resolveLogConfig(flags, env, file) {
102
125
  const merged = clampLogConfig({
@@ -285,6 +308,7 @@ export function loadConfig(flags = {}) {
285
308
  },
286
309
  log: resolveLogConfig(flags, env.partial, file),
287
310
  team: resolveTeamConfig(flags, env.partial, file),
311
+ todo: resolveTodoConfig(flags, env.partial, file),
288
312
  cwd,
289
313
  color,
290
314
  colorLevel,
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type ThinkingLevel } from '@aragon-agent/core';
9
9
  import { type LogLevelName } from '../logging/levels.js';
10
+ import type { FollowThroughMode } from '../todo/types.js';
10
11
  /**
11
12
  * `ModelRegistry` seeds builtin lists for five providers, but `initProviders()`
12
13
  * registers only three adapters. Running a model whose provider has no adapter
@@ -239,6 +240,40 @@ export declare const DEFAULT_TEAM_CONFIG: TeamConfig;
239
240
  * the two disagree would make the panel's `n/m` readout confusing for no gain.
240
241
  */
241
242
  export declare function clampTeamConfig(raw: unknown): TeamConfig;
243
+ export interface TodoConfig {
244
+ /** Register `todo_write` at all. Default TRUE. */
245
+ enabled: boolean;
246
+ /**
247
+ * Render the right rail.
248
+ *
249
+ * INDEPENDENT OF `enabled` ON PURPOSE (D-16): a screen-reader user wants the
250
+ * planning discipline without the column, and the prompt block varies one
251
+ * sentence rather than disappearing (§3.7).
252
+ */
253
+ panel: boolean;
254
+ /**
255
+ * What happens when a run ends with unfinished steps
256
+ * (todo-plan-followthrough §4.1). Default `'notify'`, which reproduces round
257
+ * 1's behaviour byte for byte.
258
+ *
259
+ * THE THIRD SCALAR, and it stays a scalar for the reason the section header
260
+ * above gives: `store.ts` merges this section BY HAND, and both of its merges
261
+ * spread the whole object (`{ ...DEFAULT_CONFIG.todo, ...(partial.todo ?? {}) }`
262
+ * and `{ ...current.todo, ...(patch.todo ?? {}) }`), so a third scalar key is
263
+ * carried automatically and needs no change there. A NESTED field would not
264
+ * be, and would mean replacing both merges first.
265
+ */
266
+ followThrough: FollowThroughMode;
267
+ }
268
+ export declare const DEFAULT_TODO_CONFIG: TodoConfig;
269
+ /**
270
+ * THE single gate for every todo-config read AND write, mirroring
271
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` for the reason
272
+ * those record: hardening only the read path leaves a bad value on disk that
273
+ * reverts to the default on every launch, which presents to the user as "my
274
+ * setting won't stick".
275
+ */
276
+ export declare function clampTodoConfig(raw: unknown): TodoConfig;
242
277
  export interface PersistedConfig {
243
278
  version: number;
244
279
  provider: string;
@@ -305,6 +340,8 @@ export interface PersistedConfig {
305
340
  log: LogConfig;
306
341
  /** Team mode. The FOURTH nested object; scalars only (see TeamConfig). */
307
342
  team: TeamConfig;
343
+ /** Todo planning. The FIFTH nested object; scalars only (see TodoConfig). */
344
+ todo: TodoConfig;
308
345
  }
309
346
  export declare const DEFAULT_CONFIG: PersistedConfig;
310
347
  /**
@@ -399,6 +436,16 @@ export interface CliConfig {
399
436
  * `/team` flips is a separate flag on the controller.
400
437
  */
401
438
  team: TeamConfig;
439
+ /**
440
+ * Resolved todo-planning settings (todo-plan-execution §4.2).
441
+ *
442
+ * `enabled` here decides whether `todo_write` is REGISTERED, which happens
443
+ * once at construction and can never be undone (C-1). The live on/off switch
444
+ * `/todo` flips is a separate flag on the controller; `panel` is read at
445
+ * RENDER time through `controller.getConfig()`, which is why `/todo panel`
446
+ * must call `setTodoConfig` as well as persisting (P1-2).
447
+ */
448
+ todo: TodoConfig;
402
449
  /**
403
450
  * Lifetime submit count driving the composer hint fade (`HINT_FADE_AFTER`).
404
451
  *
@@ -312,6 +312,40 @@ export function clampTeamConfig(raw) {
312
312
  maxTurnsPerSubagent: clampInt(src.maxTurnsPerSubagent, DEFAULT_TEAM_CONFIG.maxTurnsPerSubagent, TURNS_RANGE),
313
313
  };
314
314
  }
315
+ export const DEFAULT_TODO_CONFIG = {
316
+ enabled: true,
317
+ panel: true,
318
+ followThrough: 'notify',
319
+ };
320
+ /**
321
+ * There is no enum helper in this file, so this follows the `isThinkingLevel`
322
+ * shape already here rather than inventing a second idiom.
323
+ */
324
+ const FOLLOW_THROUGH_MODES = ['notify', 'auto', 'off'];
325
+ function followMode(value, fallback) {
326
+ return typeof value === 'string' && FOLLOW_THROUGH_MODES.includes(value)
327
+ ? value
328
+ : fallback;
329
+ }
330
+ /**
331
+ * THE single gate for every todo-config read AND write, mirroring
332
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` for the reason
333
+ * those record: hardening only the read path leaves a bad value on disk that
334
+ * reverts to the default on every launch, which presents to the user as "my
335
+ * setting won't stick".
336
+ */
337
+ export function clampTodoConfig(raw) {
338
+ const src = (raw && typeof raw === 'object' ? raw : {});
339
+ return {
340
+ enabled: bool(src.enabled, DEFAULT_TODO_CONFIG.enabled),
341
+ panel: bool(src.panel, DEFAULT_TODO_CONFIG.panel),
342
+ // CLAMPED TO THE DEFAULT, NEVER REJECTED — the discipline every other key in
343
+ // this file follows, and the trade round 1 already made for `--thinking`.
344
+ // `ARAGON_TODO_FOLLOW=garbage` therefore resolves to `'notify'` and throws
345
+ // nothing (AC-5).
346
+ followThrough: followMode(src.followThrough, DEFAULT_TODO_CONFIG.followThrough),
347
+ };
348
+ }
315
349
  export const DEFAULT_CONFIG = {
316
350
  version: CONFIG_VERSION,
317
351
  provider: 'anthropic',
@@ -340,6 +374,10 @@ export const DEFAULT_CONFIG = {
340
374
  skills: DEFAULT_SKILLS_CONFIG,
341
375
  log: DEFAULT_LOG_CONFIG,
342
376
  team: DEFAULT_TEAM_CONFIG,
377
+ // The load path's first spread is what supplies the section to a user with no
378
+ // `todo` key at all, so this line is as required as the two merges in
379
+ // `store.ts` (P0-1).
380
+ todo: DEFAULT_TODO_CONFIG,
343
381
  };
344
382
  /**
345
383
  * Runtime state that used to be stored in `config.json` by mistake
@@ -14,7 +14,7 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampMaxTokens, clampSkillsConfig, clampTeamConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampMaxTokens, clampSkillsConfig, clampTeamConfig, clampTodoConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
18
  /**
19
19
  * Normalize `maxTokens` for both directions of the config file.
20
20
  *
@@ -122,6 +122,10 @@ export function loadPersistedConfig() {
122
122
  log: clampLogConfig({ ...DEFAULT_CONFIG.log, ...(partial.log ?? {}) }),
123
123
  // `team` is the fourth. Same one-level rule, same merge.
124
124
  team: clampTeamConfig({ ...DEFAULT_CONFIG.team, ...(partial.team ?? {}) }),
125
+ // `todo` is the fifth. Same one-level rule, same merge — and this line is
126
+ // the READ half of the pair that keeps `/todo panel off` from silently
127
+ // unregistering the tool on the next launch (P0-1 / AC-35).
128
+ todo: clampTodoConfig({ ...DEFAULT_CONFIG.todo, ...(partial.todo ?? {}) }),
125
129
  };
126
130
  }
127
131
  /**
@@ -168,6 +172,11 @@ export function updatePersistedConfig(patch) {
168
172
  // 4` sends `{ team: { maxSubagents: 4 } }`, which a shallow spread would
169
173
  // turn into "changing the fan-out width silently reverted every timeout".
170
174
  team: clampTeamConfig({ ...current.team, ...(patch.team ?? {}) }),
175
+ // The fifth section, and its omission is the worst of the five (P0-1):
176
+ // `/todo panel off` sends `{ todo: { panel: false } }`, which a shallow
177
+ // spread turns into "turning the panel off silently unregisters the tool",
178
+ // permanently, from the next launch onward, with nothing anywhere saying so.
179
+ todo: clampTodoConfig({ ...current.todo, ...(patch.todo ?? {}) }),
171
180
  };
172
181
  writeConfigFile(merged);
173
182
  return merged;