@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Flow runner (v0.5) — execute a {@link FlowSpec} DAG: run every phase's strategy in
3
+ * dependency order, fanning out phases whose `needs` are all satisfied in parallel,
4
+ * threading each upstream phase's output into its dependents' task. Journals every
5
+ * completed phase (one JSONL line) so a later run can RESUME by skipping done phases. A
6
+ * failed phase blocks its dependents (they can't run without their input). The strategy
7
+ * runner is injected (`deps.runPhase`), so the DAG logic is engine-agnostic and tested
8
+ * with a stub.
9
+ */
10
+
11
+ import { fenceUntrusted } from "../core/fence.ts";
12
+ import { emptyUsage } from "../engine/stream.ts";
13
+ import type { FlowPhase, FlowSpec } from "./flow.ts";
14
+ import type { AgentResult, FailureKind } from "./types.ts";
15
+
16
+ export type PhaseStatus = "running" | "done" | "failed";
17
+
18
+ /** One phase ready to run: its spec, the built task, and its upstream phases' results. */
19
+ export interface FlowPhaseRun {
20
+ phase: FlowPhase;
21
+ task: string;
22
+ upstream: Record<string, AgentResult>;
23
+ }
24
+
25
+ export type RunPhaseFn = (run: FlowPhaseRun) => Promise<AgentResult>;
26
+
27
+ /** One journal line — appended as each phase completes (for resume). */
28
+ export interface FlowJournalEntry {
29
+ phase: string;
30
+ hash: string;
31
+ ok: boolean;
32
+ output: string;
33
+ /** A gated phase whose checkpoint was approved — so a resume doesn't re-prompt. */
34
+ gateApproved?: boolean;
35
+ }
36
+
37
+ /** A resume map value: a phase result plus whether its checkpoint gate was already approved. */
38
+ export type ResumedResult = AgentResult & { gateApproved?: boolean };
39
+
40
+ export interface FlowRunDeps {
41
+ /** Run one phase's strategy → its result (wraps `runPersonaStrategy` in prod). */
42
+ runPhase: RunPhaseFn;
43
+ /** The pinned `flow@hash` for this run (I3) — recorded in every journal line. */
44
+ hash: string;
45
+ signal?: AbortSignal;
46
+ /** Grace an aborted wave gets to unwind cooperatively before its still-running phases are
47
+ * force-settled (defaults to {@link ABORT_GRACE_MS}); shrinkable in tests. */
48
+ abortGraceMs?: number;
49
+ /** Append one entry as each phase completes. */
50
+ journal?: (entry: FlowJournalEntry) => void;
51
+ /** Phases already completed (from a prior run's journal) — skipped, output reused. */
52
+ resume?: Record<string, ResumedResult>;
53
+ /** Live UI: phase lifecycle. */
54
+ onPhase?: (id: string, status: PhaseStatus, result?: AgentResult) => void;
55
+ /** A gated phase's checkpoint: after it completes, approve before its dependents run.
56
+ * Absent ⇒ gates auto-approve (informational only — headless runs aren't blocked). */
57
+ approveGate?: (phase: FlowPhase, result: AgentResult) => Promise<boolean>;
58
+ }
59
+
60
+ export interface FlowOutcome {
61
+ ok: boolean;
62
+ results: Record<string, AgentResult>;
63
+ /** The terminal (sink) phases' combined output — the flow's answer. */
64
+ output: string;
65
+ /** The first phase that failed, when a settled result identifies one. */
66
+ failedPhase?: string;
67
+ /** The actionable cause of the flow's first failure, or the cancellation cause. */
68
+ error?: string;
69
+ /** The machine-readable cause of the flow failure. */
70
+ failureKind?: FailureKind;
71
+ /** The run signal fired before every phase settled ok. A cancelled flow is NOT the same as one
72
+ * that merely finished without an answer, and callers report it differently. */
73
+ cancelled?: boolean;
74
+ }
75
+
76
+ /** How long an aborted wave is given to unwind COOPERATIVELY before its still-running phases are
77
+ * force-settled as cancelled. A strategy that consults the run signal returns its own (more
78
+ * informative) result — the last good draft, the upstream step's output, the usage already
79
+ * billed — inside this window; a strategy that ignores the signal must not be able to hold the
80
+ * whole run open. Sized for the cooperative path to actually WIN it: a strategy notices at a
81
+ * round boundary, and only once its in-flight legs have settled through the engine's abort path
82
+ * (an in-process session stop, or a child process tree kill), which is seconds rather than
83
+ * milliseconds. The full wait is paid only by a phase that never unwinds at all. */
84
+ const ABORT_GRACE_MS = 5_000;
85
+
86
+ const CANCELLED_ERROR = "cancelled: the flow run was aborted";
87
+
88
+ /** Resolves when `signal` aborts — never, when there is none. `dispose` drops the listener, so a
89
+ * many-wave run doesn't accumulate one per wave on a long-lived signal. */
90
+ function whenAborted(signal: AbortSignal | undefined): { promise: Promise<void>; dispose: () => void } {
91
+ if (!signal) return { promise: new Promise<void>(() => { /* never */ }), dispose: () => { /* nothing to drop */ } };
92
+ let onAbort = (): void => { /* replaced below */ };
93
+ const promise = new Promise<void>((resolve) => {
94
+ onAbort = (): void => resolve();
95
+ if (signal.aborted) resolve();
96
+ else signal.addEventListener("abort", onAbort, { once: true });
97
+ });
98
+ return { promise, dispose: () => signal.removeEventListener("abort", onAbort) };
99
+ }
100
+
101
+ /** Await `p`, but give up after `ms`. The timer is unref'd: a pending grace must never be the
102
+ * reason a process stays alive. */
103
+ function within(p: Promise<unknown>, ms: number): Promise<void> {
104
+ return new Promise((resolve) => {
105
+ const timer = setTimeout(resolve, ms);
106
+ timer.unref?.();
107
+ const settle = (): void => {
108
+ clearTimeout(timer);
109
+ resolve();
110
+ };
111
+ void p.then(settle, settle);
112
+ });
113
+ }
114
+
115
+ /** Build a phase's task: the base task plus each upstream phase's output as context. */
116
+ function buildPhaseTask(baseTask: string, spec: FlowSpec, phase: FlowPhase, upstream: Record<string, AgentResult>): string {
117
+ const parts = [`Flow "${spec.name}" — phase "${phase.id}" (strategy: ${phase.strategy}).`, baseTask];
118
+ const ups = Object.entries(upstream);
119
+ if (ups.length > 0) {
120
+ parts.push("\nUpstream results to build on:");
121
+ for (const [id, r] of ups) parts.push(`\n--- ${id} ---\n${fenceUntrusted(r.output)}`);
122
+ }
123
+ return parts.join("\n");
124
+ }
125
+
126
+ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDeps): Promise<FlowOutcome> {
127
+ const byId = new Map(spec.phases.map((p) => [p.id, p]));
128
+ // A gated phase only satisfies its dependents once its checkpoint is "passed" (or it blocks
129
+ // them once "rejected"). Resume pre-seeds gates the journal already recorded as approved.
130
+ const gateState = new Map<string, "passed" | "rejected">();
131
+ const done = new Map<string, AgentResult>();
132
+ for (const [id, r] of Object.entries(deps.resume ?? {})) {
133
+ const gated = byId.get(id)?.gate === true;
134
+ // A gated phase resumed as ok but WITHOUT a recorded approval (the process died between
135
+ // journaling its completion and its gate decision) must RE-RUN, not be treated as done —
136
+ // otherwise it sits in `done` with an unresolved gate and its dependents can never run.
137
+ if (gated && r.ok && !(r as ResumedResult).gateApproved) continue;
138
+ done.set(id, r);
139
+ if (gated && (r as ResumedResult).gateApproved) gateState.set(id, "passed");
140
+ }
141
+ // A FAILED gated phase has no checkpoint to resolve — its gate must not keep dependents
142
+ // from being considered (needFailed then records them "blocked", exactly like a failed
143
+ // non-gated need; otherwise they'd never become ready and silently vanish, stuck ⏳).
144
+ const gateResolved = (id: string): boolean => !byId.get(id)?.gate || gateState.has(id) || done.get(id)?.ok === false;
145
+ const needFailed = (n: string): boolean => (done.has(n) && !done.get(n)?.ok) || gateState.get(n) === "rejected";
146
+ let remaining = spec.phases.filter((p) => !done.has(p.id));
147
+ let cancelled = false;
148
+ // Keep blocked dependents distinct from phases that actually ran and failed. The flow's
149
+ // declaration order is not necessarily topological, so a dependent can appear before the
150
+ // root failure in `spec.phases`; it must never hide that root cause in the final outcome.
151
+ const blockedPhaseIds = new Set<string>();
152
+
153
+ while (remaining.length > 0) {
154
+ if (deps.signal?.aborted) {
155
+ cancelled = true;
156
+ break;
157
+ }
158
+ // A phase can be considered once every need is done AND any gated need is resolved.
159
+ const ready = remaining.filter((p) => (p.needs ?? []).every((n) => done.has(n) && gateResolved(n)));
160
+ if (ready.length === 0) break; // acyclic + progress guarantees this can't happen
161
+
162
+ // A phase whose any need FAILED or whose gate was REJECTED is blocked — record, don't run.
163
+ const blocked = ready.filter((p) => (p.needs ?? []).some((n) => needFailed(n)));
164
+ for (const p of blocked) {
165
+ blockedPhaseIds.add(p.id);
166
+ const gateReject = (p.needs ?? []).some((n) => gateState.get(n) === "rejected");
167
+ const error = gateReject ? "blocked: a checkpoint gate was not approved" : "blocked: an upstream phase failed";
168
+ const r: AgentResult = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
169
+ done.set(p.id, r);
170
+ deps.onPhase?.(p.id, "failed", r);
171
+ deps.journal?.({ phase: p.id, hash: deps.hash, ok: false, output: "" });
172
+ }
173
+
174
+ const runnable = ready.filter((p) => !blocked.includes(p));
175
+ // Phases that produced their own result. Anything still missing when an aborted wave's
176
+ // grace expires is force-settled below, so a run-level abort can cut a wave already in
177
+ // flight instead of waiting on strategies that never consult the signal.
178
+ const settled = new Map<string, AgentResult>();
179
+ let forced = false;
180
+ const wave = Promise.all(
181
+ runnable.map(async (p): Promise<void> => {
182
+ deps.onPhase?.(p.id, "running");
183
+ const upstream: Record<string, AgentResult> = {};
184
+ for (const n of p.needs ?? []) {
185
+ const u = done.get(n);
186
+ if (u) upstream[n] = u;
187
+ }
188
+ const task = buildPhaseTask(baseTask, spec, p, upstream);
189
+ // A throwing phase (e.g. a misconfigured strategy) is a phase FAILURE, not a
190
+ // crashed DAG — record it failed so its dependents block and the flow finishes.
191
+ let r: AgentResult;
192
+ try {
193
+ r = await deps.runPhase({ phase: p, task, upstream });
194
+ } catch (err) {
195
+ const error = err instanceof Error ? err.message : String(err);
196
+ r = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
197
+ }
198
+ // The run already gave up on this phase and moved on. Its UI node is torn down and
199
+ // its outcome is already reported, so re-drive neither — but work that DID complete
200
+ // is still worth keeping: journal it so a RESUME reuses the output instead of paying
201
+ // for the same phase a second time. A late FAILURE is not journaled — there is
202
+ // nothing to reuse, and a resume has to re-run it anyway.
203
+ if (forced) {
204
+ if (r.ok) deps.journal?.({ phase: p.id, hash: deps.hash, ok: true, output: r.output });
205
+ return;
206
+ }
207
+ settled.set(p.id, r);
208
+ deps.onPhase?.(p.id, r.ok ? "done" : "failed", r);
209
+ deps.journal?.({ phase: p.id, hash: deps.hash, ok: r.ok, output: r.output });
210
+ }),
211
+ );
212
+
213
+ const abort = whenAborted(deps.signal);
214
+ try {
215
+ await Promise.race([wave, abort.promise]);
216
+ if (deps.signal?.aborted) await within(wave, deps.abortGraceMs ?? ABORT_GRACE_MS);
217
+ } finally {
218
+ abort.dispose();
219
+ }
220
+ if (deps.signal?.aborted) {
221
+ forced = true;
222
+ for (const p of runnable) {
223
+ if (settled.has(p.id)) continue;
224
+ // `failureKind: "abort"` is the distinction every other layer keys on (the strategies'
225
+ // own cancelled results carry it) — without it a phase the USER stopped reads to its
226
+ // consumers as one that simply failed.
227
+ const r: AgentResult = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error: CANCELLED_ERROR, failureKind: "abort" };
228
+ settled.set(p.id, r);
229
+ deps.onPhase?.(p.id, "failed", r);
230
+ // Deliberately NOT journaled: the phase produced nothing, so a resume has to re-run
231
+ // it rather than skip it as done.
232
+ }
233
+ }
234
+ for (const [id, r] of settled) done.set(id, r);
235
+ if (deps.signal?.aborted) {
236
+ // Gates are skipped on the way out — an aborted run must not stop to prompt for a
237
+ // checkpoint nobody is waiting on.
238
+ cancelled = spec.phases.some((p) => done.get(p.id)?.ok !== true);
239
+ break;
240
+ }
241
+
242
+ // Resolve checkpoint gates for phases that just completed OK — sequentially, so the
243
+ // next readiness pass sees each gate's verdict (a rejected gate blocks its dependents).
244
+ for (const [id, r] of settled) {
245
+ const phase = byId.get(id);
246
+ if (!phase?.gate || !r.ok || gateState.has(id)) continue;
247
+ const approved = deps.approveGate ? await deps.approveGate(phase, r) : true;
248
+ gateState.set(id, approved ? "passed" : "rejected");
249
+ if (approved) deps.journal?.({ phase: id, hash: deps.hash, ok: true, output: r.output, gateApproved: true });
250
+ }
251
+ remaining = remaining.filter((p) => !done.has(p.id));
252
+ }
253
+
254
+ const results: Record<string, AgentResult> = Object.fromEntries(done);
255
+ const needed = new Set(spec.phases.flatMap((p) => p.needs ?? []));
256
+ const terminals = spec.phases.filter((p) => !needed.has(p.id));
257
+ const terminalOutput = terminals
258
+ .map((p) => done.get(p.id)?.output ?? "")
259
+ .filter((s) => s.trim())
260
+ .join("\n\n---\n\n");
261
+ const ok = spec.phases.every((p) => done.get(p.id)?.ok ?? false);
262
+ const failures = spec.phases
263
+ .map((phase) => ({ phase, result: done.get(phase.id) }))
264
+ .filter((entry): entry is { phase: FlowPhase; result: AgentResult } => entry.result !== undefined && !entry.result.ok);
265
+ const rootFailures = failures.filter((entry) => !blockedPhaseIds.has(entry.phase.id));
266
+ const cancellation = cancelled;
267
+ // Cancellation is authoritative: a normal failure from an earlier phase must not replace the
268
+ // user's stop with an ordinary failure classification. Prefer an explicitly aborted phase when
269
+ // one settled, otherwise keep the cancellation at flow level without inventing a failed phase.
270
+ const failure = cancellation
271
+ ? failures.find((entry) => entry.result.failureKind === "abort")
272
+ : rootFailures[0] ?? failures[0];
273
+ const failedPhase = failure?.phase.id;
274
+ const error = cancellation ? (failure?.result.error ?? CANCELLED_ERROR) : failure?.result.error;
275
+ const failureKind = cancellation ? "abort" as const : failure?.result.failureKind;
276
+ const failureText = failedPhase
277
+ ? `Flow failed at phase "${failedPhase}": ${error ?? "the phase failed"}`
278
+ : `Flow failed: ${error ?? "the flow did not complete successfully"}`;
279
+ const cancellationText = `Flow cancelled${failedPhase ? ` at phase "${failedPhase}"` : ""}: ${error ?? CANCELLED_ERROR}`;
280
+ 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;
292
+ return {
293
+ ok,
294
+ results,
295
+ output,
296
+ ...(failedPhase ? { failedPhase } : {}),
297
+ ...(error ? { error } : {}),
298
+ ...(failureKind ? { failureKind } : {}),
299
+ ...(cancelled ? { cancelled: true } : {}),
300
+ };
301
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Flows (v0.5) — a declarative DAG over strategies. A `*.flow.json` file names phases,
3
+ * each running a strategy over a roster, wired by `needs` edges. JSON-first (the magi
4
+ * council's ruling): `JSON.parse` + a hand-rolled validator (no new deps), the DAG
5
+ * pinned per run via the contract hasher and journaled for resume. This module is the
6
+ * PURE core — schema, validation, and topological order; the runner (engine + journal
7
+ * injected) lives alongside in `flow-run.ts`.
8
+ */
9
+
10
+ import { stableHash } from "../core/contract.ts";
11
+
12
+ export interface FlowPhase {
13
+ id: string;
14
+ strategy: string;
15
+ roster?: string;
16
+ needs?: string[];
17
+ params?: Record<string, unknown>;
18
+ /** A checkpoint: after this phase completes, require approval before its dependents run. */
19
+ gate?: boolean;
20
+ }
21
+
22
+ export interface FlowSpec {
23
+ name: string;
24
+ description?: string;
25
+ phases: FlowPhase[];
26
+ }
27
+
28
+ export type FlowParse = { ok: true; flow: FlowSpec } | { ok: false; error: string };
29
+ export type TopoResult = { ok: true; order: string[] } | { ok: false; error: string };
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. */
33
+ export function flowHash(spec: FlowSpec): string {
34
+ return stableHash(spec);
35
+ }
36
+
37
+ function isObject(x: unknown): x is Record<string, unknown> {
38
+ return typeof x === "object" && x !== null && !Array.isArray(x);
39
+ }
40
+
41
+ /** Kahn's algorithm over `needs` edges → a dependency-respecting order, or a cycle error. */
42
+ export function topoOrder(phases: FlowPhase[]): TopoResult {
43
+ const ids = new Set(phases.map((p) => p.id));
44
+ const indeg = new Map<string, number>();
45
+ const dependents = new Map<string, string[]>(); // a phase → the phases that need it
46
+ for (const p of phases) indeg.set(p.id, 0);
47
+ for (const p of phases) {
48
+ for (const n of p.needs ?? []) {
49
+ if (!ids.has(n)) return { ok: false, error: `phase "${p.id}" needs unknown phase "${n}"` };
50
+ indeg.set(p.id, (indeg.get(p.id) ?? 0) + 1);
51
+ dependents.set(n, [...(dependents.get(n) ?? []), p.id]);
52
+ }
53
+ }
54
+ const queue = phases.filter((p) => (indeg.get(p.id) ?? 0) === 0).map((p) => p.id);
55
+ const order: string[] = [];
56
+ while (queue.length > 0) {
57
+ const id = queue.shift() as string;
58
+ order.push(id);
59
+ for (const dep of dependents.get(id) ?? []) {
60
+ const d = (indeg.get(dep) ?? 0) - 1;
61
+ indeg.set(dep, d);
62
+ if (d === 0) queue.push(dep);
63
+ }
64
+ }
65
+ if (order.length !== phases.length) return { ok: false, error: "flow has a cycle" };
66
+ return { ok: true, order };
67
+ }
68
+
69
+ /** Parse + validate a `*.flow.json` into a runnable FlowSpec (or a clear error). The name is
70
+ * taken as the flow's IDENTITY and kept verbatim, path-hostile characters and all: the journal
71
+ * filename it becomes is derived from it (`journalFileName`, flow-journal.ts), not spelled by it. */
72
+ export function parseFlow(content: string): FlowParse {
73
+ let raw: unknown;
74
+ try {
75
+ raw = JSON.parse(content);
76
+ } catch {
77
+ return { ok: false, error: "flow is not valid JSON" };
78
+ }
79
+ if (!isObject(raw)) return { ok: false, error: "flow must be a JSON object" };
80
+ if (typeof raw.name !== "string" || !raw.name.trim()) return { ok: false, error: "flow.name is required" };
81
+ if (!Array.isArray(raw.phases) || raw.phases.length === 0) return { ok: false, error: "flow.phases must be a non-empty array" };
82
+
83
+ const phases: FlowPhase[] = [];
84
+ const seen = new Set<string>();
85
+ for (const [i, p] of (raw.phases as unknown[]).entries()) {
86
+ if (!isObject(p)) return { ok: false, error: `phase ${i} must be an object` };
87
+ if (typeof p.id !== "string" || !p.id.trim()) return { ok: false, error: `phase ${i} needs an id` };
88
+ if (seen.has(p.id)) return { ok: false, error: `duplicate phase id "${p.id}"` };
89
+ if (typeof p.strategy !== "string" || !p.strategy.trim()) return { ok: false, error: `phase "${p.id}" needs a strategy` };
90
+ seen.add(p.id);
91
+ const phase: FlowPhase = { id: p.id, strategy: p.strategy };
92
+ if (typeof p.roster === "string" && p.roster.trim()) phase.roster = p.roster;
93
+ if (Array.isArray(p.needs)) {
94
+ if (!p.needs.every((n): n is string => typeof n === "string")) return { ok: false, error: `phase "${p.id}" needs must be strings` };
95
+ phase.needs = p.needs;
96
+ }
97
+ if (isObject(p.params)) phase.params = p.params;
98
+ if (p.gate === true) phase.gate = true;
99
+ phases.push(phase);
100
+ }
101
+
102
+ const topo = topoOrder(phases);
103
+ if (!topo.ok) return { ok: false, error: topo.error };
104
+
105
+ const flow: FlowSpec = { name: raw.name, phases };
106
+ if (typeof raw.description === "string") flow.description = raw.description;
107
+ return { ok: true, flow };
108
+ }
109
+
110
+ /** Bind a parsed flow to the live strategy registry + team names. `parseFlow` is
111
+ * structural (JSON + DAG); this is the discovery check so a typo cannot start a run. */
112
+ export function verifyFlowRefs(
113
+ flow: FlowSpec,
114
+ known: { strategies: readonly string[]; teams: ReadonlySet<string> },
115
+ ): FlowParse {
116
+ for (const phase of flow.phases) {
117
+ if (!known.strategies.includes(phase.strategy)) {
118
+ return { ok: false, error: `unknown strategy "${phase.strategy}"` };
119
+ }
120
+ if (phase.roster && !known.teams.has(phase.roster)) {
121
+ return { ok: false, error: `unknown roster "${phase.roster}"` };
122
+ }
123
+ }
124
+ return { ok: true, flow };
125
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Judge preparation (§4.3) — the impartial-judge counterpart to `vote`. Voting tallies
3
+ * the candidates' own votes; judging hands the candidates to ONE judge agent that picks.
4
+ * The bias guard is that the judge must see them **anonymised** (no author identity) and,
5
+ * for production, **reordered** (so position can't bias the pick). This module is the PURE
6
+ * part: render an anonymised, labelled ballot and resolve the judge's chosen label back to
7
+ * the real candidate. A judge strategy then runs `agent(judge, { task: prep.ballot })` and
8
+ * maps its verdict via `prep.pick(...)` — no new engine surface needed.
9
+ */
10
+
11
+ import { fenceUntrusted } from "../core/fence.ts";
12
+ import type { AgentResult } from "./types.ts";
13
+
14
+ export interface JudgePrep {
15
+ /** The candidates rendered anonymised + reordered, each under a `[A]`/`[B]`… label. */
16
+ ballot: string;
17
+ /** Resolve a judge's chosen label (case-insensitive) back to the original candidate. */
18
+ pick(label: string): AgentResult | undefined;
19
+ }
20
+
21
+ const LABELS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
22
+
23
+ /**
24
+ * The judge's vote reduced to a ballot label. Models routinely answer "Candidate B" or "B."
25
+ * where the ballot asked for "B", so a bare single-letter token is read as the pick.
26
+ *
27
+ * The candidate letters are filtered against the labels ACTUALLY on this ballot, and an
28
+ * ambiguous vote (two different labels named, e.g. "A or B?") resolves to nothing. Guessing
29
+ * here is worse than failing: the no-pick path already returns the panel (or, for compete,
30
+ * every valid diff) — a refusal loses nothing, while a wrong guess applies the wrong work.
31
+ */
32
+ export function ballotLabel(vote: string, onBallot: number): string {
33
+ const bare = vote.trim().toUpperCase();
34
+ if (bare.length <= 1) return bare;
35
+ const labels = new Set(LABELS.slice(0, Math.min(onBallot, LABELS.length)));
36
+ const named = [...new Set(bare.split(/[^A-Z]+/).filter((t) => t.length === 1 && labels.has(t)))];
37
+ return named.length === 1 ? named[0]! : bare;
38
+ }
39
+
40
+ function isPermutation(order: number[], n: number): boolean {
41
+ if (order.length !== n) return false;
42
+ const seen = new Set(order);
43
+ if (seen.size !== n) return false;
44
+ return order.every((i) => Number.isInteger(i) && i >= 0 && i < n);
45
+ }
46
+
47
+ /** Anonymise + label (+ optionally reorder) candidates for an impartial LLM judge. `order`
48
+ * is a permutation of candidate indices (ballot position → candidate); production passes a
49
+ * shuffle, tests pass a fixed permutation. Defaults to candidate order. A malformed `order`
50
+ * (wrong length / not a permutation) is ignored rather than silently becoming identity —
51
+ * identity would drop the reorder half of the bias guard. */
52
+ export function prepareJudge(candidates: AgentResult[], order?: number[]): JudgePrep {
53
+ const perm = order && isPermutation(order, candidates.length) ? order : candidates.map((_, i) => i);
54
+ const byLabel = new Map<string, AgentResult>();
55
+ const sections: string[] = [];
56
+ perm.forEach((candIdx, pos) => {
57
+ const c = candidates[candIdx];
58
+ if (!c) return;
59
+ const label = LABELS[pos] ?? `#${pos + 1}`;
60
+ byLabel.set(label, c);
61
+ sections.push(`[${label}]\n${fenceUntrusted(c.output.trim())}`);
62
+ });
63
+ return {
64
+ ballot: sections.join("\n\n"),
65
+ pick: (label) => byLabel.get(ballotLabel(label, byLabel.size)),
66
+ };
67
+ }
68
+
69
+ /** A random permutation of [0..n) — so a judge can't be biased by candidate order.
70
+ * Production ballots pass this to {@link prepareJudge}; tests pass a fixed permutation. */
71
+ export function shuffleOrder(n: number): number[] {
72
+ const a = Array.from({ length: n }, (_, i) => i);
73
+ for (let i = n - 1; i > 0; i--) {
74
+ const j = Math.floor(Math.random() * (i + 1));
75
+ [a[i], a[j]] = [a[j] as number, a[i] as number];
76
+ }
77
+ return a;
78
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Peer-model retry policy — when one council member fails for a MODEL reason, which model
3
+ * should it be re-run on?
4
+ *
5
+ * This is the roster-level complement to `engine/fallback.ts`. That decorator reroutes the SAME
6
+ * model id to a family-compatible provider (anthropic/x → bedrock/x) and knows nothing about the
7
+ * other members; this decides to hand a broken core a DIFFERENT model that a sibling just proved
8
+ * works in this very session. The two compose: the engine exhausts provider routes first, and only
9
+ * a run that still comes back `provider`/`unknown-model` reaches here.
10
+ *
11
+ * The order of preference is evidence, strongest first:
12
+ * 1. the session's own model, when a peer proved it — the user is running on it, so it is the
13
+ * most likely to be authed, in budget, and available;
14
+ * 2. otherwise the first healthy peer's model in roster order (deterministic, not iteration order);
15
+ * 3. otherwise the session model unproven — the case where the failing core is the first or the
16
+ * only one left, so no peer evidence exists at all.
17
+ *
18
+ * A council's whole value is UNCORRELATED errors from distinct reasoners, so this deliberately
19
+ * never runs as a preference: it is what keeps a broken core from silently dropping out of the
20
+ * vote, and callers are expected to surface it. Pure — no engine, no I/O, no clock.
21
+ */
22
+
23
+ import type { AgentResult, FailureKind } from "./types.ts";
24
+
25
+ /** The only failures a DIFFERENT model can fix. `provider` is the provider rejecting or breaking
26
+ * (auth, outage, 5xx, model-not-supported); `unknown-model` is a ref that does not resolve at all.
27
+ * Everything else — a user stop, an idle timeout, a contract violation, the agent's own error —
28
+ * reproduces identically on any model, so retrying only burns tokens. `abort` above all must never
29
+ * retry: a stop that silently respawned work would be a stop that does not stop. */
30
+ export const RETRYABLE_MODEL_FAILURES = ["provider", "unknown-model"] as const satisfies readonly FailureKind[];
31
+
32
+ const retryable = new Set<string>(RETRYABLE_MODEL_FAILURES);
33
+
34
+ export interface ModelRetry {
35
+ /** Position in the round's results, so the caller can re-run exactly this member. */
36
+ index: number;
37
+ agent: string;
38
+ /** The model that just failed, when the engine resolved one. */
39
+ from?: string;
40
+ /** The model to re-run on — never the one that just failed. */
41
+ model: string;
42
+ /** Which evidence chose it, for an honest breadcrumb in the log and the tree. */
43
+ reason: "peer" | "session";
44
+ }
45
+
46
+ export interface ModelRetryDeps {
47
+ /** The model the user's own session runs on (`provider/id`), when known. */
48
+ sessionModel?: string;
49
+ }
50
+
51
+ /**
52
+ * Plan at most one retry per failed member. Returns an empty list when nothing is worth
53
+ * re-running — no model-caused failure, or no model available that differs from the one that
54
+ * already failed.
55
+ */
56
+ export function planModelRetries(results: readonly AgentResult[], deps: ModelRetryDeps): ModelRetry[] {
57
+ const healthy = results.filter((r) => r.ok && r.modelUsed).map((r) => r.modelUsed as string);
58
+ const session = deps.sessionModel?.trim() || undefined;
59
+ // A peer's model is evidence; the session model is evidence too when a peer used it, and a
60
+ // reasonable guess when nothing survived to prove anything.
61
+ const candidates = session && healthy.includes(session)
62
+ ? [session, ...healthy.filter((model) => model !== session)]
63
+ : healthy.length > 0
64
+ ? healthy
65
+ : session
66
+ ? [session]
67
+ : [];
68
+
69
+ const plan: ModelRetry[] = [];
70
+ results.forEach((result, index) => {
71
+ if (result.ok || !result.failureKind || !retryable.has(result.failureKind)) return;
72
+ // Re-running on the model that just failed cannot produce a different outcome. Keep walking:
73
+ // a later healthy peer may still provide a distinct model worth trying.
74
+ const candidate = candidates.find((model) => model !== result.modelUsed);
75
+ if (!candidate) return;
76
+ plan.push({
77
+ index,
78
+ agent: result.agent,
79
+ ...(result.modelUsed ? { from: result.modelUsed } : {}),
80
+ model: candidate,
81
+ reason: healthy.includes(candidate) ? "peer" : "session",
82
+ });
83
+ });
84
+ return plan;
85
+ }