@aeondave/pi-persona 1.17.0 → 1.17.3

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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +16 -11
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -0,0 +1,235 @@
1
+ import {
2
+ MONITOR_MAX_ARGS, MONITOR_MAX_ARG_CHARS, MONITOR_MAX_ARGV_CHARS,
3
+ MONITOR_MAX_COMMAND_CHARS, MONITOR_MAX_EVENTS, MONITOR_MAX_LABEL_CHARS,
4
+ type MonitorEntry, type MonitorEvent, type MonitorOutput,
5
+ } from "../core/monitor.ts";
6
+
7
+ export const MONITOR_LOG_ENTRY = "pi-persona-monitor-log";
8
+ export const MONITOR_LOG_MAX_STREAM_CHARS = 16 * 1024;
9
+ export const MONITOR_LOG_MAX_TEXT_CHARS = 16 * 1024;
10
+ export const MONITOR_LOG_MAX_RECORDS = 64;
11
+ export const MONITOR_LOG_MAX_BYTES = 2 * 1024 * 1024;
12
+
13
+ export interface MonitorLogRecord {
14
+ version: 1;
15
+ entry: MonitorEntry;
16
+ outcome: "running" | "exit" | "error" | "timeout" | "cancelled" | "ended";
17
+ /** Last semantic event's text (not an interleaved raw stream). */
18
+ text: string;
19
+ stdout: string;
20
+ stderr: string;
21
+ truncated: boolean;
22
+ storageWarning?: string;
23
+ }
24
+
25
+ export interface MonitorArchiveDeps {
26
+ /** Only the owning session's entries; never a global session/history lookup. */
27
+ entries: () => readonly unknown[];
28
+ /** Session-native custom data, NOT a model-visible custom message. */
29
+ append: (customType: string, data: unknown) => void;
30
+ }
31
+
32
+ const BUDGET_WARNING = "Monitor log persistence unavailable: session archive budget exhausted; this log is retained only in bounded runtime history.";
33
+ const APPEND_WARNING = "Monitor log persistence unavailable: appending the session entry failed; this log is retained only in bounded runtime history.";
34
+ const HISTORY_WARNING = "Monitor log history unavailable: owning-session entries could not be read; persistence is disabled to preserve the session budget.";
35
+
36
+ function object(value: unknown): value is Record<string, unknown> {
37
+ return value !== null && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ function nonempty(value: unknown, limit: number): value is string {
41
+ return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !value.includes("\0");
42
+ }
43
+
44
+ function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
45
+ return Object.keys(value).every((key) => keys.includes(key));
46
+ }
47
+
48
+ function validEntry(value: unknown): value is MonitorEntry {
49
+ if (!object(value) || !onlyKeys(value, ["id", "label", "command", "args", "cwd", "mode", "startedAtEpochMs", "expiresAtEpochMs", "maxEvents"])) return false;
50
+ return nonempty(value.id, 512)
51
+ && nonempty(value.label, MONITOR_MAX_LABEL_CHARS)
52
+ && nonempty(value.command, MONITOR_MAX_COMMAND_CHARS)
53
+ && nonempty(value.cwd, 32_768)
54
+ && (value.mode === "output" || value.mode === "exit")
55
+ && Array.isArray(value.args) && value.args.length <= MONITOR_MAX_ARGS
56
+ && Array.from(value.args).every((arg: unknown) => typeof arg === "string" && arg.length <= MONITOR_MAX_ARG_CHARS && !arg.includes("\0"))
57
+ && value.args.reduce((sum: number, arg: string) => sum + arg.length, 0) <= MONITOR_MAX_ARGV_CHARS
58
+ && typeof value.startedAtEpochMs === "number" && Number.isFinite(value.startedAtEpochMs) && value.startedAtEpochMs >= 0
59
+ && typeof value.expiresAtEpochMs === "number" && Number.isFinite(value.expiresAtEpochMs) && value.expiresAtEpochMs >= value.startedAtEpochMs
60
+ && typeof value.maxEvents === "number" && Number.isInteger(value.maxEvents) && value.maxEvents >= 1 && value.maxEvents <= MONITOR_MAX_EVENTS;
61
+ }
62
+
63
+ function copyEntry(entry: MonitorEntry): MonitorEntry {
64
+ return {
65
+ id: entry.id, label: entry.label, command: entry.command, args: [...entry.args],
66
+ cwd: entry.cwd, mode: entry.mode, startedAtEpochMs: entry.startedAtEpochMs,
67
+ expiresAtEpochMs: entry.expiresAtEpochMs, maxEvents: entry.maxEvents,
68
+ };
69
+ }
70
+
71
+ function copyRecord(record: MonitorLogRecord): MonitorLogRecord {
72
+ return {
73
+ version: 1, entry: copyEntry(record.entry), outcome: record.outcome,
74
+ text: record.text, stdout: record.stdout, stderr: record.stderr, truncated: record.truncated,
75
+ ...(record.storageWarning === undefined ? {} : { storageWarning: record.storageWarning }),
76
+ };
77
+ }
78
+
79
+ /** Validate custom-entry DATA for historical operator rendering; never returns input references.
80
+ * Unknown fields are rejected so serialized metadata cannot hide outside the archive budget. */
81
+ export function parseMonitorLogRecord(value: unknown): MonitorLogRecord | undefined {
82
+ try {
83
+ return parseRecord(value);
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ function parseRecord(value: unknown): MonitorLogRecord | undefined {
90
+ if (!object(value) || !onlyKeys(value, ["version", "entry", "outcome", "text", "stdout", "stderr", "truncated", "storageWarning"])
91
+ || value.version !== 1 || !validEntry(value.entry)) return undefined;
92
+ if (value.outcome !== "exit" && value.outcome !== "error" && value.outcome !== "timeout"
93
+ && value.outcome !== "cancelled" && value.outcome !== "ended") return undefined;
94
+ if (typeof value.text !== "string" || value.text.length > MONITOR_LOG_MAX_TEXT_CHARS
95
+ || typeof value.stdout !== "string" || value.stdout.length > MONITOR_LOG_MAX_STREAM_CHARS
96
+ || typeof value.stderr !== "string" || value.stderr.length > MONITOR_LOG_MAX_STREAM_CHARS
97
+ || typeof value.truncated !== "boolean"
98
+ || (value.storageWarning !== undefined && (typeof value.storageWarning !== "string" || value.storageWarning.length > 1024))) return undefined;
99
+ return copyRecord(value as unknown as MonitorLogRecord);
100
+ }
101
+
102
+ /** UTF-8 uses at most three bytes per UTF-16 code unit, AFTER JSON escaping.
103
+ * Reserve extra space for the host's custom-entry envelope. Deliberately conservative:
104
+ * this bounds the archive's serialized records, not arbitrary host/session metadata. */
105
+ function storageCost(record: unknown): number {
106
+ return (JSON.stringify(record) ?? "null").length * 3 + 256;
107
+ }
108
+
109
+ /** Bounded, session-native raw logs. No files, processes, timers, wakes, or model messages.
110
+ * Feed only admitted MonitorManager lifecycles (at most MONITOR_MAX_ACTIVE running entries).
111
+ * Completed records are oldest-first; the most recent 64 survive in runtime memory.
112
+ * Hydration runs once, so construct a fresh archive when the owning session changes.
113
+ * History never restores running monitors. A removed observer has no cause information:
114
+ * cancellation, permission revocation, shutdown and synchronous start failure all map to cancelled.
115
+ */
116
+ export class MonitorArchive {
117
+ private readonly deps: MonitorArchiveDeps;
118
+ private readonly active = new Map<string, MonitorLogRecord>();
119
+ private readonly completed = new Map<string, MonitorLogRecord>();
120
+ private hydrated = false;
121
+ private persistedCount = 0;
122
+ private persistedBytes = 0;
123
+ private historyWarning: string | undefined;
124
+
125
+ constructor(deps: MonitorArchiveDeps) {
126
+ this.deps = deps;
127
+ }
128
+
129
+ armed(entry: MonitorEntry): void {
130
+ this.hydrate();
131
+ if (this.active.has(entry.id) || this.completed.has(entry.id)) return;
132
+ this.active.set(entry.id, {
133
+ version: 1, entry: copyEntry(entry), outcome: "running", text: "", stdout: "", stderr: "", truncated: false,
134
+ ...(this.historyWarning === undefined ? {} : { storageWarning: this.historyWarning }),
135
+ });
136
+ }
137
+
138
+ output({ id, stream, text }: MonitorOutput): void {
139
+ const record = this.active.get(id);
140
+ if (!record || !text) return;
141
+ const current = record[stream];
142
+ if (current.length + text.length > MONITOR_LOG_MAX_STREAM_CHARS) record.truncated = true;
143
+ // Slice the chunk BEFORE concatenation: a huge callback must not create a huge copy.
144
+ record[stream] = text.length >= MONITOR_LOG_MAX_STREAM_CHARS
145
+ ? text.slice(-MONITOR_LOG_MAX_STREAM_CHARS)
146
+ : (current + text).slice(-MONITOR_LOG_MAX_STREAM_CHARS);
147
+ }
148
+
149
+ event(event: MonitorEvent): void {
150
+ const record = this.active.get(event.id);
151
+ if (!record) return;
152
+ record.text = event.text.slice(0, MONITOR_LOG_MAX_TEXT_CHARS);
153
+ if (event.text.length > MONITOR_LOG_MAX_TEXT_CHARS) record.truncated = true;
154
+ if (!event.terminal) return;
155
+ record.outcome = event.kind === "output" ? "ended" : event.kind;
156
+ this.finish(record);
157
+ }
158
+
159
+ removed(entry: MonitorEntry): void {
160
+ const record = this.active.get(entry.id);
161
+ if (!record) return;
162
+ record.outcome = "cancelled";
163
+ this.finish(record);
164
+ }
165
+
166
+ result(id: string): MonitorLogRecord | undefined {
167
+ this.hydrate();
168
+ const record = this.active.get(id) ?? this.completed.get(id);
169
+ return record ? copyRecord(record) : undefined;
170
+ }
171
+
172
+ list(): MonitorLogRecord[] {
173
+ this.hydrate();
174
+ return [...this.completed.values(), ...this.active.values()].map(copyRecord);
175
+ }
176
+
177
+ private hydrate(): void {
178
+ if (this.hydrated) return;
179
+ this.hydrated = true;
180
+ try {
181
+ const entries = this.deps.entries();
182
+ if (!Array.isArray(entries)) throw new Error("invalid session entries");
183
+ for (const entry of entries) {
184
+ try {
185
+ if (!object(entry) || entry.type !== "custom" || entry.customType !== MONITOR_LOG_ENTRY) continue;
186
+ // Charge even unknown/malformed versions and duplicate ids: ignoring a record
187
+ // for retrieval does not remove its bytes from an append-only session.
188
+ this.persistedCount++;
189
+ this.persistedBytes += storageCost(entry.data);
190
+ const record = parseMonitorLogRecord(entry.data);
191
+ if (record) this.remember(record);
192
+ } catch {
193
+ // An unaccountable entry cannot safely reset the lifetime budget.
194
+ this.historyWarning = HISTORY_WARNING;
195
+ }
196
+ }
197
+ } catch {
198
+ // Without a trustworthy baseline, appending could silently overrun the session quota.
199
+ this.historyWarning = HISTORY_WARNING;
200
+ }
201
+ }
202
+
203
+ private finish(record: MonitorLogRecord): void {
204
+ // Settle BEFORE calling injected persistence; duplicate/reentrant callbacks are inert.
205
+ this.active.delete(record.entry.id);
206
+ this.remember(record);
207
+ if (this.historyWarning !== undefined) {
208
+ record.storageWarning = this.historyWarning;
209
+ return;
210
+ }
211
+ const cost = storageCost(record);
212
+ if (this.persistedCount >= MONITOR_LOG_MAX_RECORDS || this.persistedBytes + cost > MONITOR_LOG_MAX_BYTES) {
213
+ record.storageWarning = BUDGET_WARNING;
214
+ return;
215
+ }
216
+ // Reserve even when append throws: it may have committed before reporting failure.
217
+ // This also bounds repeated failing append attempts over the runtime's lifetime.
218
+ this.persistedCount++;
219
+ this.persistedBytes += cost;
220
+ try {
221
+ this.deps.append(MONITOR_LOG_ENTRY, copyRecord(record));
222
+ } catch {
223
+ record.storageWarning = APPEND_WARNING;
224
+ }
225
+ }
226
+
227
+ private remember(record: MonitorLogRecord): void {
228
+ this.completed.delete(record.entry.id);
229
+ this.completed.set(record.entry.id, record);
230
+ while (this.completed.size > MONITOR_LOG_MAX_RECORDS) {
231
+ const oldest = this.completed.keys().next().value;
232
+ if (oldest !== undefined) this.completed.delete(oldest);
233
+ }
234
+ }
235
+ }
@@ -1,14 +1,28 @@
1
1
  /** Monitor events use the same idle follow-up delivery as timer and Intercom. */
2
- import { MonitorManager, type MonitorEvent } from "../core/monitor.ts";
2
+ import { MonitorManager, type MonitorEntry, type MonitorEvent, type MonitorOutput } from "../core/monitor.ts";
3
3
  import { fenceUntrusted, stripTerminalControls } from "../core/fence.ts";
4
4
  import { IdleCoalescingNotifier } from "../engine/async.ts";
5
5
  import { startMonitorProcess } from "./process.ts";
6
6
 
7
+ /** A passive view of the monitor lifecycle (the agent tree's monitor rows). Every `armed` is followed
8
+ * by exactly one of: a terminal `event`, or `removed` (cancel, permission loss, shutdown, start failure).
9
+ * Observation never changes admission or delivery. */
10
+ export interface MonitorObserver {
11
+ armed(entry: MonitorEntry): void;
12
+ event(event: MonitorEvent): void;
13
+ removed(entry: MonitorEntry): void;
14
+ /** Raw process output for bounded local views/storage only; never a wake. */
15
+ output?(output: MonitorOutput): void;
16
+ }
17
+
7
18
  interface MonitorSessionDeps {
19
+ /** Activation-unique prefix when results are persisted across reloads. */
20
+ idPrefix?: string;
8
21
  isIdle(): boolean;
9
22
  canRun(): boolean;
10
23
  deliver(text: string): void;
11
24
  start?: typeof startMonitorProcess;
25
+ observe?: MonitorObserver;
12
26
  }
13
27
 
14
28
  export function createMonitorSession(d: MonitorSessionDeps) {
@@ -31,7 +45,13 @@ export function createMonitorSession(d: MonitorSessionDeps) {
31
45
  });
32
46
  const manager = new MonitorManager({
33
47
  ...timerDeps, now: () => Date.now(), start: d.start ?? startMonitorProcess,
48
+ ...(d.idPrefix ? { idPrefix: d.idPrefix } : {}),
49
+ ...(d.observe ? { onArmed: (entry: MonitorEntry) => d.observe?.armed(entry), onRemoved: (entry: MonitorEntry) => d.observe?.removed(entry) } : {}),
50
+ onOutput: (output) => { d.observe?.output?.(output); },
34
51
  onEvent: (event) => {
52
+ // Observed BEFORE the permission gate: a terminal event has already left the manager, so the
53
+ // `cancelAll` below could never announce it, and its row would stay on screen forever.
54
+ d.observe?.event(event);
35
55
  if (!d.canRun()) { cancelAll(); return; }
36
56
  const oldest = notifier.peekPending()[0];
37
57
  if (notifier.peekPending().length >= 64 && oldest) {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Flow journal (v0.5) — JSONL resume. Each completed phase appends one line; on a later
3
3
  * run the journal is read back into a resume map (phase id → output) so done phases are
4
- * skipped. Lines whose `hash` doesn't match the current `flow@hash` are ignored (the flow
5
- * changed), and a phase recorded `ok:false` is NOT resumed (it re-runs). `parseJournal`
4
+ * skipped. Lines whose `hash` doesn't match the current run hash (`flowRunHash`: flow + task +
5
+ * cwd) are ignored (another flow version, task or project), and a phase recorded `ok:false` is
6
+ * NOT resumed (it re-runs). `parseJournal`
6
7
  * and the filename derivation are pure (tested); the read/write helpers are thin `fs` glue.
7
8
  */
8
9
 
@@ -24,8 +25,9 @@ const PATH_HOSTILE = /[/\\<>:"|?*\x00-\x1f]/g;
24
25
  * name as a filename can be; the `flow@hash` half is what actually keeps journals apart, since
25
26
  * it covers the whole spec (the name included). Two names that encode alike — `ci:quick` and
26
27
  * `ci?quick` — are therefore still two different specs with two different hashes and two
27
- * different files, and so is a name long enough to be truncated here. Should a truncated hash
28
- * ever collide anyway, every LINE still carries the full one, so a resume reads only its own. */
28
+ * different files, and so is a name long enough to be truncated here. Runs of ONE flow on another
29
+ * task or project share this file, and a truncated hash could collide: either way every LINE
30
+ * carries the full run hash (`flowRunHash`), so a resume reads only its own. */
29
31
  export function journalFileName(spec: FlowSpec): string {
30
32
  return `${encodeFlowName(spec.name)}.${flowHash(spec).slice(0, 8)}.journal.jsonl`;
31
33
  }
@@ -38,7 +40,7 @@ function encodeFlowName(name: string): string {
38
40
  return encoded || "flow";
39
41
  }
40
42
 
41
- /** Fold a JSONL journal into a resume map for the given `flow@hash`. Later lines win, so a
43
+ /** Fold a JSONL journal into a resume map for the given run hash. Later lines win, so a
42
44
  * phase that failed then succeeded ends up resumed; a phase still failed is left to re-run.
43
45
  * A `gateApproved` line marks a checkpoint already approved so a resume doesn't re-prompt. */
44
46
  export function parseJournal(content: string, hash: string): Record<string, ResumedResult> {
@@ -74,6 +76,25 @@ export function readJournal(path: string, hash: string): Record<string, ResumedR
74
76
  }
75
77
  }
76
78
 
79
+ /** A finished run leaves the file: drop ITS lines (unreadable ones too), keep every other run's
80
+ * resumable progress, and remove the file once nothing is left. Best-effort. */
81
+ export function clearJournal(path: string, hash: string): void {
82
+ try {
83
+ const others = fs.readFileSync(path, "utf8").split("\n").filter((line) => {
84
+ try {
85
+ const e = JSON.parse(line) as FlowJournalEntry | null;
86
+ return !!e && typeof e === "object" && e.hash !== hash;
87
+ } catch {
88
+ return false;
89
+ }
90
+ });
91
+ if (others.length === 0) fs.rmSync(path, { force: true });
92
+ else fs.writeFileSync(path, `${others.join("\n")}\n`);
93
+ } catch {
94
+ /* best effort — a journal that cannot be cleaned is only a stale resume candidate */
95
+ }
96
+ }
97
+
77
98
  /** An appending writer — one JSONL line per completed phase. Best-effort. */
78
99
  export function journalWriter(path: string): (entry: FlowJournalEntry) => void {
79
100
  return (entry) => {
@@ -40,7 +40,7 @@ export type ResumedResult = AgentResult & { gateApproved?: boolean };
40
40
  export interface FlowRunDeps {
41
41
  /** Run one phase's strategy → its result (wraps `runPersonaStrategy` in prod). */
42
42
  runPhase: RunPhaseFn;
43
- /** The pinned `flow@hash` for this run (I3) — recorded in every journal line. */
43
+ /** The pinned run identity (I3: `flowRunHash` = flow + task + cwd) — recorded in every journal line. */
44
44
  hash: string;
45
45
  signal?: AbortSignal;
46
46
  /** Grace an aborted wave gets to unwind cooperatively before its still-running phases are
@@ -71,6 +71,17 @@ export interface FlowOutcome {
71
71
  /** The run signal fired before every phase settled ok. A cancelled flow is NOT the same as one
72
72
  * that merely finished without an answer, and callers report it differently. */
73
73
  cancelled?: boolean;
74
+ /** The first checkpoint (in spec order) that was not approved, when that is the ONLY reason the
75
+ * flow did not finish: a stop the operator chose, not a failure. `ok` stays false and `error`
76
+ * stays "blocked: …" (a mandatory flow must keep failing closed); `output` is the gated
77
+ * phase's own answer plus a stop note. */
78
+ stoppedAtGate?: string;
79
+ }
80
+
81
+ /** What a finished flow IS to a reader. A checkpoint the operator declined is a STOP, their choice, never a
82
+ * failure: the one predicate the tool result, the tree row and the cards share. */
83
+ export function flowDisposition(outcome: { ok: boolean; stoppedAtGate?: string | undefined }): "done" | "stopped" | "failed" {
84
+ return outcome.ok ? "done" : outcome.stoppedAtGate !== undefined ? "stopped" : "failed";
74
85
  }
75
86
 
76
87
  /** How long an aborted wave is given to unwind COOPERATIVELY before its still-running phases are
@@ -270,6 +281,12 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
270
281
  const failure = cancellation
271
282
  ? failures.find((entry) => entry.result.failureKind === "abort")
272
283
  : rootFailures[0] ?? failures[0];
284
+ // A rejected checkpoint is a STOP, not a failure, only when nothing else went wrong: a real phase
285
+ // failure or a cancellation is the more important fact and keeps its ordinary report. A rejected
286
+ // gate with no dependents blocks nothing, so the flow is still `ok` and was not stopped.
287
+ const stoppedAtGate = !cancellation && !ok && rootFailures.length === 0
288
+ ? spec.phases.find((p) => gateState.get(p.id) === "rejected")?.id
289
+ : undefined;
273
290
  const failedPhase = failure?.phase.id;
274
291
  const error = cancellation ? (failure?.result.error ?? CANCELLED_ERROR) : failure?.result.error;
275
292
  const failureKind = cancellation ? "abort" as const : failure?.result.failureKind;
@@ -278,24 +295,32 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
278
295
  : `Flow failed: ${error ?? "the flow did not complete successfully"}`;
279
296
  const cancellationText = `Flow cancelled${failedPhase ? ` at phase "${failedPhase}"` : ""}: ${error ?? CANCELLED_ERROR}`;
280
297
  const outputAlreadySignalsCancellation = /\b(?:cancel(?:led|lation)?|abort(?:ed)?|stopp?ed)\b/i.test(terminalOutput);
281
- const output = cancellation
282
- ? terminalOutput.trim()
283
- ? outputAlreadySignalsCancellation
284
- ? terminalOutput
285
- : `${terminalOutput}\n\n${cancellationText}`
286
- : cancellationText
287
- : terminalOutput.trim()
288
- ? terminalOutput
289
- : failure
290
- ? failureText
291
- : terminalOutput;
298
+ const terminalReport = (): string => {
299
+ // A rejected checkpoint answers with the gated phase's own output: that is what the operator was
300
+ // asked to approve, and dropping it on a "no" would throw away work already paid for. Another
301
+ // branch that finished keeps its answer beside it.
302
+ if (stoppedAtGate !== undefined) {
303
+ return [
304
+ done.get(stoppedAtGate)?.output ?? "",
305
+ terminalOutput,
306
+ `- flow stopped at checkpoint "${stoppedAtGate}": not approved (declined, dismissed, or no interactive session). Later phases did not run.`,
307
+ ].filter((part) => part.trim()).join("\n\n");
308
+ }
309
+ if (cancellation) {
310
+ if (!terminalOutput.trim()) return cancellationText;
311
+ return outputAlreadySignalsCancellation ? terminalOutput : `${terminalOutput}\n\n${cancellationText}`;
312
+ }
313
+ if (terminalOutput.trim()) return terminalOutput;
314
+ return failure ? failureText : terminalOutput;
315
+ };
292
316
  return {
293
317
  ok,
294
318
  results,
295
- output,
319
+ output: terminalReport(),
296
320
  ...(failedPhase ? { failedPhase } : {}),
297
321
  ...(error ? { error } : {}),
298
322
  ...(failureKind ? { failureKind } : {}),
299
323
  ...(cancelled ? { cancelled: true } : {}),
324
+ ...(stoppedAtGate !== undefined ? { stoppedAtGate } : {}),
300
325
  };
301
326
  }
@@ -28,12 +28,19 @@ export interface FlowSpec {
28
28
  export type FlowParse = { ok: true; flow: FlowSpec } | { ok: false; error: string };
29
29
  export type TopoResult = { ok: true; order: string[] } | { ok: false; error: string };
30
30
 
31
- /** The pinned `flow@hash` for a run (I3): a stable sha256 over the spec, so a hot reload
32
- * of the file can't change an in-flight run and a resume only matches the same flow. */
31
+ /** A stable sha256 over the spec — the flow half of a run's identity, and the journal file name's
32
+ * hash (`journalFileName`): a hot reload of the file can't change an in-flight run. */
33
33
  export function flowHash(spec: FlowSpec): string {
34
34
  return stableHash(spec);
35
35
  }
36
36
 
37
+ /** The pinned run identity (I3) recorded in every journal line: flow + task + cwd. A resume
38
+ * skips finished phases only for the SAME flow on the SAME task in the SAME project; a failed
39
+ * `flow X` on task A must never feed its phases' output to `flow X` on task B. */
40
+ export function flowRunHash(spec: FlowSpec, task: string, cwd: string): string {
41
+ return stableHash({ flow: flowHash(spec), task: task.trim(), cwd });
42
+ }
43
+
37
44
  function isObject(x: unknown): x is Record<string, unknown> {
38
45
  return typeof x === "object" && x !== null && !Array.isArray(x);
39
46
  }
@@ -89,21 +89,25 @@ export function itemLedger(
89
89
  });
90
90
  }
91
91
 
92
- /** Combine fan-out results into one labeled-section result; ok iff all ok. */
93
- export function aggregateResults(results: AgentResult[]): AgentResult {
94
- const sections = results.map((r) => {
92
+ /** Combine fan-out results into one labeled-section result; ok iff all ok. `labels` (aligned to
93
+ * `results`) name each section by lens or item instead of the bare agent; the label is kept as
94
+ * `member` only when it adds something, so a label-free call is byte-identical to before. */
95
+ export function aggregateResults(results: AgentResult[], labels?: readonly string[]): AgentResult {
96
+ const labelOf = (r: AgentResult, i: number): string => labels?.[i] ?? r.agent;
97
+ const sections = results.map((r, i) => {
95
98
  const status = r.ok ? "ok" : `failed${r.failureKind ? ` (${r.failureKind})` : ""}`;
96
99
  const body = r.output || r.error || "(no output)";
97
100
  const cause = !r.ok && r.error && r.output && r.error !== r.output ? `\n\nError: ${r.error}` : "";
98
- return `### [${r.agent}] ${status}\n\n${body}${cause}`;
101
+ return `### [${labelOf(r, i)}] ${status}\n\n${body}${cause}`;
99
102
  });
100
103
  const aggregate: AgentResult = {
101
104
  agent: "aggregate",
102
105
  output: sections.join("\n\n---\n\n"),
103
106
  structured: {
104
107
  count: results.length,
105
- results: results.map((r) => ({
108
+ results: results.map((r, i) => ({
106
109
  agent: r.agent,
110
+ ...(labelOf(r, i) !== r.agent ? { member: labelOf(r, i) } : {}),
107
111
  ok: r.ok,
108
112
  output: r.output,
109
113
  error: r.error ?? null,
@@ -72,7 +72,7 @@ export function humanizeAggregateResult(r: AgentResult): string | undefined {
72
72
  for (const value of members) {
73
73
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
74
74
  const member = value as Record<string, unknown>;
75
- const agent = typeof member.agent === "string" && member.agent.trim() ? member.agent.trim() : "member";
75
+ const agent = field(member, "member") ?? field(member, "agent") ?? "member";
76
76
  const ok = member.ok === true;
77
77
  const output = typeof member.output === "string" ? member.output : "";
78
78
  const structured = member.structured && typeof member.structured === "object" && !Array.isArray(member.structured)
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { RunLimits } from "../core/capabilities.ts";
9
- import { emptyUsage, type ToolEvent } from "../engine/stream.ts";
9
+ import { type ChildUsage, emptyUsage, type ToolEvent } from "../engine/stream.ts";
10
10
  import { type JudgePrep, prepareJudge } from "./judge.ts";
11
11
  import { planModelRecovery } from "./model-retry.ts";
12
12
  import { mapWithConcurrency, Semaphore } from "./parallel.ts";
@@ -53,7 +53,7 @@ export interface AgentRunSpec {
53
53
 
54
54
  /** Is `x` a genuine positive, finite `timeoutMs` override — vs. absent/junk (undefined, NaN,
55
55
  * Infinity, ≤0), which means "no override, fall back to the run's existing default"? The
56
- * ONE predicate every `timeoutMs` site gates on (`specOf`, the single-async delegate path,
56
+ * ONE predicate every `timeoutMs` site gates on (`specOf`, the single-leg delegate path,
57
57
  * and both engine adapters), so they can't drift into checking it differently. */
58
58
  export function isPositiveFiniteMs(x: number | undefined): x is number {
59
59
  return x !== undefined && Number.isFinite(x) && x > 0;
@@ -65,6 +65,8 @@ export interface AgentProgress {
65
65
  output: string;
66
66
  /** Tokens consumed so far (best-effort). */
67
67
  tokens?: number;
68
+ /** Copied cumulative counters for display, when supplied by the engine. */
69
+ usage?: ChildUsage;
68
70
  /** The tool the agent is currently running (e.g. "grep src/…"), if any. */
69
71
  activity?: string;
70
72
  /** One authoritative runtime tool lifecycle transition; never contains tool args or output. */
@@ -105,7 +107,8 @@ export interface StrategySDK {
105
107
  agent(spec: AgentRunSpec, options?: AgentCallOptions): Promise<AgentResult>;
106
108
  parallel<T>(thunks: Array<() => Promise<T>>, opts?: { concurrency?: number }): Promise<T[]>;
107
109
  reduce: {
108
- aggregate(results: AgentResult[]): AgentResult;
110
+ /** `labels` (aligned to `results`) name each section by lens or item instead of the agent. */
111
+ aggregate(results: AgentResult[], labels?: readonly string[]): AgentResult;
109
112
  vote(candidates: AgentResult[], opts: VoteOpts): ReducerResult;
110
113
  /** Anonymise + label candidates for an impartial judge (run `agent(judge, …)` on
111
114
  * the returned ballot, then map the verdict via `pick`). */
@@ -144,6 +147,17 @@ export interface StrategyParam {
144
147
 
145
148
  export interface Strategy {
146
149
  name: string;
150
+ /** The problem class this method answers, in one line — rendered into the council tool's
151
+ * description from the registry, so the guidance cannot drift from the code. */
152
+ when?: string;
153
+ /** What the roster positions MEAN, where order or role matters (`[splitter, worker]`). */
154
+ roster?: string;
155
+ /** The team this method is built for. A call that switches TO this method and names no roster seats it,
156
+ * instead of the profile's roster (the wrong panel for it) or the `magi` fallback (read-only voters). */
157
+ team?: string;
158
+ /** Param keys the run cannot proceed without; a call that omits one is refused up front,
159
+ * before any run, tree row or model picker exists. Each must also be declared in `params`. */
160
+ required?: readonly string[];
147
161
  /** Declared, discoverable params (name → type/default/doc). Consumed by the council tool
148
162
  * (warn on unknown keys) and `/doctor`. A strategy with no params omits this. */
149
163
  params?: Record<string, StrategyParam>;
@@ -62,8 +62,12 @@ function clip(diff: string, max: number): string {
62
62
 
63
63
  export const compete: Strategy = {
64
64
  name: "compete",
65
+ when: "rival implementations in worktrees, blind-judged; returns the winning diff (clean git repo)",
66
+ roster: "competitors",
67
+ team: "build",
68
+ required: ["judge"],
65
69
  params: {
66
- judge: { type: "agent", doc: "(required) the arbiter agent, or an inline { agent, role?, model?, skills? }" },
70
+ judge: { type: "agent", doc: "the arbiter agent, or an inline { agent, role?, model?, skills? }" },
67
71
  ballotDiffChars: { type: "number", default: 6000, doc: "ballot-only diff clip length; the winner's diff is always full" },
68
72
  },
69
73
  async run(input, sdk) {
@@ -78,6 +78,8 @@ function cancelled(rounds: number, usages: AgentResult["usage"][]): AgentResult
78
78
 
79
79
  export const councilRounds: Strategy = {
80
80
  name: "council-rounds",
81
+ when: "hard fork needing iterated debate: re-vote each round to a bestOf majority",
82
+ team: "magi",
81
83
  params: {
82
84
  rounds: { type: "number", default: 3, doc: "max deliberation rounds" },
83
85
  bestOf: { type: "number", doc: "default: majority of the roster" },
@@ -46,6 +46,9 @@ function cancelled(rounds: number, output: string, usages: AgentResult["usage"][
46
46
 
47
47
  export const criticLoop: Strategy = {
48
48
  name: "critic-loop",
49
+ when: "ONE draft + adversarial critique, revised until the critic approves",
50
+ roster: "[generator, critic]",
51
+ team: "repair",
49
52
  params: {
50
53
  // `default` must name an agent that ACTUALLY runs (the collector resolves the participant
51
54
  // as: the provided override, else roster[rosterIndex], else this agent) — never a phrase
@@ -84,6 +84,8 @@ function cancelled(usages: AgentResult["usage"][]): AgentResult {
84
84
 
85
85
  export const debate: Strategy = {
86
86
  name: "debate",
87
+ when: "2+ members argue live with each other, then a majority vote",
88
+ team: "magi",
87
89
  params: {
88
90
  bestOf: { type: "number", doc: "default: majority of the roster" },
89
91
  aggregate: { type: "string", default: "majority", doc: '"majority" | "unanimity"' },
@@ -5,16 +5,17 @@
5
5
  * No peers: fanout is deliberately independent parallel work — use `debate` for a live exchange.
6
6
  */
7
7
 
8
- import { rosterSpec } from "../roster.ts";
8
+ import { rosterNodeKeys, rosterSpec } from "../roster.ts";
9
9
  import type { Strategy } from "../sdk.ts";
10
10
 
11
11
  export const fanout: Strategy = {
12
12
  name: "fanout",
13
+ when: "ONE question to a panel of distinct lenses/models, answers side by side, no vote (separate tasks → delegate)",
13
14
  async run(input, sdk) {
14
15
  const team = input.roster ? sdk.roster.team(input.roster) : [];
15
16
  if (team.length === 0) throw new Error("fanout: a non-empty roster is required");
16
17
  sdk.log(`fanout: ${team.length} agents on "${input.task.slice(0, 40)}"`);
17
18
  const results = await sdk.parallel(team.map((m) => () => sdk.agent({ ...rosterSpec(m), task: input.task })));
18
- return { ...sdk.reduce.aggregate(results), agent: "fanout" };
19
+ return { ...sdk.reduce.aggregate(results, rosterNodeKeys(team)), agent: "fanout" };
19
20
  },
20
21
  };
@@ -44,8 +44,12 @@ function cancelled(arbiter: string, display: AgentResult[], usage: AgentResult["
44
44
 
45
45
  export const judge: Strategy = {
46
46
  name: "judge",
47
+ when: "rival COMPLETE answers: independent panel, one impartial arbiter picks",
48
+ roster: "the panel",
49
+ team: "magi",
50
+ required: ["judge"],
47
51
  params: {
48
- judge: { type: "agent", doc: "(required) the arbiter agent, or an inline { agent, role?, model?, skills? }" },
52
+ judge: { type: "agent", doc: "the arbiter agent, or an inline { agent, role?, model?, skills? }" },
49
53
  contract: { type: "string", doc: "optional output contract the panel runs against" },
50
54
  },
51
55
  async run(input, sdk) {