@aragon-agent/cli 0.6.35 → 0.6.36

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 (146) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3173 -3071
  3. package/dist/agent/agent-mode.d.ts +20 -4
  4. package/dist/agent/agent-mode.js +20 -4
  5. package/dist/agent/controller.d.ts +38 -3
  6. package/dist/agent/controller.js +64 -4
  7. package/dist/agent/headless.js +31 -0
  8. package/dist/agent/reducer.d.ts +13 -2
  9. package/dist/agent/reducer.js +1 -0
  10. package/dist/cli.js +12 -0
  11. package/dist/commands/agents-command.d.ts +15 -0
  12. package/dist/commands/agents-command.js +27 -0
  13. package/dist/commands/builtins.js +131 -4
  14. package/dist/commands/perf.d.ts +11 -0
  15. package/dist/commands/perf.js +2 -1
  16. package/dist/commands/registry.d.ts +14 -0
  17. package/dist/config/app-paths.d.ts +15 -0
  18. package/dist/config/app-paths.js +33 -2
  19. package/dist/config/env.d.ts +4 -1
  20. package/dist/config/env.js +28 -2
  21. package/dist/config/load.d.ts +10 -0
  22. package/dist/config/load.js +8 -1
  23. package/dist/config/schema.d.ts +68 -0
  24. package/dist/config/schema.js +39 -0
  25. package/dist/config/store.js +12 -1
  26. package/dist/config/ui-state.d.ts +18 -0
  27. package/dist/config/ui-state.js +9 -0
  28. package/dist/exec/events.d.ts +25 -0
  29. package/dist/exec/events.js +4 -0
  30. package/dist/exec/index.js +16 -1
  31. package/dist/exec/runner.js +10 -0
  32. package/dist/proc/limits.d.ts +16 -0
  33. package/dist/proc/limits.js +16 -0
  34. package/dist/proc/supervisor.d.ts +20 -1
  35. package/dist/proc/supervisor.js +42 -14
  36. package/dist/prompts/command.d.ts +34 -0
  37. package/dist/prompts/command.js +237 -0
  38. package/dist/prompts/copy.d.ts +89 -0
  39. package/dist/prompts/copy.js +131 -0
  40. package/dist/prompts/fill.d.ts +51 -0
  41. package/dist/prompts/fill.js +46 -0
  42. package/dist/prompts/limits.d.ts +53 -0
  43. package/dist/prompts/limits.js +53 -0
  44. package/dist/prompts/normalize.d.ts +53 -0
  45. package/dist/prompts/normalize.js +190 -0
  46. package/dist/prompts/resolve.d.ts +31 -0
  47. package/dist/prompts/resolve.js +60 -0
  48. package/dist/prompts/search.d.ts +58 -0
  49. package/dist/prompts/search.js +146 -0
  50. package/dist/prompts/store.d.ts +86 -0
  51. package/dist/prompts/store.js +453 -0
  52. package/dist/prompts/types.d.ts +204 -0
  53. package/dist/prompts/types.js +13 -0
  54. package/dist/session/checkpoint.d.ts +8 -1
  55. package/dist/session/checkpoint.js +6 -0
  56. package/dist/session/persist.d.ts +19 -1
  57. package/dist/session/persist.js +13 -1
  58. package/dist/session/startup-session.d.ts +7 -2
  59. package/dist/session/startup-session.js +5 -2
  60. package/dist/session/store.js +3 -0
  61. package/dist/session/validate-session.js +64 -1
  62. package/dist/team/limits.d.ts +25 -0
  63. package/dist/team/limits.js +26 -0
  64. package/dist/team/runtime.js +27 -2
  65. package/dist/team/settled.d.ts +57 -0
  66. package/dist/team/settled.js +87 -0
  67. package/dist/team/subagent.js +48 -2
  68. package/dist/team/timeline.d.ts +74 -0
  69. package/dist/team/timeline.js +101 -0
  70. package/dist/team/types.d.ts +24 -1
  71. package/dist/todo/acceptance.d.ts +120 -0
  72. package/dist/todo/acceptance.js +399 -0
  73. package/dist/todo/follow-through.d.ts +48 -23
  74. package/dist/todo/follow-through.js +110 -44
  75. package/dist/todo/limits.d.ts +62 -1
  76. package/dist/todo/limits.js +62 -1
  77. package/dist/todo/panel-rows.d.ts +29 -39
  78. package/dist/todo/panel-rows.js +37 -83
  79. package/dist/todo/plan-sections.d.ts +60 -0
  80. package/dist/todo/plan-sections.js +108 -0
  81. package/dist/todo/prompt.d.ts +17 -12
  82. package/dist/todo/prompt.js +59 -40
  83. package/dist/todo/row-window.d.ts +51 -0
  84. package/dist/todo/row-window.js +107 -0
  85. package/dist/todo/store.d.ts +139 -31
  86. package/dist/todo/store.js +271 -73
  87. package/dist/todo/todo-tool.d.ts +33 -8
  88. package/dist/todo/todo-tool.js +186 -30
  89. package/dist/todo/types.d.ts +58 -0
  90. package/dist/ui/App.js +310 -46
  91. package/dist/ui/Composer.d.ts +6 -2
  92. package/dist/ui/Composer.js +2 -2
  93. package/dist/ui/Header.d.ts +9 -1
  94. package/dist/ui/Header.js +2 -2
  95. package/dist/ui/PromptInput.d.ts +21 -1
  96. package/dist/ui/PromptInput.js +21 -1
  97. package/dist/ui/ScrollbackShell.d.ts +19 -19
  98. package/dist/ui/ScrollbackShell.js +8 -1
  99. package/dist/ui/StatusBar.d.ts +5 -0
  100. package/dist/ui/StatusBar.js +1 -0
  101. package/dist/ui/TeamPanel.d.ts +26 -44
  102. package/dist/ui/TeamPanel.js +46 -135
  103. package/dist/ui/TodoPanel.d.ts +17 -14
  104. package/dist/ui/TodoPanel.js +146 -38
  105. package/dist/ui/Transcript.js +1 -1
  106. package/dist/ui/composer-input.d.ts +10 -0
  107. package/dist/ui/composer-input.js +57 -24
  108. package/dist/ui/entries/TodoCard.d.ts +26 -11
  109. package/dist/ui/entries/TodoCard.js +56 -15
  110. package/dist/ui/glyphs.d.ts +19 -0
  111. package/dist/ui/glyphs.js +4 -0
  112. package/dist/ui/layout/agents-board.d.ts +102 -0
  113. package/dist/ui/layout/agents-board.js +389 -0
  114. package/dist/ui/layout/cells.d.ts +61 -0
  115. package/dist/ui/layout/cells.js +156 -0
  116. package/dist/ui/layout/header-hints.d.ts +13 -3
  117. package/dist/ui/layout/header-hints.js +48 -14
  118. package/dist/ui/layout/status-detail-layout.js +9 -0
  119. package/dist/ui/layout/status-layout.d.ts +13 -0
  120. package/dist/ui/layout/team-row.d.ts +139 -0
  121. package/dist/ui/layout/team-row.js +246 -0
  122. package/dist/ui/layout/todo-layout.d.ts +38 -0
  123. package/dist/ui/layout/todo-layout.js +75 -0
  124. package/dist/ui/layout/virtual-window.js +23 -3
  125. package/dist/ui/overlays/AgentsBoardOverlay.d.ts +57 -0
  126. package/dist/ui/overlays/AgentsBoardOverlay.js +183 -0
  127. package/dist/ui/overlays/HelpOverlay.js +17 -2
  128. package/dist/ui/overlays/PromptEditorPage.d.ts +48 -0
  129. package/dist/ui/overlays/PromptEditorPage.js +227 -0
  130. package/dist/ui/overlays/PromptLibraryOverlay.d.ts +37 -0
  131. package/dist/ui/overlays/PromptLibraryOverlay.js +191 -0
  132. package/dist/ui/overlays/PromptPicker.d.ts +36 -0
  133. package/dist/ui/overlays/PromptPicker.js +129 -0
  134. package/dist/ui/overlays/PromptTextArea.d.ts +86 -0
  135. package/dist/ui/overlays/PromptTextArea.js +141 -0
  136. package/dist/ui/overlays/prompt-library-state.d.ts +162 -0
  137. package/dist/ui/overlays/prompt-library-state.js +335 -0
  138. package/dist/ui/overlays/prompt-picker-state.d.ts +71 -0
  139. package/dist/ui/overlays/prompt-picker-state.js +98 -0
  140. package/dist/ui/scrollback/band.js +14 -1
  141. package/dist/ui/team-status.d.ts +25 -0
  142. package/dist/ui/team-status.js +31 -3
  143. package/dist/ui/use-startup-notices.d.ts +35 -1
  144. package/dist/ui/use-startup-notices.js +29 -2
  145. package/dist/ui/use-wheel-routing.js +9 -1
  146. package/package.json +2 -2
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The two stripping lists, and the two functions that apply them
3
+ * (tui-subagent-panel §4.5.4).
4
+ *
5
+ * WHY TWO LISTS AND NOT ONE. The fields fall into three groups, not two:
6
+ *
7
+ * - `activity` / `activityArgs` / `retry` / `timeline` have NO reader on a settled run
8
+ * (the card and the report read `summary` / `error` / the counters), so they are
9
+ * removed at the dispatch exit as well;
10
+ * - `lastIntervention` MUST survive onto the settled run -
11
+ * `overseer-runtime-supervision.test.ts` asserts it and the supervision report reads
12
+ * it - but `team/types.ts` marks it TRANSIENT / never persisted, and it is not part
13
+ * of `session/validate-session.ts`'s persisted shape either. So it is stripped at
14
+ * the SAVE boundary only;
15
+ * - `retryable`, `truncated`, `compactions`, `lastTool`, `summary`, `error` are facts
16
+ * that still mean something after the dispatch, and are in NEITHER list. Deleting
17
+ * `retryable` in passing would be the kind of "cleanup" that loses a contract.
18
+ *
19
+ * WHY TWO PLACES AND NOT ONE (§4.5.4 / RV-P0-1). `dispatch()`'s exit only cleans the
20
+ * FINAL outcome. Everything written to disk goes through `saveSession`, and during a
21
+ * dispatch the live `team` entry holds the LIVE runs - `teamUpdate` writes them
22
+ * straight into `view.entries`. So a `/save`, a checkpoint or the exit flush issued
23
+ * mid-dispatch would carry the timeline into `<id>.json` even with the dispatch exit
24
+ * perfectly clean. The save boundary is therefore the second, independent gate, and it
25
+ * is the one that protects all three write paths (`R-12`).
26
+ *
27
+ * WHY THIS IS NOT IN `runtime.ts` (RV-P1-4): that file is already past the 1,000-line
28
+ * ceiling `CLAUDE.md` sets and has no dated exception in `.claude-index/config.md`, so
29
+ * new logic goes here and `runtime.ts` keeps one call.
30
+ *
31
+ * ASCII ONLY: `src/team/**` is inside `glyphs.test.ts`'s static scan.
32
+ */
33
+ /** Fields with no reader on a settled run. Stripped at the dispatch exit. */
34
+ export const SETTLE_STRIP_FIELDS = ['activity', 'activityArgs', 'retry', 'timeline'];
35
+ /**
36
+ * Fields that may never reach a file. `lastIntervention` joins the four above because
37
+ * it must live on the settled run while staying out of every persisted one.
38
+ */
39
+ export const PERSIST_STRIP_FIELDS = [...SETTLE_STRIP_FIELDS, 'lastIntervention'];
40
+ /**
41
+ * Drop `fields` from one run, returning a NEW object - and the SAME object when there
42
+ * is nothing to drop, so a run's identity is preserved for reference equality where
43
+ * nothing changed (the `publish()` path compares nothing, but the reducer's
44
+ * `mapEntry` does dedupe on identity in some frames).
45
+ */
46
+ function stripRun(run, fields) {
47
+ let changed = false;
48
+ for (const field of fields) {
49
+ if (run[field] !== undefined) {
50
+ changed = true;
51
+ break;
52
+ }
53
+ }
54
+ if (!changed)
55
+ return run;
56
+ const copy = { ...run };
57
+ for (const field of fields)
58
+ delete copy[field];
59
+ return copy;
60
+ }
61
+ /**
62
+ * The dispatch exit: one run, without `SETTLE_STRIP_FIELDS`.
63
+ *
64
+ * `lastIntervention` is deliberately KEPT (see this file's header) - the settled card's
65
+ * badge and the supervision report both read it.
66
+ */
67
+ export function settledRun(run) {
68
+ return stripRun({ ...run, filesTouched: [...run.filesTouched] }, SETTLE_STRIP_FIELDS);
69
+ }
70
+ /**
71
+ * The save boundary: every `kind: 'team'` entry's runs, without
72
+ * `PERSIST_STRIP_FIELDS`.
73
+ *
74
+ * Entries that carry no team runs - or whose runs carry none of the fields - are
75
+ * returned UNTOUCHED, which is what keeps an ordinary session file byte-identical to
76
+ * the one this build produced before the feature.
77
+ */
78
+ export function stripEphemeralRuns(entries) {
79
+ return entries.map((entry) => {
80
+ if (entry.kind !== 'team')
81
+ return entry;
82
+ const runs = entry.runs.map((run) => stripRun(run, PERSIST_STRIP_FIELDS));
83
+ return runs.some((run, index) => run !== entry.runs[index])
84
+ ? { ...entry, runs }
85
+ : entry;
86
+ });
87
+ }
@@ -15,8 +15,9 @@ import { formatStreamError } from '../agent/reducer.js';
15
15
  import { buildSystemPrompt } from '../agent/system-prompt.js';
16
16
  import { buildFastBlock } from '../fast/prompt.js';
17
17
  import { createBuiltinTools } from '../tools/index.js';
18
- import { pickActivityArgs, sanitizeActivity } from './activity.js';
18
+ import { describeToolActivity, pickActivityArgs, sanitizeActivity } from './activity.js';
19
19
  import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
20
+ import { appendTimeline } from './timeline.js';
20
21
  import { isRetryableStreamError } from './retry.js';
21
22
  import { resolveSubagentKey, resolveSubagentModel } from './model.js';
22
23
  import { makeTeamSend, makeTeamWait, withMailboxTail } from './comm-tools.js';
@@ -306,6 +307,18 @@ export function createSubagent(spec, deps, hooks) {
306
307
  // append: a long turn would otherwise grow this without limit, and it is the
307
308
  // only per-token allocation this feature adds.
308
309
  let textTail = '';
310
+ /**
311
+ * The tool call in flight, for the timeline's duration (tui-subagent-panel §4.5.2).
312
+ *
313
+ * ONE field, not a stack: a child executes one tool at a time, and the ring records
314
+ * only the pair's difference. The `start` event deliberately writes NOTHING to the
315
+ * timeline - a line that appears and is then rewritten would push a real event out of
316
+ * the 24-slot ring for every call.
317
+ */
318
+ let pendingTool = null;
319
+ const noteTimeline = (entry) => {
320
+ run.timeline = appendTimeline(run.timeline, entry);
321
+ };
309
322
  const emit = () => {
310
323
  const now = Date.now();
311
324
  const retryKey = run.retry ? `${run.retry.attempt}/${run.retry.maxRetries}` : '';
@@ -399,6 +412,15 @@ export function createSubagent(spec, deps, hooks) {
399
412
  break;
400
413
  }
401
414
  case 'compaction_end':
415
+ // The timeline records the APPLIED compactions only (§4.5.2): a refused one
416
+ // changed nothing, and the board already shows the phase.
417
+ if (event.applied) {
418
+ noteTimeline({
419
+ kind: 'compact',
420
+ at: Date.now(),
421
+ text: `compacted ${event.estimatedTokensBefore} -> ${event.estimatedTokensAfter} tokens`,
422
+ });
423
+ }
402
424
  contextManager?.onCompactionEnd({
403
425
  applied: event.applied,
404
426
  ...(event.reason !== undefined ? { reason: event.reason } : {}),
@@ -407,6 +429,9 @@ export function createSubagent(spec, deps, hooks) {
407
429
  });
408
430
  break;
409
431
  case 'tool_execution_start':
432
+ // Recorded for the DURATION only; the entry itself is appended on the end
433
+ // event, when `ok` and `ms` are both known (§4.5.2).
434
+ pendingTool = { name: event.toolName, at: Date.now() };
410
435
  run.phase = event.toolName === 'team_wait' ? 'waiting' : 'tool';
411
436
  run.lastTool = event.toolName;
412
437
  run.toolCalls += 1;
@@ -415,7 +440,20 @@ export function createSubagent(spec, deps, hooks) {
415
440
  run.activityArgs = pickActivityArgs(event.args); // PICKED, not raw (P1-4)
416
441
  noteFile(event.toolName, event.args);
417
442
  break;
418
- case 'tool_execution_end':
443
+ case 'tool_execution_end': {
444
+ const endedAt = Date.now();
445
+ const startedAt = pendingTool?.at;
446
+ pendingTool = null;
447
+ // THE ARGUMENTS ARE GONE BY NOW, on purpose (P1-5): `activityArgs` is cleared a
448
+ // few lines below, and the timeline's text comes from `describeToolActivity` on
449
+ // the PICKED subset, so a `write_file` body can never reach the board.
450
+ noteTimeline({
451
+ kind: 'tool',
452
+ at: endedAt,
453
+ ok: event.isError !== true,
454
+ ...(startedAt !== undefined ? { ms: Math.max(0, endedAt - startedAt) } : {}),
455
+ text: describeToolActivity(event.toolName, run.activityArgs, true),
456
+ });
419
457
  run.phase = 'thinking';
420
458
  textTail = '';
421
459
  run.activity = undefined;
@@ -434,6 +472,7 @@ export function createSubagent(spec, deps, hooks) {
434
472
  run.blockedWaits = deps.bus.blockedWaitCount(spec.label);
435
473
  }
436
474
  break;
475
+ }
437
476
  case 'message_update':
438
477
  if (event.streamEvent.type === 'error') {
439
478
  run.error = formatStreamError(event.streamEvent.error);
@@ -444,6 +483,13 @@ export function createSubagent(spec, deps, hooks) {
444
483
  run.retry = undefined;
445
484
  }
446
485
  else if (event.streamEvent.type === 'retry_scheduled') {
486
+ // The timeline keeps the retry's ARRIVAL only (§4.5.2): the countdown itself is
487
+ // a live readout on the row, and a tick per attempt would eat the ring.
488
+ noteTimeline({
489
+ kind: 'retry',
490
+ at: Date.now(),
491
+ text: `retry ${event.streamEvent.attempt}/${event.streamEvent.maxRetries}`,
492
+ });
447
493
  // SET, and the phase is deliberately LEFT ALONE: the child is still
448
494
  // `thinking` as far as the scheduler is concerned, and rewriting the
449
495
  // phase would put a retry into `SubagentPhase` — a union the report, the
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The per-child event ring behind the Agents board's `timeline` section
3
+ * (tui-subagent-panel §4.5).
4
+ *
5
+ * WHY A RING ON THE RUN rather than a second store: a `SubagentRun` already IS "one
6
+ * child's projection", and every UI surface reads it through `publish()`. A separate
7
+ * store would be a second authority for the same subject and would have to be
8
+ * subscribed to separately - and the four collection points below are all single
9
+ * funnels that already exist (§4.5.2).
10
+ *
11
+ * FIVE KINDS, NOT EVERY TURN (D-12). A turn marker per turn would fill twenty-four
12
+ * slots in a long task and push out the tool calls, which are the part a reader cannot
13
+ * reconstruct from the report. Phase changes are visible in the board's `state` field
14
+ * in real time, so they do not need history.
15
+ *
16
+ * BOUNDED AND PURE. `appendTimeline` returns a NEW array (never mutates the old one),
17
+ * drops the oldest entry past `TEAM_LIMITS.timelineEntries`, and clamps every line to
18
+ * `TEAM_LIMITS.timelineTextChars`. Purity is what lets `publish()`'s shallow copy hold
19
+ * a stable snapshot without knowing this module exists (AC-25).
20
+ *
21
+ * ASCII ONLY: `src/team/**` is inside `glyphs.test.ts`'s static scan.
22
+ *
23
+ * THE TEXT IS ALWAYS BUILT FROM `describeToolActivity` / `sanitizeActivity`, never
24
+ * from raw tool arguments (D-11 / P1-4). That is the boundary that keeps a
25
+ * `write_file` body, a `bash` transcript or a child's prompt out of a surface that is
26
+ * one `JSON.stringify` from a log.
27
+ */
28
+ import type { SubagentPhase, SubagentRun } from './types.js';
29
+ export type TimelineKind = 'tool' | 'retry' | 'compact' | 'overseer' | 'settle';
30
+ export interface TimelineEntry {
31
+ kind: TimelineKind;
32
+ /** Absolute epoch ms, the same units as `startedAt` / `endedAt` / `lastIntervention.at`. */
33
+ at: number;
34
+ /** Already sanitized + clamped: at most `TEAM_LIMITS.timelineTextChars` characters. */
35
+ text: string;
36
+ /** Whether the tool call succeeded (`tool` only). */
37
+ ok?: boolean;
38
+ /** How long the tool call took in ms (`tool` only). */
39
+ ms?: number;
40
+ }
41
+ export declare function isTerminalPhase(phase: SubagentPhase): boolean;
42
+ /**
43
+ * Append one event, returning a NEW array. The oldest entry is dropped once the ring
44
+ * is full, so the array's length is bounded by `max` for every possible input.
45
+ */
46
+ export declare function appendTimeline(prev: readonly TimelineEntry[] | undefined, entry: TimelineEntry, max?: number): TimelineEntry[];
47
+ /**
48
+ * The terminal event for one ATTEMPT, or `null` when there is nothing to record yet.
49
+ *
50
+ * WHY THIS IS CALLED FROM `publish()` AND NOT FROM `settlePhase` (§4.5.2 / RV-P1-3):
51
+ * `settlePhase` covers the retry loop's exit only, and two paths set a terminal phase
52
+ * directly - the worker's "never started" abort and the cold-start backoff abort. Those
53
+ * are exactly the children whose explanation a reader needs most, and hanging the
54
+ * settle entry on `settlePhase` alone would leave them with no last event.
55
+ *
56
+ * IDEMPOTENCE IS KEYED ON THE ATTEMPT, not on "the last entry is a settle" (I-14): a
57
+ * retry re-stamps `startedAt`, so a new attempt legitimately earns a second terminal
58
+ * entry, while a repeated `publish()` of the same attempt adds nothing.
59
+ */
60
+ export declare function settleTimelineEntry(run: SubagentRun, at: number): TimelineEntry | null;
61
+ /**
62
+ * The run as a reader should see it: unchanged, or with its attempt's terminal event
63
+ * appended once. Called from `publish()`, which is every state change's single exit
64
+ * (§4.5.2) and therefore covers the two paths that set a terminal phase directly.
65
+ *
66
+ * ADDED TO THE EMITTED SNAPSHOT, NEVER TO THE LIVE RUN (this is the part `publish()`
67
+ * makes subtle). `subagent.ts` calls `onUpdate` with a COPY of the run, so writing the
68
+ * entry onto the object `publish()` receives would be lost - and because
69
+ * `settleTimelineEntry` uses the live timeline as its idempotence key, a lost write
70
+ * means every later publish of the same settled child appends another `settle` row to
71
+ * the ring. Keeping the live run untouched makes the entry a property of the VIEW:
72
+ * exactly one terminal event per attempt, however many frames are published.
73
+ */
74
+ export declare function withSettleEntry(run: SubagentRun, at: number): SubagentRun;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The per-child event ring behind the Agents board's `timeline` section
3
+ * (tui-subagent-panel §4.5).
4
+ *
5
+ * WHY A RING ON THE RUN rather than a second store: a `SubagentRun` already IS "one
6
+ * child's projection", and every UI surface reads it through `publish()`. A separate
7
+ * store would be a second authority for the same subject and would have to be
8
+ * subscribed to separately - and the four collection points below are all single
9
+ * funnels that already exist (§4.5.2).
10
+ *
11
+ * FIVE KINDS, NOT EVERY TURN (D-12). A turn marker per turn would fill twenty-four
12
+ * slots in a long task and push out the tool calls, which are the part a reader cannot
13
+ * reconstruct from the report. Phase changes are visible in the board's `state` field
14
+ * in real time, so they do not need history.
15
+ *
16
+ * BOUNDED AND PURE. `appendTimeline` returns a NEW array (never mutates the old one),
17
+ * drops the oldest entry past `TEAM_LIMITS.timelineEntries`, and clamps every line to
18
+ * `TEAM_LIMITS.timelineTextChars`. Purity is what lets `publish()`'s shallow copy hold
19
+ * a stable snapshot without knowing this module exists (AC-25).
20
+ *
21
+ * ASCII ONLY: `src/team/**` is inside `glyphs.test.ts`'s static scan.
22
+ *
23
+ * THE TEXT IS ALWAYS BUILT FROM `describeToolActivity` / `sanitizeActivity`, never
24
+ * from raw tool arguments (D-11 / P1-4). That is the boundary that keeps a
25
+ * `write_file` body, a `bash` transcript or a child's prompt out of a surface that is
26
+ * one `JSON.stringify` from a log.
27
+ */
28
+ import { TEAM_LIMITS } from './limits.js';
29
+ /** The phases that end an attempt. Used by the settle-idempotence rule below. */
30
+ const TERMINAL_PHASES = new Set(['done', 'failed', 'aborted']);
31
+ export function isTerminalPhase(phase) {
32
+ return TERMINAL_PHASES.has(phase);
33
+ }
34
+ /** Clamp one line to the display budget. Collapses whitespace for the same reason. */
35
+ function clampText(text) {
36
+ const flat = text.replace(/\s+/g, ' ').trim();
37
+ if (flat.length <= TEAM_LIMITS.timelineTextChars)
38
+ return flat;
39
+ return `${flat.slice(0, TEAM_LIMITS.timelineTextChars - 3)}...`;
40
+ }
41
+ /**
42
+ * Append one event, returning a NEW array. The oldest entry is dropped once the ring
43
+ * is full, so the array's length is bounded by `max` for every possible input.
44
+ */
45
+ export function appendTimeline(prev, entry, max = TEAM_LIMITS.timelineEntries) {
46
+ const limit = Number.isFinite(max) ? Math.max(1, Math.floor(max)) : TEAM_LIMITS.timelineEntries;
47
+ const next = [...(prev ?? []), { ...entry, text: clampText(entry.text) }];
48
+ return next.length > limit ? next.slice(next.length - limit) : next;
49
+ }
50
+ /** The first line of a multi-line answer, clamped - a timeline entry is one line. */
51
+ function head(text) {
52
+ if (text === undefined || text.length === 0)
53
+ return '';
54
+ return clampText(text);
55
+ }
56
+ /**
57
+ * The terminal event for one ATTEMPT, or `null` when there is nothing to record yet.
58
+ *
59
+ * WHY THIS IS CALLED FROM `publish()` AND NOT FROM `settlePhase` (§4.5.2 / RV-P1-3):
60
+ * `settlePhase` covers the retry loop's exit only, and two paths set a terminal phase
61
+ * directly - the worker's "never started" abort and the cold-start backoff abort. Those
62
+ * are exactly the children whose explanation a reader needs most, and hanging the
63
+ * settle entry on `settlePhase` alone would leave them with no last event.
64
+ *
65
+ * IDEMPOTENCE IS KEYED ON THE ATTEMPT, not on "the last entry is a settle" (I-14): a
66
+ * retry re-stamps `startedAt`, so a new attempt legitimately earns a second terminal
67
+ * entry, while a repeated `publish()` of the same attempt adds nothing.
68
+ */
69
+ export function settleTimelineEntry(run, at) {
70
+ if (!isTerminalPhase(run.phase))
71
+ return null;
72
+ const attemptStart = run.startedAt ?? 0;
73
+ const last = run.timeline?.[run.timeline.length - 1];
74
+ if (last?.kind === 'settle' && last.at >= attemptStart)
75
+ return null;
76
+ const text = run.phase === 'done'
77
+ ? `done: ${head(run.summary)}`.trim()
78
+ : run.phase === 'failed'
79
+ ? `failed: ${head(run.error)}`.trim()
80
+ : 'aborted';
81
+ return { kind: 'settle', at, text };
82
+ }
83
+ /**
84
+ * The run as a reader should see it: unchanged, or with its attempt's terminal event
85
+ * appended once. Called from `publish()`, which is every state change's single exit
86
+ * (§4.5.2) and therefore covers the two paths that set a terminal phase directly.
87
+ *
88
+ * ADDED TO THE EMITTED SNAPSHOT, NEVER TO THE LIVE RUN (this is the part `publish()`
89
+ * makes subtle). `subagent.ts` calls `onUpdate` with a COPY of the run, so writing the
90
+ * entry onto the object `publish()` receives would be lost - and because
91
+ * `settleTimelineEntry` uses the live timeline as its idempotence key, a lost write
92
+ * means every later publish of the same settled child appends another `settle` row to
93
+ * the ring. Keeping the live run untouched makes the entry a property of the VIEW:
94
+ * exactly one terminal event per attempt, however many frames are published.
95
+ */
96
+ export function withSettleEntry(run, at) {
97
+ const settle = settleTimelineEntry(run, at);
98
+ if (settle === null)
99
+ return run;
100
+ return { ...run, timeline: appendTimeline(run.timeline, settle) };
101
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { TokenUsage } from '@aragon-agent/core';
14
14
  import type { ActivityArgs } from './activity.js';
15
+ import type { TimelineEntry } from './timeline.js';
15
16
  /** One normalized delegation request. Produced only by `normalizeSubagentSpecs`. */
16
17
  export interface SubagentSpec {
17
18
  /** Slug, unique within the dispatch. Shown in the panel, the report and logs. */
@@ -163,7 +164,10 @@ export interface SubagentRun {
163
164
  * The most recent supervisor decision applied to this child
164
165
  * (subagent-overseer-v2 section 6). TRANSIENT, never persisted - the
165
166
  * same rule as `activity` / `activityArgs`; a resumed session has no
166
- * live dispatch.
167
+ * live dispatch. The strip that enforces it is `PERSIST_STRIP_FIELDS` in
168
+ * `team/settled.ts` (tui-subagent-panel §4.5.4): it stays on the settled run,
169
+ * because the card's badge and the supervision report read it there, and the
170
+ * SAVE boundary is where it is removed.
167
171
  *
168
172
  * `at` is ABSOLUTE epoch ms, the same units as `startedAt` / `endedAt`
169
173
  * (R-P2-3), so the panel renders `nudged 2m` with one subtraction and
@@ -175,6 +179,25 @@ export interface SubagentRun {
175
179
  at: number;
176
180
  reasonHead: string;
177
181
  };
182
+ /**
183
+ * The last `TEAM_LIMITS.timelineEntries` (24) events of this child
184
+ * (tui-subagent-panel §4.5 / §7).
185
+ *
186
+ * EPHEMERAL, on exactly the same terms as `activity` / `activityArgs` / `retry`, and
187
+ * guaranteed by TWO independent strips (`team/settled.ts`): `settledRun()` at the
188
+ * dispatch exit and `stripEphemeralRuns()` in `saveSession`'s chain. One gate is not
189
+ * enough - the live entry's runs are written by `teamUpdate` during a dispatch, so a
190
+ * checkpoint or an exit flush would otherwise carry the ring to disk (§4.5.4).
191
+ *
192
+ * NEVER persisted, never in `aragon exec`'s stream either: `exec/runner.ts`'s
193
+ * `emitTeam` builds its events from an explicit field list, so a new run field cannot
194
+ * leak into `stream-json` (AC-22).
195
+ *
196
+ * `at` is ABSOLUTE epoch ms (the units of `startedAt` / `lastIntervention.at`), and
197
+ * each `text` is already sanitized and clamped, so appending is the only write and
198
+ * `appendTimeline` is the only appender.
199
+ */
200
+ timeline?: TimelineEntry[];
178
201
  }
179
202
  /** What the supervisor may do with one inspected child. */
180
203
  export type OverseerAction = 'wait' | 'nudge' | 'replace' | 'abandon';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The acceptance-criteria model (todo-acceptance-criteria §6.2 / §6.3 / §6.6.2).
3
+ *
4
+ * ASCII ONLY: `src/todo/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE: no I/O, no timers, no `Date.now()`. The clock is injected where a
7
+ * timestamp is needed, exactly as `TodoStore`'s constructor does (P2-4).
8
+ *
9
+ * ONE PRINCIPLE RUNS THROUGH THE WHOLE FILE, and it is the reason this module
10
+ * exists: **a verdict is evidence, a plan is a belief**. A verdict is never
11
+ * destroyed by omission - not by re-sending the criterion as `pending`, and not
12
+ * by leaving it out of the list altogether - because a full replacement means the
13
+ * model re-sends the whole table on every turn and one lapse would silently erase
14
+ * a conclusion the user was shown. An unchecked criterion, by contrast, IS a
15
+ * plan, so a re-plan is free to drop it.
16
+ *
17
+ * NEVER THROWS, NEVER REJECTS, ONLY REPAIRS (I-9): the same discipline
18
+ * `normalize.ts` records for the todo list itself.
19
+ */
20
+ import type { AcceptanceItem, AcceptanceSnapshot, PlanPhase, TodoItem, TodoSnapshot } from './types.js';
21
+ export interface NormalizeAcceptanceResult {
22
+ items: AcceptanceItem[];
23
+ /** How many entries the payload contained BEFORE dropping and capping. */
24
+ requested: number;
25
+ /** Human-readable repair notes, already bounded. Empty when nothing was wrong. */
26
+ repairs: string[];
27
+ }
28
+ export interface NormalizeAcceptanceOptions {
29
+ /** `todo.acceptance`, read LIVE (D-11). False short-circuits everything below. */
30
+ enabled: boolean;
31
+ /**
32
+ * Per-lineage dedupe of the restoration note (§6.2.1 step 9 / P2-4).
33
+ *
34
+ * OWNED BY `TodoStore`, not by this module: the note would otherwise be
35
+ * re-printed on every turn, because the model has no way to know the server
36
+ * restored a verdict and would keep sending the same `pending` row. Passing the
37
+ * set in keeps this function pure and keeps the dedupe state in the one object
38
+ * that already owns the plan's lifetime.
39
+ */
40
+ restoredNotes?: Set<string>;
41
+ }
42
+ /**
43
+ * Repair whatever arrived in the `acceptance` field of a `todo_write` call.
44
+ *
45
+ * ORDER IS SEMANTICS, and one step of that order is load-bearing enough to be a
46
+ * review condition (C-2): `enabled` short-circuits FIRST, then `undefined`, then
47
+ * `Array.isArray`. `undefined` (the key is absent) and `[]` (the model sent an
48
+ * empty array) are NOT the same thing: absent means "I am only updating steps,
49
+ * keep the table", empty means "this plan needs no criteria". This is the one
50
+ * place in the feature where the two differ (§9).
51
+ */
52
+ export declare function normalizeAcceptance(raw: unknown, previous: readonly AcceptanceItem[], options: NormalizeAcceptanceOptions): NormalizeAcceptanceResult;
53
+ /**
54
+ * The immutable projection of one committed table (§6.2.2).
55
+ *
56
+ * CALLED FROM EXACTLY ONE PLACE PER COMMIT - `TodoStore`'s `write` / `restore` /
57
+ * `clear` / `reopenAll` - and stored, never recomputed on read. `snapshot()` runs
58
+ * on every event; recomputing here would stamp a new `now()` and a new object
59
+ * each frame, invalidating `TodoCard`'s `React.memo` and the transcript's height
60
+ * cache (P1-3).
61
+ */
62
+ export declare function acceptanceSummary(items: readonly AcceptanceItem[], now: () => number): AcceptanceSnapshot;
63
+ /**
64
+ * The ONE judge of "which phase is this plan in" (I-3 / D-5).
65
+ *
66
+ * Derived rather than stored, so the model, the rail, the transcript card and the
67
+ * machine event stream cannot disagree: a stored field would be a second writer
68
+ * of a fact that two lists already determine. Callers must not invoke this for a
69
+ * `null` plan.
70
+ */
71
+ export declare function derivePlanPhase(items: readonly TodoItem[], acceptance: readonly AcceptanceItem[] | null): PlanPhase;
72
+ /**
73
+ * "Is there nothing left to do in this plan" - THE single judge (G-4 / I-3).
74
+ *
75
+ * Referenced by follow-through, by `App`'s grace-window re-check and by
76
+ * `TodoStore.beginUserTurn`, because "every step is completed" stopped being the
77
+ * same claim as "the work is done" the moment acceptance criteria existed. Three
78
+ * copies of the old equation is exactly how a plan gets cleared while its last
79
+ * criterion is still unchecked.
80
+ */
81
+ export declare function planIsResolved(items: readonly TodoItem[], acceptance: readonly AcceptanceItem[] | null): boolean;
82
+ export declare function countUnfinishedSteps(items: readonly TodoItem[]): number;
83
+ export declare function countUncheckedCriteria(acceptance: readonly AcceptanceItem[] | null): number;
84
+ /** What is left in a plan, as the two numbers the notices are written from. */
85
+ export interface Outstanding {
86
+ steps: number;
87
+ checks: number;
88
+ }
89
+ /**
90
+ * The six follow-through exits all need "how much is left", and after this
91
+ * feature that quantity has two halves (§6.6.1.1). One function decides it, so no
92
+ * exit can print `0 steps left` on a plan whose steps are all done but whose
93
+ * criteria are not - which is what `total - doneCount` would say on every verify
94
+ * turn.
95
+ */
96
+ export declare function outstandingOf(snapshot: TodoSnapshot): Outstanding;
97
+ /**
98
+ * Singular and plural decided in ONE place (§6.6.1.1): six call sites must not
99
+ * each spell their own.
100
+ */
101
+ export declare function criteriaPhrase(count: number): string;
102
+ /**
103
+ * A bounded enumeration of what a plan still owes, for the tool result text
104
+ * (§6.3). Ordered `todoFirst` because the tool result is read top-down and the
105
+ * step being worked on is the more urgent of the two when both exist.
106
+ *
107
+ * BOUNDED BY CONSTRUCTION at `VERIFY_MESSAGE_LIMITS.uncheckedItems` rows: this
108
+ * string is paid for on every call (I-11).
109
+ */
110
+ export declare function planOutstanding(items: readonly TodoItem[], acceptance: readonly AcceptanceItem[] | null, todoFirst: boolean): string[];
111
+ /**
112
+ * The continuation text for the verification phase (§6.6.2).
113
+ *
114
+ * CONSTRUCTIVELY BOUNDED, which is what AC-22 asserts: the unchecked list stops
115
+ * at 5 rows, the failed list at 2, `check` is clipped to 100 characters and
116
+ * `evidence` to 60. Both truncations are deliberate - the full text is already in
117
+ * the model's own previous payload, so re-sending it is paying twice for the same
118
+ * bytes; what the model needs is a reminder of WHICH rows are open.
119
+ */
120
+ export declare function buildVerifyMessage(snapshot: TodoSnapshot): string;