@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
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `normalizeTodos` — pure repair of whatever arrived in a `todo_write` call, or
3
+ * of whatever a session file happened to contain (todo-plan-execution §3.4).
4
+ *
5
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope.
6
+ *
7
+ * MODELLED ON `team/normalize.ts`, INCLUDING THE REASON IT EXISTS. The JSON
8
+ * Schema declares SHAPE, not POLICY (C-8): `ajv` is an OPTIONAL dependency of
9
+ * `@aragon-agent/core`, so a schema-level `maxItems` / `enum` would REJECT a
10
+ * payload where it is installed and let the identical payload through to here
11
+ * where it is not. One design, two behaviours, selected by whether an optional
12
+ * install step succeeded. Every bound therefore lives in the tool's
13
+ * `description` (which is what the model reads) and is enforced here (which is
14
+ * deterministic and unit-tested).
15
+ *
16
+ * REPAIR, NEVER REJECT. Zero survivors is the caller's single hard failure.
17
+ * NEVER THROWS.
18
+ *
19
+ * THE LAST TWO RULES ARE THE FEATURE, not defensive tidying. "Exactly one item
20
+ * at a time" (R-b) is made a property of the STORED STATE rather than a request
21
+ * in a prompt, which is what lets the panel render an unconditional "current
22
+ * step" with no null branch and lets a unit test prove the property instead of
23
+ * an operator inferring it from a transcript.
24
+ */
25
+ import { TODO_LIMITS } from './limits.js';
26
+ const STATUSES = new Set([
27
+ 'pending',
28
+ 'in_progress',
29
+ 'completed',
30
+ ]);
31
+ /**
32
+ * Collapse whitespace and clamp.
33
+ *
34
+ * SANITIZED AT STORE RATHER THAN AT RENDER, which is the argument
35
+ * `sanitizeActivity` records one subsystem over: a newline inside a rail row
36
+ * would break the layout, and doing it here means the transcript card, the
37
+ * headless writer and `/todo status` all inherit the guarantee for free.
38
+ */
39
+ function clean(value, maxChars) {
40
+ if (typeof value !== 'string')
41
+ return '';
42
+ const collapsed = value.replace(/\s+/g, ' ').trim();
43
+ return collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed;
44
+ }
45
+ export function normalizeTodos(raw, max) {
46
+ if (!Array.isArray(raw))
47
+ return { items: [], requested: 0, repairs: [] };
48
+ const requested = raw.length;
49
+ const repairs = [];
50
+ const kept = [];
51
+ let dropped = 0;
52
+ for (const entry of raw) {
53
+ if (!entry || typeof entry !== 'object') {
54
+ dropped += 1;
55
+ continue;
56
+ }
57
+ const src = entry;
58
+ const content = clean(src.content, TODO_LIMITS.contentChars);
59
+ // The one field with no repair: an item with nothing to say is not an item.
60
+ if (content.length === 0) {
61
+ dropped += 1;
62
+ continue;
63
+ }
64
+ const activeForm = clean(src.activeForm, TODO_LIMITS.activeFormChars) || content;
65
+ const status = (typeof src.status === 'string' && STATUSES.has(src.status) ? src.status : 'pending');
66
+ kept.push({ content, activeForm, status });
67
+ }
68
+ if (dropped > 0) {
69
+ repairs.push(`${dropped} ${dropped === 1 ? 'item was' : 'items were'} dropped: each needs a non-empty content string.`);
70
+ }
71
+ // DROP FIRST, CAP SECOND — the ordering `normalizeSubagentSpecs` records:
72
+ // capping first would let a blank entry consume a slot the model meant for
73
+ // real work, and nothing would say so.
74
+ const ceiling = Math.max(1, Math.min(Math.floor(max) || 1, TODO_LIMITS.maxItems));
75
+ const items = kept.slice(0, ceiling);
76
+ if (kept.length > ceiling) {
77
+ // COUNTED OFF `kept`, NOT `requested`, because the two differ exactly when
78
+ // the payload ALSO had unusable entries — and those were already reported
79
+ // by the note above. `requested - ceiling` would charge the cap for them a
80
+ // second time and, worse, name a number that is not true of any suffix:
81
+ // "the last 5 were dropped" when the cap took 3 from the end and two blanks
82
+ // came from the middle. The model reads these notes to correct itself, so
83
+ // the arithmetic has to describe what actually happened.
84
+ repairs.push(`${requested} items were sent; the last ${kept.length - ceiling} were dropped (maximum ${ceiling}).`);
85
+ }
86
+ repairExactlyOneActive(items, repairs);
87
+ return { items, requested, repairs };
88
+ }
89
+ /**
90
+ * Enforce invariant I-3: exactly one item is `in_progress` whenever at least one
91
+ * item is not `completed`.
92
+ *
93
+ * The promotion half deliberately fires on the INITIAL all-pending write. A
94
+ * model that writes its plan and then starts step 1 is doing the right thing,
95
+ * and the panel should say so on the first frame rather than one tool call
96
+ * later.
97
+ */
98
+ function repairExactlyOneActive(items, repairs) {
99
+ const active = [];
100
+ for (let i = 0; i < items.length; i += 1) {
101
+ if (items[i].status === 'in_progress')
102
+ active.push(i);
103
+ }
104
+ if (active.length > 1) {
105
+ for (const index of active.slice(1)) {
106
+ items[index] = { ...items[index], status: 'pending' };
107
+ }
108
+ repairs.push(`${active.length} items were marked in_progress; only the first was kept.`);
109
+ return;
110
+ }
111
+ if (active.length === 1)
112
+ return;
113
+ const firstUnfinished = items.findIndex((item) => item.status !== 'completed');
114
+ if (firstUnfinished >= 0) {
115
+ items[firstUnfinished] = { ...items[firstUnfinished], status: 'in_progress' };
116
+ }
117
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `selectTodoRows` — which items the rail shows (todo-plan-execution §3.10).
3
+ *
4
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE WINDOWING, NO OFFSET STATE ANYWHERE (non-goal 2 / R-10). Ink has no
7
+ * absolute positioning and this codebase already owns one hand-rolled viewport
8
+ * (`ScrollViewport`) whose invariants took a whole spec to pin down; a second
9
+ * scrollable region with its own offset, its own intent nonce and its own wheel
10
+ * band would be the largest source of new bugs in this feature by a wide margin.
11
+ * The rail therefore never scrolls - it windows around the item being worked on
12
+ * and reports what is off each end.
13
+ *
14
+ * THE DIRECT DESCENDANT OF `selectPanelRows`'s F-2 LESSON: the team panel
15
+ * originally took the first five children by ARRAY INDEX, which showed the five
16
+ * that finished FIRST and hid everything still working. Here the equivalent
17
+ * mistake is `slice(0, maxRows)`, which on a 20-item plan shows steps 1-8 - all
18
+ * completed - and hides the one the user is actually watching.
19
+ */
20
+ import type { TodoItem } from './types.js';
21
+ export interface TodoRowSelection {
22
+ visible: {
23
+ item: TodoItem;
24
+ index: number;
25
+ }[];
26
+ hiddenAbove: number;
27
+ hiddenBelow: number;
28
+ }
29
+ /**
30
+ * The row the window is built around: the item in progress, or the first
31
+ * unfinished one, or the last item when everything is done.
32
+ *
33
+ * `normalizeTodos` guarantees an `in_progress` item whenever one is not
34
+ * completed (I-3), so the second branch is a belt-and-braces path for a list
35
+ * this module was handed without going through the store.
36
+ */
37
+ export declare function todoAnchorIndex(items: TodoItem[]): number;
38
+ /**
39
+ * @param maxRows Rows available for the item rows AND the two overflow markers
40
+ * together. `todoRailRows()` upstream is what makes this number
41
+ * honest while a team dispatch is live (I-10 / P1-3).
42
+ *
43
+ * THE ANCHOR IS ALWAYS VISIBLE, and that beats the row bound when the two
44
+ * conflict. They can only conflict below `maxRows` 3 with overflow on BOTH
45
+ * sides — one item plus two markers is three rows — where this returns the
46
+ * anchor and lets the caller's `overflow="hidden"` clip a marker. Hiding the
47
+ * one row the user is watching in order to keep a `+N above` would be the wrong
48
+ * trade. `TODO_LIMITS.panelMinRows` (6) gates the mount and leaves `TodoPanel`
49
+ * at least 4 item rows, so the case is unreachable from the rail; a future
50
+ * caller with a smaller budget should read this paragraph before assuming
51
+ * otherwise.
52
+ */
53
+ export declare function selectTodoRows(items: TodoItem[], maxRows: number): TodoRowSelection;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `selectTodoRows` — which items the rail shows (todo-plan-execution §3.10).
3
+ *
4
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE WINDOWING, NO OFFSET STATE ANYWHERE (non-goal 2 / R-10). Ink has no
7
+ * absolute positioning and this codebase already owns one hand-rolled viewport
8
+ * (`ScrollViewport`) whose invariants took a whole spec to pin down; a second
9
+ * scrollable region with its own offset, its own intent nonce and its own wheel
10
+ * band would be the largest source of new bugs in this feature by a wide margin.
11
+ * The rail therefore never scrolls - it windows around the item being worked on
12
+ * and reports what is off each end.
13
+ *
14
+ * THE DIRECT DESCENDANT OF `selectPanelRows`'s F-2 LESSON: the team panel
15
+ * originally took the first five children by ARRAY INDEX, which showed the five
16
+ * that finished FIRST and hid everything still working. Here the equivalent
17
+ * mistake is `slice(0, maxRows)`, which on a 20-item plan shows steps 1-8 - all
18
+ * completed - and hides the one the user is actually watching.
19
+ */
20
+ /**
21
+ * The row the window is built around: the item in progress, or the first
22
+ * unfinished one, or the last item when everything is done.
23
+ *
24
+ * `normalizeTodos` guarantees an `in_progress` item whenever one is not
25
+ * completed (I-3), so the second branch is a belt-and-braces path for a list
26
+ * this module was handed without going through the store.
27
+ */
28
+ export function todoAnchorIndex(items) {
29
+ if (items.length === 0)
30
+ return 0;
31
+ const active = items.findIndex((item) => item.status === 'in_progress');
32
+ if (active >= 0)
33
+ return active;
34
+ const unfinished = items.findIndex((item) => item.status !== 'completed');
35
+ if (unfinished >= 0)
36
+ return unfinished;
37
+ return items.length - 1;
38
+ }
39
+ /**
40
+ * How much of the window sits ABOVE the anchor.
41
+ *
42
+ * A QUARTER, and the asymmetry is the point: completed context above the anchor
43
+ * is sacrificed before upcoming work below it, because the user's question is
44
+ * "what is next", not "what is behind me".
45
+ */
46
+ const ABOVE_SHARE = 0.25;
47
+ function windowFor(count, anchor, slots) {
48
+ if (slots >= count)
49
+ return { start: 0, end: count };
50
+ const above = Math.min(anchor, Math.max(0, Math.floor((slots - 1) * ABOVE_SHARE)));
51
+ let start = anchor - above;
52
+ let end = start + slots;
53
+ if (end > count) {
54
+ end = count;
55
+ start = Math.max(0, end - slots);
56
+ }
57
+ return { start, end };
58
+ }
59
+ /**
60
+ * @param maxRows Rows available for the item rows AND the two overflow markers
61
+ * together. `todoRailRows()` upstream is what makes this number
62
+ * honest while a team dispatch is live (I-10 / P1-3).
63
+ *
64
+ * THE ANCHOR IS ALWAYS VISIBLE, and that beats the row bound when the two
65
+ * conflict. They can only conflict below `maxRows` 3 with overflow on BOTH
66
+ * sides — one item plus two markers is three rows — where this returns the
67
+ * anchor and lets the caller's `overflow="hidden"` clip a marker. Hiding the
68
+ * one row the user is watching in order to keep a `+N above` would be the wrong
69
+ * trade. `TODO_LIMITS.panelMinRows` (6) gates the mount and leaves `TodoPanel`
70
+ * at least 4 item rows, so the case is unreachable from the rail; a future
71
+ * caller with a smaller budget should read this paragraph before assuming
72
+ * otherwise.
73
+ */
74
+ export function selectTodoRows(items, maxRows) {
75
+ const count = items.length;
76
+ const anchor = todoAnchorIndex(items);
77
+ if (count === 0)
78
+ return { visible: [], hiddenAbove: 0, hiddenBelow: 0 };
79
+ if (maxRows <= 0) {
80
+ return { visible: [], hiddenAbove: anchor, hiddenBelow: count - 1 - anchor };
81
+ }
82
+ // The marker rows and the window depend on each other: a `+N above` row costs
83
+ // a slot, which can shrink the window, which can create a `+N below`. Two
84
+ // passes reach the fixpoint for every input this can be handed; the loop bound
85
+ // is a backstop, not a live case.
86
+ let reserved = 0;
87
+ let start = 0;
88
+ let end = count;
89
+ for (let pass = 0; pass < 3; pass += 1) {
90
+ const slots = Math.max(1, maxRows - reserved);
91
+ const win = windowFor(count, anchor, slots);
92
+ start = win.start;
93
+ end = win.end;
94
+ const need = (start > 0 ? 1 : 0) + (end < count ? 1 : 0);
95
+ if (need === reserved)
96
+ break;
97
+ reserved = need;
98
+ }
99
+ const visible = [];
100
+ for (let i = start; i < end; i += 1)
101
+ visible.push({ item: items[i], index: i });
102
+ return { visible, hiddenAbove: start, hiddenBelow: count - end };
103
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The `<todo_planning>` system-prompt block (todo-plan-execution §3.7).
3
+ *
4
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope, and this string
5
+ * also reaches a model that may be running in a `cmd.exe` terminal.
6
+ *
7
+ * SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what preserves C-10:
8
+ * with `todo.enabled: false` the prompt is BYTE-IDENTICAL to the pre-todo output
9
+ * for a fixed tool array. An unconditional guidance line anywhere here would
10
+ * quietly break `--no-todo`, `--no-team`, `--no-skills` and the plan-mode
11
+ * snapshot all at once.
12
+ *
13
+ * THE BLOCK'S THRESHOLD (3+ steps) IS STRICTER THAN THE STRUCTURAL GUARD
14
+ * (`minFreshItems`, 2). A backstop looser than the guidance never argues with a
15
+ * judgement call the model made on purpose: a 2-step list for two genuinely
16
+ * separate deliverables is fine, and the prompt simply does not encourage it.
17
+ *
18
+ * `TODO_BLOCK_VERSION` exists so a change to this wording is greppable from a
19
+ * behaviour report - INCLUDING a change to either `{{VISIBILITY}}` variant,
20
+ * which is part of the block.
21
+ */
22
+ import { TODO_BLOCK_VERSION } from './limits.js';
23
+ export { TODO_BLOCK_VERSION };
24
+ export interface TodoBlockParams {
25
+ /**
26
+ * Whether this session can actually render the rail.
27
+ *
28
+ * FOUR SUPPORTED PATHS HAVE THE TOOL AND NO PANEL — `-p` (§3.12), inline mode
29
+ * (non-goal 4), `--no-todo-panel` (§4.3), and a terminal under 80 columns or 6
30
+ * viewport rows (§6.4) — and in the first three the answer is fixed for the
31
+ * whole session, before the prompt is composed. Telling the model about a
32
+ * panel the user does not have is the same error `composeSystemPrompt` already
33
+ * guards against for team mode with its TWO flags (P1-6 / D-24).
34
+ *
35
+ * Dropping the block instead would be wrong: the planning discipline is worth
36
+ * having without the column, which is precisely why `enabled` and `panel` are
37
+ * separate config keys, and a screen-reader user is the case that argument was
38
+ * written for.
39
+ */
40
+ panelVisible: boolean;
41
+ }
42
+ /**
43
+ * BOUNDED AT 1600 CHARACTERS, and the number is asserted in `todo-tool.test.ts`.
44
+ *
45
+ * The block is paid for on EVERY turn of every todo-enabled session, so anything
46
+ * that does not change a planning decision does not belong in it (the discipline
47
+ * `buildTeamBlock` records at 1200 for a shorter block). This one lands at ~1430
48
+ * as designed — three bullet lists plus the `{{VISIBILITY}}` sentence — and was
49
+ * NOT trimmed to reach a borrowed budget, because every line in it changes a
50
+ * planning decision, which is the test that actually applies. The bound exists
51
+ * so that adding a paragraph stays a decision someone makes on purpose. See
52
+ * spec §15 IF-3.
53
+ */
54
+ export declare function buildTodoBlock(params: TodoBlockParams): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `<todo_planning>` system-prompt block (todo-plan-execution §3.7).
3
+ *
4
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope, and this string
5
+ * also reaches a model that may be running in a `cmd.exe` terminal.
6
+ *
7
+ * SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what preserves C-10:
8
+ * with `todo.enabled: false` the prompt is BYTE-IDENTICAL to the pre-todo output
9
+ * for a fixed tool array. An unconditional guidance line anywhere here would
10
+ * quietly break `--no-todo`, `--no-team`, `--no-skills` and the plan-mode
11
+ * snapshot all at once.
12
+ *
13
+ * THE BLOCK'S THRESHOLD (3+ steps) IS STRICTER THAN THE STRUCTURAL GUARD
14
+ * (`minFreshItems`, 2). A backstop looser than the guidance never argues with a
15
+ * judgement call the model made on purpose: a 2-step list for two genuinely
16
+ * separate deliverables is fine, and the prompt simply does not encourage it.
17
+ *
18
+ * `TODO_BLOCK_VERSION` exists so a change to this wording is greppable from a
19
+ * behaviour report - INCLUDING a change to either `{{VISIBILITY}}` variant,
20
+ * which is part of the block.
21
+ */
22
+ import { TODO_BLOCK_VERSION } from './limits.js';
23
+ export { TODO_BLOCK_VERSION };
24
+ /**
25
+ * The ONLY sentence in the block that mentions a display at all. Everything else
26
+ * talks about "the list", which is what makes "the two variants differ in
27
+ * exactly one sentence" checkable rather than approximate (AC-42).
28
+ */
29
+ const VISIBLE = 'The user sees this list in a panel beside the conversation, so it is the only place ' +
30
+ 'they can watch a long task progress.';
31
+ const NOT_VISIBLE = 'The user cannot see this list in this session, but keeping it is still how you stay ' +
32
+ 'on one step at a time and report what is left.';
33
+ /**
34
+ * BOUNDED AT 1600 CHARACTERS, and the number is asserted in `todo-tool.test.ts`.
35
+ *
36
+ * The block is paid for on EVERY turn of every todo-enabled session, so anything
37
+ * that does not change a planning decision does not belong in it (the discipline
38
+ * `buildTeamBlock` records at 1200 for a shorter block). This one lands at ~1430
39
+ * as designed — three bullet lists plus the `{{VISIBILITY}}` sentence — and was
40
+ * NOT trimmed to reach a borrowed budget, because every line in it changes a
41
+ * planning decision, which is the test that actually applies. The bound exists
42
+ * so that adding a paragraph stays a decision someone makes on purpose. See
43
+ * spec §15 IF-3.
44
+ */
45
+ export function buildTodoBlock(params) {
46
+ return [
47
+ '<todo_planning>',
48
+ 'For work that takes three or more distinct steps, keep a visible plan with the',
49
+ 'todo_write tool.',
50
+ params.panelVisible ? VISIBLE : NOT_VISIBLE,
51
+ '',
52
+ 'Use it when:',
53
+ '- the request has three or more steps that must happen in order;',
54
+ '- the user gave you several things to do in one message;',
55
+ '- you are part-way through a long task and the user asks what is left.',
56
+ '',
57
+ 'Do NOT use it for:',
58
+ '- anything you can finish in one or two steps - just do it and say what you did;',
59
+ '- a question, an explanation, or a search with no work attached;',
60
+ '- restating a step you are already in the middle of.',
61
+ '',
62
+ 'How to use it:',
63
+ '- Send the COMPLETE list every time. The tool replaces the whole list; it has no',
64
+ ' add or update operation.',
65
+ '- Exactly one item is in_progress at a time. Mark an item completed the moment',
66
+ ' it is done and mark the next one in_progress in the SAME call.',
67
+ '- Do not batch. Finishing three steps and reporting them together hides where you',
68
+ ' are RIGHT NOW, which is the whole point of keeping the list.',
69
+ '- Give each item a content ("Add the rail to AppShell") and an activeForm',
70
+ ' ("Adding the rail to AppShell"). The list shows activeForm for the step in',
71
+ ' progress and content for the rest.',
72
+ '- Only mark an item completed when it is really finished. If you hit a blocker,',
73
+ ' leave it in_progress, add an item describing what is blocking, and say so.',
74
+ '</todo_planning>',
75
+ ].join('\n');
76
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `TodoStore` — the session's todo list plus its CLI-local event stream
3
+ * (todo-plan-execution §3.5).
4
+ *
5
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope.
6
+ *
7
+ * ZERO I/O OF ANY KIND, and no timers. That is what lets `todo_write` be the one
8
+ * tool in the array with no error path beyond a malformed payload (R-h), and it
9
+ * is why `AgentController` can own this object as unconditionally as it owns
10
+ * `TeamRuntime`.
11
+ *
12
+ * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
+ * nothing that does not tell the model may change it. `/clear` wipes the
14
+ * transcript but not the conversation, so it must NOT reach this class;
15
+ * `/reset` clears `messages`, so it must. Every write is a FULL REPLACEMENT and
16
+ * every clear is a FULL REMOVAL - there is no partial update anywhere, which is
17
+ * what makes I-9 ("the panel is never a contradiction of the model") provable
18
+ * rather than argued.
19
+ */
20
+ import { type NormalizeResult } from './normalize.js';
21
+ import type { TodoClearReason, TodoEventListener, TodoSnapshot } from './types.js';
22
+ export declare class TodoStore {
23
+ private readonly now;
24
+ private items;
25
+ private updatedAt;
26
+ /** Consecutive `beginUserTurn()` calls since the last `write()`. */
27
+ private turnsSinceWrite;
28
+ private readonly listeners;
29
+ /**
30
+ * @param now Injected clock. Defaults to `Date.now`; a test passes a counter
31
+ * so `TodoSnapshot.updatedAt` is not wall time (P2-4).
32
+ */
33
+ constructor(now?: () => number);
34
+ /** `null` means no list, which is what leaves the rail unmounted entirely. */
35
+ snapshot(): TodoSnapshot | null;
36
+ isEmpty(): boolean;
37
+ /**
38
+ * Commit a model payload.
39
+ *
40
+ * ZERO SURVIVORS LEAVES THE PREVIOUS LIST UNTOUCHED (AC-9). A model sending
41
+ * nothing usable is a BUG, and destroying a working plan because of one
42
+ * malformed call is the worst available reaction; the caller turns the empty
43
+ * result into an error the model can act on. Contrast `restore()`, where an
44
+ * empty list is a FACT about a file and therefore means "clear".
45
+ */
46
+ write(raw: unknown): NormalizeResult;
47
+ /**
48
+ * `/resume`. Runs through `normalizeTodos` — a session file is user-editable
49
+ * input and gets a model payload's treatment.
50
+ *
51
+ * AN EMPTY (OR ALL-DROPPED) ARRAY CLEARS; IT DOES NOT NO-OP (P0-2 / D-22).
52
+ * `/resume` has just called `replaceMessages`, so the conversation the current
53
+ * list belonged to is gone; leaving it on screen is I-2 INVERTED — a panel
54
+ * that disagrees with the model, which §1.2 ranks below having no panel at
55
+ * all. The two entry points share normalization and differ only here, which is
56
+ * exactly the difference between "the model sent nothing" (a bug) and "the
57
+ * file has nothing" (a fact).
58
+ */
59
+ restore(items: unknown): void;
60
+ clear(reason: TodoClearReason): void;
61
+ /**
62
+ * The turn boundary (§3.2).
63
+ *
64
+ * Called from `AgentController.prompt()` and NOT from `steer()` — the same
65
+ * asymmetry, for the same reason, that `askRounds = 0` already has: a steer is
66
+ * a mid-turn interjection, not a new task. The stale counter inherits that
67
+ * asymmetry for free, which is the behaviour you want: a steer must not age
68
+ * out the plan it is steering.
69
+ *
70
+ * A FULLY-COMPLETED LIST IS DROPPED HERE rather than at `agent_end`. Ending
71
+ * the run is exactly when the user wants to see `7/7 done`; wiping it there
72
+ * would replace a moment of feedback with a blank column (D-6).
73
+ */
74
+ beginUserTurn(): void;
75
+ /**
76
+ * Report a refusal that never reached `write()` (§3.3 steps 1 and 2).
77
+ *
78
+ * THE COMPENSATING PATH FOR SUPPRESSING THE TOOL CARD (§3.8): `todo_write`
79
+ * renders its own transcript entry, so without this the one case that produces
80
+ * nothing visible would be the case that most needs to be visible.
81
+ */
82
+ reject(reason: string): void;
83
+ subscribe(listener: TodoEventListener): () => void;
84
+ /** Per-listener isolation: one bad subscriber cannot fail a tool call. */
85
+ private emit;
86
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `TodoStore` — the session's todo list plus its CLI-local event stream
3
+ * (todo-plan-execution §3.5).
4
+ *
5
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope.
6
+ *
7
+ * ZERO I/O OF ANY KIND, and no timers. That is what lets `todo_write` be the one
8
+ * tool in the array with no error path beyond a malformed payload (R-h), and it
9
+ * is why `AgentController` can own this object as unconditionally as it owns
10
+ * `TeamRuntime`.
11
+ *
12
+ * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
+ * nothing that does not tell the model may change it. `/clear` wipes the
14
+ * transcript but not the conversation, so it must NOT reach this class;
15
+ * `/reset` clears `messages`, so it must. Every write is a FULL REPLACEMENT and
16
+ * every clear is a FULL REMOVAL - there is no partial update anywhere, which is
17
+ * what makes I-9 ("the panel is never a contradiction of the model") provable
18
+ * rather than argued.
19
+ */
20
+ import { getLogger } from '../logging/logger.js';
21
+ import { TODO_LIMITS } from './limits.js';
22
+ import { normalizeTodos } from './normalize.js';
23
+ export class TodoStore {
24
+ now;
25
+ items = [];
26
+ updatedAt = 0;
27
+ /** Consecutive `beginUserTurn()` calls since the last `write()`. */
28
+ turnsSinceWrite = 0;
29
+ listeners = new Set();
30
+ /**
31
+ * @param now Injected clock. Defaults to `Date.now`; a test passes a counter
32
+ * so `TodoSnapshot.updatedAt` is not wall time (P2-4).
33
+ */
34
+ constructor(now = Date.now) {
35
+ this.now = now;
36
+ }
37
+ /** `null` means no list, which is what leaves the rail unmounted entirely. */
38
+ snapshot() {
39
+ if (this.items.length === 0)
40
+ return null;
41
+ return {
42
+ items: this.items,
43
+ total: this.items.length,
44
+ doneCount: this.items.filter((item) => item.status === 'completed').length,
45
+ activeIndex: this.items.findIndex((item) => item.status === 'in_progress'),
46
+ updatedAt: this.updatedAt,
47
+ };
48
+ }
49
+ isEmpty() {
50
+ return this.items.length === 0;
51
+ }
52
+ /**
53
+ * Commit a model payload.
54
+ *
55
+ * ZERO SURVIVORS LEAVES THE PREVIOUS LIST UNTOUCHED (AC-9). A model sending
56
+ * nothing usable is a BUG, and destroying a working plan because of one
57
+ * malformed call is the worst available reaction; the caller turns the empty
58
+ * result into an error the model can act on. Contrast `restore()`, where an
59
+ * empty list is a FACT about a file and therefore means "clear".
60
+ */
61
+ write(raw) {
62
+ const result = normalizeTodos(raw, TODO_LIMITS.maxItems);
63
+ if (result.items.length === 0) {
64
+ this.emit({
65
+ type: 'rejected',
66
+ reason: 'No usable todo items: each needs a non-empty content string.',
67
+ });
68
+ return result;
69
+ }
70
+ this.items = result.items;
71
+ this.updatedAt = this.now();
72
+ this.turnsSinceWrite = 0;
73
+ const snapshot = this.snapshot();
74
+ // COUNTS ONLY, NEVER ITEM TEXT (D-19 / §3.14). It is user-task content, it
75
+ // is unbounded in aggregate, and the counts answer every question a log can
76
+ // answer here: did the list move, and was the payload repaired?
77
+ //
78
+ // SCOPE `'agent'`, NOT `'todo'`. §3.14 writes `'todo'`, but `LogScope` is a
79
+ // CLOSED UNION in `logging/logger.ts` and that file is not in the change
80
+ // plan; team mode faced the identical choice and files its CLI-local events
81
+ // under `'agent'` (`install.ts::attachTeamEvents`). Following that precedent
82
+ // keeps the diff focused and keeps a support reader filtering on one scope
83
+ // for everything a turn did. See spec §15 IF-1.
84
+ getLogger().debug('agent', 'todo_write', {
85
+ total: snapshot?.total ?? 0,
86
+ done: snapshot?.doneCount ?? 0,
87
+ active: snapshot?.activeIndex ?? -1,
88
+ repairs: result.repairs.length,
89
+ });
90
+ if (snapshot)
91
+ this.emit({ type: 'updated', snapshot });
92
+ return result;
93
+ }
94
+ /**
95
+ * `/resume`. Runs through `normalizeTodos` — a session file is user-editable
96
+ * input and gets a model payload's treatment.
97
+ *
98
+ * AN EMPTY (OR ALL-DROPPED) ARRAY CLEARS; IT DOES NOT NO-OP (P0-2 / D-22).
99
+ * `/resume` has just called `replaceMessages`, so the conversation the current
100
+ * list belonged to is gone; leaving it on screen is I-2 INVERTED — a panel
101
+ * that disagrees with the model, which §1.2 ranks below having no panel at
102
+ * all. The two entry points share normalization and differ only here, which is
103
+ * exactly the difference between "the model sent nothing" (a bug) and "the
104
+ * file has nothing" (a fact).
105
+ */
106
+ restore(items) {
107
+ const result = normalizeTodos(items, TODO_LIMITS.maxItems);
108
+ if (result.items.length === 0) {
109
+ this.clear('reset');
110
+ return;
111
+ }
112
+ this.items = result.items;
113
+ this.updatedAt = this.now();
114
+ this.turnsSinceWrite = 0;
115
+ const snapshot = this.snapshot();
116
+ if (snapshot)
117
+ this.emit({ type: 'updated', snapshot });
118
+ }
119
+ clear(reason) {
120
+ this.items = [];
121
+ this.updatedAt = this.now();
122
+ this.turnsSinceWrite = 0;
123
+ this.emit({ type: 'cleared', reason });
124
+ }
125
+ /**
126
+ * The turn boundary (§3.2).
127
+ *
128
+ * Called from `AgentController.prompt()` and NOT from `steer()` — the same
129
+ * asymmetry, for the same reason, that `askRounds = 0` already has: a steer is
130
+ * a mid-turn interjection, not a new task. The stale counter inherits that
131
+ * asymmetry for free, which is the behaviour you want: a steer must not age
132
+ * out the plan it is steering.
133
+ *
134
+ * A FULLY-COMPLETED LIST IS DROPPED HERE rather than at `agent_end`. Ending
135
+ * the run is exactly when the user wants to see `7/7 done`; wiping it there
136
+ * would replace a moment of feedback with a blank column (D-6).
137
+ */
138
+ beginUserTurn() {
139
+ if (this.items.length === 0)
140
+ return;
141
+ if (this.items.every((item) => item.status === 'completed')) {
142
+ this.clear('turn');
143
+ return;
144
+ }
145
+ // ...but a partial list is not immortal (P1-7 / D-21). Without a bound, a
146
+ // plan abandoned mid-way holds a fifth of the terminal for the rest of the
147
+ // session, showing work nobody is doing - the "furniture when idle" R-g
148
+ // rules out. Self-healing, because the model's next write is a full
149
+ // replacement: the panel is ABSENT while the model still knows the plan,
150
+ // which is the safe direction (I-9).
151
+ this.turnsSinceWrite += 1;
152
+ if (this.turnsSinceWrite > TODO_LIMITS.staleTurns)
153
+ this.clear('stale');
154
+ }
155
+ /**
156
+ * Report a refusal that never reached `write()` (§3.3 steps 1 and 2).
157
+ *
158
+ * THE COMPENSATING PATH FOR SUPPRESSING THE TOOL CARD (§3.8): `todo_write`
159
+ * renders its own transcript entry, so without this the one case that produces
160
+ * nothing visible would be the case that most needs to be visible.
161
+ */
162
+ reject(reason) {
163
+ this.emit({ type: 'rejected', reason });
164
+ }
165
+ subscribe(listener) {
166
+ this.listeners.add(listener);
167
+ return () => {
168
+ this.listeners.delete(listener);
169
+ };
170
+ }
171
+ /** Per-listener isolation: one bad subscriber cannot fail a tool call. */
172
+ emit(event) {
173
+ for (const listener of [...this.listeners]) {
174
+ try {
175
+ listener(event);
176
+ }
177
+ catch {
178
+ // Swallowed on purpose. A UI subscriber that throws is a UI bug, and
179
+ // failing the model's tool call over it would be a worse one.
180
+ }
181
+ }
182
+ }
183
+ }