@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,239 @@
1
+ /**
2
+ * magi — the MagiSystem. Run a roster of distinct-persona "systems" on the same
3
+ * question in parallel, each returning a structured vote, then decide by
4
+ * majority (or unanimity), surfacing the tally and a minority report. Diversity
5
+ * comes from the roster's personas; bias guards live in the vote reducer.
6
+ *
7
+ * Reflection round (params.reflect, default ON): after the independent round, each
8
+ * core sees the OTHER cores' positions **anonymised** and casts a FINAL vote —
9
+ * revising only if a genuinely new consideration moves it (holding is fine). This
10
+ * lets a core catch a blind spot without turning MAGI into groupthink: the positions
11
+ * are anonymised (no "defer to Casper" authority bias), it is exactly ONE round (not
12
+ * iterate-to-consensus — that is `council-rounds`), and dissent is always preserved.
13
+ * Set `reflect: false` for a pure independent poll (uncorrelated errors, cheapest).
14
+ */
15
+
16
+ import { fenceUntrusted } from "../../core/fence.ts";
17
+ import { planModelRetries } from "../model-retry.ts";
18
+ import { sumUsage, summarizeFailedResults } from "../reducers.ts";
19
+ import { dissentLine, readableRuling as readable } from "../render.ts";
20
+ import type { Strategy, StrategySDK } from "../sdk.ts";
21
+ import type { AgentResult } from "../types.ts";
22
+ import { type RosterMember, rosterSpec } from "../roster.ts";
23
+
24
+ const LABELS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
25
+
26
+ /** A poll the run cancelled — distinct from one that finished without a ruling, so a
27
+ * journal or supervisor records it as cancelled rather than as a completed failure. */
28
+ function cancelled(usages: AgentResult["usage"][]): AgentResult {
29
+ const output = "MAGI cancelled — the run was aborted before a ruling.";
30
+ return {
31
+ agent: "magi",
32
+ output,
33
+ structured: { status: "cancelled", reflected: false, headline: output },
34
+ usage: sumUsage(usages),
35
+ ok: false,
36
+ error: "the run was aborted",
37
+ failureKind: "abort",
38
+ };
39
+ }
40
+
41
+ interface Recovery {
42
+ /** Round 1 with each successful recovery spliced into its original roster position. */
43
+ results: AgentResult[];
44
+ /** Every retry leg that actually ran, so usage can bill what the run really spent. */
45
+ attempts: AgentResult[];
46
+ /** Roster index → the model that rescued it. MUST be carried into every LATER round: rebuilding a
47
+ * spec from the roster alone would put the core straight back on the model that just broke. */
48
+ models: Map<number, string>;
49
+ }
50
+
51
+ /**
52
+ * Re-run the cores whose MODEL broke on one that this run proved works, and splice the recoveries
53
+ * back into their original roster positions. Returns `round1` untouched when nothing qualifies.
54
+ *
55
+ * Exactly one pass: a core that fails again keeps its second failure and drops out of the vote
56
+ * honestly, rather than looping. The abort signal is checked first, so a stopped run recovers
57
+ * nothing.
58
+ */
59
+ async function recoverBrokenModels(
60
+ round1: AgentResult[],
61
+ team: RosterMember[],
62
+ task: string,
63
+ sdk: StrategySDK,
64
+ affordable: number,
65
+ ): Promise<Recovery> {
66
+ const nothing = { results: round1, attempts: [], models: new Map<number, string>() };
67
+ if (sdk.signal?.aborted || affordable <= 0) return nothing;
68
+ const wanted = planModelRetries(round1, { ...(sdk.sessionModel ? { sessionModel: sdk.sessionModel } : {}) });
69
+ if (wanted.length === 0) return nothing;
70
+ // Recovery is an ENHANCEMENT and must not spend the budget the rounds themselves need: exceeding
71
+ // `maxChildren` throws out of sdk.agent(), which would turn a poll that had a ruling into no
72
+ // ruling at all. Take what the run can afford, oldest-first, and say what was left behind.
73
+ const plan = wanted.slice(0, affordable);
74
+ for (const dropped of wanted.slice(affordable)) {
75
+ sdk.log(`magi: ${dropped.agent} stays failed — no child budget left to retry it (maxChildren ${sdk.limits.maxChildren})`);
76
+ }
77
+ const recovered = [...round1];
78
+ const attempts: AgentResult[] = [];
79
+ const models = new Map<number, string>();
80
+ let observedTokens = round1.reduce((total, result) => total + result.usage.input + result.usage.output, 0);
81
+ for (let i = 0; i < plan.length; i++) {
82
+ const retry = plan[i] as (typeof plan)[number];
83
+ if (sdk.signal?.aborted) break;
84
+ if (sdk.limits.budgetTokens > 0 && observedTokens >= sdk.limits.budgetTokens) {
85
+ for (const dropped of plan.slice(i)) {
86
+ sdk.log(`magi: ${dropped.agent} stays failed — token budget exhausted (${sdk.limits.budgetTokens})`);
87
+ }
88
+ break;
89
+ }
90
+ sdk.log(`magi: ${retry.agent} failed on ${retry.from ?? "its model"} — retrying on ${retry.model} (${retry.reason === "peer" ? "worked for a peer" : "session model"})`);
91
+ const member = team[retry.index];
92
+ // A member is always present: the plan indexes the round the team produced. Retries are
93
+ // deliberately serial so each completed attempt updates the observable token remainder before
94
+ // another billed leg is allowed to start.
95
+ const attempt = await sdk.agent({ ...rosterSpec(member as RosterMember), model: retry.model, task, outputContract: "default" });
96
+ attempts.push(attempt);
97
+ observedTokens += attempt.usage.input + attempt.usage.output;
98
+ // Keep the ORIGINAL failure when the retry failed too: the first error is the one that
99
+ // describes the core's own model, which is what a reader needs to fix the roster — and a
100
+ // twice-failed core keeps NO override, so it re-runs on its own model and fails honestly.
101
+ if (!attempt.ok) continue;
102
+ recovered[retry.index] = attempt;
103
+ models.set(retry.index, retry.model);
104
+ }
105
+ // Every attempt is returned separately: a recovery REPLACES a failed result, so summing the
106
+ // final array alone would bill the run for less than it actually spent.
107
+ return { results: recovered, attempts, models };
108
+ }
109
+
110
+ export const magi: Strategy = {
111
+ name: "magi",
112
+ params: {
113
+ aggregate: { type: "string", default: "majority", doc: '"majority" | "unanimity"' },
114
+ reflect: { type: "boolean", default: true, doc: "one anonymised reflection round" },
115
+ },
116
+ async run(input, sdk) {
117
+ const team = input.roster ? sdk.roster.team(input.roster) : [];
118
+ if (team.length === 0) throw new Error("magi: a roster of voting personas is required");
119
+ if (sdk.signal?.aborted) return cancelled([]);
120
+ const aggregate = input.params.aggregate === "unanimity" ? "unanimity" : "majority";
121
+ const reflect = input.params.reflect !== false; // default ON — one informed round
122
+ sdk.log(`magi: ${team.length} systems, ${aggregate} vote${reflect ? " + reflection" : ""}`);
123
+
124
+ // Round 1 — each core answers INDEPENDENTLY (uncorrelated errors: the whole point).
125
+ const round1 = await sdk.parallel(
126
+ team.map((m) => () => sdk.agent({ ...rosterSpec(m), task: input.task, outputContract: "default" })),
127
+ );
128
+
129
+ // A core whose MODEL broke is not a dissenting voice, it is a missing one — and a poll of two
130
+ // is a materially weaker poll. Re-run just those cores, ONCE, on a model this run proved works
131
+ // (a healthy peer's, else the session's own). Only provider/unknown-model failures qualify:
132
+ // an abort or a contract violation reproduces on any model, and a retried abort would be a
133
+ // stop that does not stop. Diversity is the council's whole point, so this never runs as a
134
+ // preference — it is the difference between a degraded poll and a lost vote, and it is logged.
135
+ // Round 1 has spent one child per member, and the reflection round (when it runs) will spend
136
+ // another; whatever is left is what recovery may use.
137
+ const reserved = team.length * (reflect ? 2 : 1);
138
+ const recovery = await recoverBrokenModels(round1, team, input.task, sdk, sdk.limits.maxChildren - reserved);
139
+ const opening = recovery.results;
140
+
141
+ // An abort settles every core as ok:false/'abort' instead of throwing, so a stop that
142
+ // landed while round 1 was running is first visible here — without this MAGI re-polls the
143
+ // whole roster for a reflection round nobody will read, then reports a "no ruling".
144
+ if (sdk.signal?.aborted || round1.every((c) => c.failureKind === "abort")) {
145
+ return cancelled([...round1, ...recovery.attempts].map((c) => c.usage));
146
+ }
147
+
148
+ let candidates = opening;
149
+ const okCount = opening.filter((c) => c.ok).length;
150
+ const tokensBeforeReflection = [...round1, ...recovery.attempts].reduce(
151
+ (total, result) => total + result.usage.input + result.usage.output,
152
+ 0,
153
+ );
154
+ // A reflection is meaningful only when every core can participate. Under an active token
155
+ // budget, reserve the observable minimum of one token per leg instead of starting a partial
156
+ // round (or letting every parallel leg race past an already exhausted SDK counter).
157
+ const reflectionHasTokenRoom =
158
+ sdk.limits.budgetTokens <= 0 || tokensBeforeReflection + team.length <= sdk.limits.budgetTokens;
159
+ const didReflect = reflect && okCount >= 2 && reflectionHasTokenRoom;
160
+ if (reflect && okCount >= 2 && !reflectionHasTokenRoom) {
161
+ sdk.log(`magi: reflection skipped — token budget cannot fund the full panel (${sdk.limits.budgetTokens})`);
162
+ }
163
+ if (didReflect) {
164
+ // Round 2 — each core sees the others' positions ANONYMISED (no author identity, so a
165
+ // core can't defer to a "senior" peer) and casts its FINAL vote. Instructed to hold
166
+ // unless genuinely moved, so this informs without manufacturing false consensus.
167
+ const positions = opening
168
+ .filter((c) => c.ok)
169
+ .map((c, i) => `[Position ${LABELS[i] ?? `#${i + 1}`}]\n${fenceUntrusted(readable(c))}`)
170
+ .join("\n\n");
171
+ const reflectTask =
172
+ `${input.task}\n\n--- the panel's positions so far (anonymised — judge them on merit, not source) ---\n${positions}\n\n` +
173
+ `Reconsider ONLY if one of these raises a consideration that genuinely changes your analysis — it is perfectly fine to hold your original position through your own lens. ` +
174
+ `Then cast your FINAL vote.`;
175
+ // The recovered model must survive into this round: rebuilding from `rosterSpec(m)` alone
176
+ // would hand the rescued core back the model that just broke, so it fails again, is
177
+ // quarantined as invalid, and the recovery buys nothing but tokens.
178
+ candidates = await sdk.parallel(
179
+ team.map((m, i) => () => {
180
+ const model = recovery.models.get(i);
181
+ return sdk.agent({ ...rosterSpec(m), ...(model ? { model } : {}), task: reflectTask, outputContract: "default" });
182
+ }),
183
+ );
184
+ // Same reasoning as round 1, and both clauses earn their place. `sdk.signal` is wired
185
+ // (the council/flow tools pass Pi's tool-execution signal down), but a stop that lands
186
+ // DURING the reflection round is visible here only as every core settling
187
+ // `failureKind: "abort"` — as is one on a caller that passed no signal. Without the
188
+ // second clause a stopped run reports a normal-looking "no ruling" from a killed poll.
189
+ if (sdk.signal?.aborted || candidates.every((c) => c.failureKind === "abort")) {
190
+ return cancelled([...round1, ...recovery.attempts, ...candidates].map((c) => c.usage));
191
+ }
192
+ }
193
+ const decision = sdk.reduce.vote(candidates, { aggregate, keepBestFallback: true });
194
+
195
+ // Lead with the ruling (the answer); the decision/tally plumbing is a compact footer,
196
+ // not the headline. The collapsed council card shows `headline`; the supervisor still
197
+ // receives the full text (ruling + dissent + footer).
198
+ const lines: string[] = [];
199
+ if (decision.winner) lines.push(readable(decision.winner));
200
+ if (decision.dissent && decision.dissent.length > 0) {
201
+ lines.push(`\n--- dissent (minority report) ---\n${decision.dissent.map(dissentLine).join("\n\n")}`);
202
+ }
203
+ const tally = Object.entries(decision.tally).map(([k, v]) => `${k}=${v}`).join(", ") || "—";
204
+ const invalid = decision.invalid && decision.invalid.length > 0 ? ` · ${decision.invalid.length} invalid excluded` : "";
205
+ // State the recovery in the RULING, not only through sdk.log — which no production caller
206
+ // sinks. A reader must be able to tell that a core argued on a borrowed model, because that
207
+ // is exactly the diversity the tally silently lost.
208
+ const rescued = recovery.models.size > 0
209
+ ? ` · ${recovery.models.size} core${recovery.models.size === 1 ? "" : "s"} recovered on ${[...new Set(recovery.models.values())].join(", ")}`
210
+ : "";
211
+ lines.push(
212
+ `\n— magi: ${decision.status}${didReflect ? " (after 1 reflection round)" : ""}${decision.usedFallback ? " · fell back to best-by-confidence" : ""} · tally ${tally}${invalid}${rescued}`,
213
+ );
214
+
215
+ const winnerResult = decision.winner?.structured?.result;
216
+ const headline = decision.winner
217
+ ? typeof winnerResult === "string" && winnerResult.trim()
218
+ ? winnerResult.trim()
219
+ : readable(decision.winner).split("\n")[0] ?? decision.status
220
+ : decision.status;
221
+
222
+ // Usage sums EVERY run the poll actually paid for: the opening round, any model recovery,
223
+ // and the reflection round (separate from the opening one when reflection ran).
224
+ const allRuns: AgentResult[] = didReflect ? [...round1, ...recovery.attempts, ...candidates] : [...round1, ...recovery.attempts];
225
+ const result: AgentResult = {
226
+ agent: "magi",
227
+ output: lines.join("\n"),
228
+ structured: { status: decision.status, tally: decision.tally, usedFallback: decision.usedFallback, reflected: didReflect, headline },
229
+ usage: sumUsage(allRuns.map((c) => c.usage)),
230
+ ok: decision.winner !== undefined,
231
+ };
232
+ if (!result.ok) {
233
+ const cause = summarizeFailedResults(candidates, "magi produced no ruling");
234
+ result.error = cause.error;
235
+ result.failureKind = cause.failureKind;
236
+ }
237
+ return result;
238
+ },
239
+ };
@@ -0,0 +1,327 @@
1
+ /**
2
+ * map — dynamic fan-out over a RUNTIME list (vs `fanout`'s fixed roster). A splitter agent
3
+ * breaks the task into independent sub-items, then a worker runs once per item in parallel
4
+ * (bounded by the run limits / `params.maxItems`), and the results aggregate. The map-reduce
5
+ * shape for "review each changed file", "summarise each doc", etc. Built on the SDK + the
6
+ * shared JSON extractor — no new engine surface.
7
+ *
8
+ * roster = [splitter, worker] (worker defaults to the splitter if only one is given)
9
+ * params = { maxItems?: number, peers?: boolean (workers share load-bearing cross-item
10
+ * discoveries live via contact_peer — default off), ownership?: "off"|"declare"|"enforce",
11
+ * verify?: string (agent that re-checks each completed item; empty = off) }
12
+ */
13
+
14
+ import { extractJsonCandidate } from "../../core/contract.ts";
15
+ import { fenceUntrusted } from "../../core/fence.ts";
16
+ import { cappedList } from "../../core/format.ts";
17
+ import { validateParallelWriteSets } from "../../core/ownership.ts";
18
+ import type { ChildUsage } from "../../engine/stream.ts";
19
+ import { itemLedger, type ItemLedgerEntry, sumUsage } from "../reducers.ts";
20
+ import { rosterSpec, type RosterMember, type RosterSpec } from "../roster.ts";
21
+ import type { Strategy, StrategySDK } from "../sdk.ts";
22
+ import type { AgentResult, FailureKind } from "../types.ts";
23
+
24
+ // Cooperative cross-talk (params.peers): workers share load-bearing cross-item discoveries
25
+ // live. Injected into the TASK text (not the role) so UI tree keys stay stable.
26
+ const CROSS_TALK = [
27
+ "You have sibling workers on OTHER sub-items of this same batch. If you discover something",
28
+ "load-bearing that likely applies to their items too (a shared gotcha, a convention, a blocker),",
29
+ "share it once via `contact_peer` (action `list`, then `send`) — short and factual. Incorporate",
30
+ 'any "[message from peer …]" notes you receive. No chatter: only load-bearing findings.',
31
+ ].join(" ");
32
+
33
+ const NOT_RUN_NAME_CAP = 5;
34
+
35
+ export interface ParsedItem {
36
+ item: string;
37
+ writeSet?: string[];
38
+ }
39
+
40
+ /** Every ```-fenced block's contents, last fence first (the model, told to "Return ONLY a JSON
41
+ * array", most often either answers bare or wraps that same array in one ```/```json fence).
42
+ * A miniature, map-local copy of `contract.ts`'s private `fencedBlocks` — that module's helpers
43
+ * aren't exported, and this one only ever needs the fence, never the object-preferring picker
44
+ * built on top of it there. */
45
+ function fencedBlocks(text: string): string[] {
46
+ const out: string[] = [];
47
+ const re = /```[^\n`]*\n([\s\S]*?)```/g;
48
+ let m = re.exec(text);
49
+ while (m !== null) {
50
+ if (m[1] !== undefined) out.push(m[1].trim());
51
+ m = re.exec(text);
52
+ }
53
+ return out.reverse();
54
+ }
55
+
56
+ /** Parse a splitter's output into a list of items (tolerant of fences/prose). Each entry is
57
+ * either a plain string sub-item, or an object declaring `{ item, writeSet? }` — any other
58
+ * shape falls back to its JSON text as the item, exactly like before this field existed.
59
+ *
60
+ * Tries a direct parse of the trimmed output, then of each fenced block, accepting only a
61
+ * candidate that parses to an ARRAY — `extractJsonCandidate` (src/core/contract.ts) is built
62
+ * for contracts, which always want an OBJECT, so among several parseable candidates it prefers
63
+ * one that starts with `{`. That is the right call for a contract answer, but wrong here: an
64
+ * `{item, writeSet}` element nested inside the requested top-level ARRAY would win and shadow
65
+ * the whole list — including when that array sits inside a fence, the most likely shape a
66
+ * model actually emits. `extractJsonCandidate` only comes in as a last-resort fallback, for
67
+ * prose-wrapped output with no fence (where, absent a nested object item, its preference never
68
+ * triggers). */
69
+ function parseItems(output: string): ParsedItem[] {
70
+ let parsed: unknown;
71
+ let found = false;
72
+ for (const candidate of [output.trim(), ...fencedBlocks(output)]) {
73
+ try {
74
+ const value: unknown = JSON.parse(candidate);
75
+ if (Array.isArray(value)) {
76
+ parsed = value;
77
+ found = true;
78
+ break;
79
+ }
80
+ } catch {
81
+ // try the next candidate
82
+ }
83
+ }
84
+ if (!found) {
85
+ try {
86
+ parsed = JSON.parse(extractJsonCandidate(output));
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+ if (!Array.isArray(parsed)) return [];
92
+ return parsed
93
+ .map((x): ParsedItem => {
94
+ if (typeof x === "string") return { item: x };
95
+ if (x && typeof x === "object" && !Array.isArray(x)) {
96
+ const obj = x as Record<string, unknown>;
97
+ const writeSetOk = obj.writeSet === undefined || (Array.isArray(obj.writeSet) && obj.writeSet.every((p) => typeof p === "string"));
98
+ if (typeof obj.item === "string" && writeSetOk) {
99
+ return obj.writeSet !== undefined ? { item: obj.item, writeSet: obj.writeSet as string[] } : { item: obj.item };
100
+ }
101
+ }
102
+ return { item: JSON.stringify(x) };
103
+ })
104
+ .filter((p) => p.item.trim());
105
+ }
106
+
107
+ /** The verifier's run spec: the roster's own entry for that agent name (its role/model/tools
108
+ * specialisation applies) when `params.verify` names a roster member, else a bare `rosterSpec`
109
+ * for a stand-alone agent named directly in the param. */
110
+ function verifierSpec(team: readonly RosterMember[], name: string) {
111
+ const member = team.find((m) => rosterSpec(m).agent === name);
112
+ return rosterSpec(member ?? name);
113
+ }
114
+
115
+ /** Read a verifier leg's verdict. Reuses the SAME `outputContract: "default"` machinery
116
+ * `critic-loop` reads its critic's stance from (`structured.stance`) rather than inventing a
117
+ * new parser — "approve" passes, anything else (an explicit "reject"/"revise", a missing
118
+ * stance, or invalid structured output) fails the item. A verifier leg that itself couldn't
119
+ * run (`ok: false` — provider/timeout/abort/contract) is a distinct case: that's not a verdict
120
+ * at all, so its OWN `failureKind` carries through instead of `"verification"`, which is
121
+ * reserved for an actual negative (or absent) verdict. Returns `undefined` on a pass. */
122
+ function verifyOutcome(v: AgentResult): { failureKind: FailureKind; error: string } | undefined {
123
+ if (!v.ok) return { failureKind: v.failureKind ?? "agent", error: v.error || "the verifier failed to run" };
124
+ if (v.structured?.stance === "approve") return undefined;
125
+ const reason = (typeof v.structured?.result === "string" && v.structured.result.trim()) || v.output.trim() || "the verifier withheld approval";
126
+ return { failureKind: "verification", error: reason };
127
+ }
128
+
129
+ type SplitOutcome =
130
+ | { ok: true; allItems: ParsedItem[]; items: ParsedItem[]; dropped: number; usage: ChildUsage }
131
+ | { ok: false; result: AgentResult };
132
+
133
+ /** Run the splitter agent and parse its output into the runtime item list, clamped to
134
+ * `maxItems`. A splitter failure, or a split that produced no usable items, is a terminal
135
+ * `AgentResult` — the whole run fails before any worker spawns. */
136
+ async function splitIntoItems(sdk: StrategySDK, splitter: RosterSpec, task: string, maxItems: number): Promise<SplitOutcome> {
137
+ const split = await sdk.agent({
138
+ ...splitter,
139
+ task: `Break this task into independent sub-items. Return ONLY a JSON array of short strings — one per sub-item, nothing else.\n\nTask: ${task}`,
140
+ });
141
+ if (!split.ok) {
142
+ return {
143
+ ok: false,
144
+ result: {
145
+ agent: "map",
146
+ output: split.output || split.error || "(splitter failed)",
147
+ usage: split.usage,
148
+ ok: false,
149
+ ...(split.error ? { error: split.error } : {}),
150
+ ...(split.failureKind ? { failureKind: split.failureKind } : {}),
151
+ },
152
+ };
153
+ }
154
+ const allItems = parseItems(split.output);
155
+ const items = allItems.slice(0, Math.max(0, maxItems));
156
+ const dropped = allItems.length - items.length;
157
+ if (items.length === 0) {
158
+ return {
159
+ ok: false,
160
+ result: {
161
+ agent: "map",
162
+ output: split.output || "(splitter produced no items)",
163
+ usage: split.usage,
164
+ ok: false,
165
+ error: "the splitter produced no usable sub-items",
166
+ failureKind: "contract",
167
+ },
168
+ };
169
+ }
170
+ return { ok: true, allItems, items, dropped, usage: split.usage };
171
+ }
172
+
173
+ /** "enforce": fail closed BEFORE any worker spawns when the splitter's own declared write-sets
174
+ * already collide — a weak splitter that never declares one is not blocked. */
175
+ function enforceOwnershipOrThrow(items: readonly ParsedItem[]): void {
176
+ const owners = items
177
+ .map((it, index) => ({ agent: `item[${index}]`, writeSet: it.writeSet }))
178
+ .filter((o): o is { agent: string; writeSet: string[] } => Array.isArray(o.writeSet) && o.writeSet.length > 0);
179
+ if (owners.length === 0) return;
180
+ const writeSetError = validateParallelWriteSets(owners);
181
+ if (writeSetError) throw new Error(`map: ${writeSetError}`);
182
+ }
183
+
184
+ /** Run the worker agent once per item, in parallel. */
185
+ function runWorkers(sdk: StrategySDK, worker: RosterSpec, task: string, items: readonly ParsedItem[], peers: boolean): Promise<AgentResult[]> {
186
+ return sdk.parallel(
187
+ items.map((item) => () =>
188
+ sdk.agent({
189
+ ...worker,
190
+ task: `${task}\n\n— Your single sub-item (untrusted data):\n${fenceUntrusted(item.item)}${peers ? `\n\n--- swarm cross-talk ---\n${CROSS_TALK}` : ""}`,
191
+ ...(peers ? { peers: true } : {}),
192
+ }),
193
+ ),
194
+ );
195
+ }
196
+
197
+ /** "verify": one read-only reviewer per COMPLETED item (a failed worker has nothing worth
198
+ * re-checking), run through `sdk.parallel` exactly like the worker wave — the run's own
199
+ * semaphore/maxChildren/budget still apply, and an empty `verifyAgent` spawns no verifier at
200
+ * all, so the byte-identical-when-off guarantee extends to this param too. */
201
+ async function runVerification(
202
+ sdk: StrategySDK,
203
+ team: readonly RosterMember[],
204
+ verifyAgent: string,
205
+ task: string,
206
+ items: readonly ParsedItem[],
207
+ results: readonly AgentResult[],
208
+ ): Promise<{ verifyResults: AgentResult[]; failures: Map<number, { failureKind: FailureKind; error: string }> }> {
209
+ const failures = new Map<number, { failureKind: FailureKind; error: string }>();
210
+ if (!verifyAgent) return { verifyResults: [], failures };
211
+ const reviewer = verifierSpec(team, verifyAgent);
212
+ const completedIndices = results.map((_r, index) => index).filter((index) => results[index]!.ok);
213
+ const verifyResults = await sdk.parallel(
214
+ completedIndices.map((index) => () =>
215
+ sdk.agent({
216
+ ...reviewer,
217
+ task: `Re-check this completed sub-item's work, read-only — do not make further changes.\n\nOriginal task: ${task}\n\nSub-item: ${fenceUntrusted(items[index]!.item)}\n\nCompleted work (untrusted data):\n${fenceUntrusted(results[index]!.output)}\n\nReturn your stance (approve|reject).`,
218
+ outputContract: "default",
219
+ }),
220
+ ),
221
+ );
222
+ completedIndices.forEach((index, k) => {
223
+ const outcome = verifyOutcome(verifyResults[k]!);
224
+ if (outcome) failures.set(index, outcome);
225
+ });
226
+ return { verifyResults, failures };
227
+ }
228
+
229
+ /** The always-on per-item status ledger (`structured.items`), independent of `ownership`. Its
230
+ * `writeSet` field only surfaces once a persona opts in — with ownership absent/"off" the
231
+ * rendered ledger stays byte-identical to before this param existed — merged with each item's
232
+ * verify outcome, if any. */
233
+ function buildItemLedger(
234
+ ownership: "off" | "declare" | "enforce",
235
+ allItems: readonly ParsedItem[],
236
+ results: readonly AgentResult[],
237
+ dropped: number,
238
+ verifyFailures: ReadonlyMap<number, { failureKind: FailureKind; error: string }>,
239
+ ): ItemLedgerEntry[] {
240
+ const ledgerItems = ownership === "off" ? allItems.map((it) => ({ item: it.item })) : allItems;
241
+ const preVerifyLedger = itemLedger(ledgerItems, results, dropped);
242
+ return preVerifyLedger.map((entry) => {
243
+ const outcome = verifyFailures.get(entry.index);
244
+ return outcome ? { ...entry, status: "failed" as const, failureKind: outcome.failureKind, error: outcome.error } : entry;
245
+ });
246
+ }
247
+
248
+ export const map: Strategy = {
249
+ name: "map",
250
+ params: {
251
+ maxItems: { type: "number", doc: "default AND ceiling: the run's maxChildren, less the splitter's own slot" },
252
+ peers: { type: "boolean", default: false, doc: "workers share load-bearing cross-item discoveries live" },
253
+ ownership: {
254
+ type: "string",
255
+ default: "off",
256
+ doc: "off | declare | enforce — how the splitter's per-item writeSet is used: ignored, recorded in the item ledger, or checked for overlaps before any worker spawns",
257
+ },
258
+ verify: {
259
+ type: "string",
260
+ default: "",
261
+ doc: "agent that re-checks each completed item read-only; empty = no verification pass. Costs one extra child per completed item",
262
+ },
263
+ },
264
+ async run(input, sdk) {
265
+ const team = input.roster ? sdk.roster.team(input.roster) : [];
266
+ const splitterMember = team[0];
267
+ if (!splitterMember) throw new Error("map: a roster with at least a splitter agent is required");
268
+ const splitter = rosterSpec(splitterMember);
269
+ const worker = team[1] ? rosterSpec(team[1]) : splitter;
270
+ const peers = input.params.peers === true;
271
+ // Unknown values behave as "off" — lenient (I2: strategies are trusted project code), a
272
+ // typo in a persona's params never blocks a run, it just skips the extra observability.
273
+ const ownershipParam = input.params.ownership;
274
+ const ownership = ownershipParam === "declare" || ownershipParam === "enforce" ? ownershipParam : "off";
275
+ const verifyAgent = typeof input.params.verify === "string" ? input.params.verify.trim() : "";
276
+ // The splitter spends one child slot before any worker runs, so the worker cap is one
277
+ // BELOW maxChildren — at the cap the last worker would trip the run's pre-spawn guard
278
+ // and take the whole (mostly finished) fan-out down with it. When `verify` is set, the
279
+ // verifier wave asks for ONE MORE child per completed item on top of that, so the cap is
280
+ // halved instead — splitter + workers + verifiers must all fit under the same ceiling.
281
+ const workerSlots = Math.max(1, sdk.limits.maxChildren - 1);
282
+ const cap = verifyAgent ? Math.max(1, Math.floor((sdk.limits.maxChildren - 1) / 2)) : workerSlots;
283
+ const maxItems = Math.min(typeof input.params.maxItems === "number" ? input.params.maxItems : cap, cap);
284
+
285
+ const split = await splitIntoItems(sdk, splitter, input.task, maxItems);
286
+ if (!split.ok) return split.result;
287
+ const { allItems, items, dropped, usage: splitUsage } = split;
288
+ sdk.log(`map: ${items.length} items → ${worker.agent}${peers ? " (cross-talk on)" : ""}`);
289
+ if (peers && items.length > sdk.limits.maxConcurrency) {
290
+ sdk.log(
291
+ `map: ${items.length} items exceeds maxConcurrency (${sdk.limits.maxConcurrency}) — the live exchange will be batched (workers beyond the concurrency window join late)`,
292
+ );
293
+ }
294
+
295
+ if (ownership === "enforce") enforceOwnershipOrThrow(items);
296
+
297
+ const results = await runWorkers(sdk, worker, input.task, items, peers);
298
+ const agg = sdk.reduce.aggregate(results);
299
+ // Say what was left out. The clamp is right — a worker per item past the cap would trip the
300
+ // pre-spawn guard and lose the whole fan-out — but an aggregate that silently covers part of
301
+ // the splitter's list reads as a complete answer over an incomplete input set.
302
+ const droppedNote =
303
+ dropped > 0
304
+ ? `\n\n[pi-persona] ${dropped} sub-item(s) beyond the worker cap (${maxItems}) were not run — this covers ${items.length} of ${allItems.length} sub-items.`
305
+ : "";
306
+
307
+ const { verifyResults, failures: verifyFailures } = await runVerification(sdk, team, verifyAgent, input.task, items, results);
308
+ const ledger = buildItemLedger(ownership, allItems, results, dropped, verifyFailures);
309
+
310
+ const notRun = ledger.filter((e) => e.status === "not-run").map((e) => e.item);
311
+ const notRunNote = ownership !== "off" && notRun.length > 0 ? `\n\n[pi-persona] not run: ${cappedList(notRun, NOT_RUN_NAME_CAP)}` : "";
312
+ // Independent of `ownership` — verification runs (and costs a child) whenever `verify` is
313
+ // set, so its verdict must be visible whenever it runs, not only when a persona also opted
314
+ // into the ownership ledger. Without this the aggregate still reads as a complete "ok"
315
+ // answer even though a completed item's work was rejected.
316
+ const rejected = ledger.filter((e) => e.failureKind === "verification").map((e) => e.item);
317
+ const verifyNote = rejected.length > 0 ? `\n\n[pi-persona] verification failed: ${cappedList(rejected, NOT_RUN_NAME_CAP)}` : "";
318
+ const output = `${agg.output}${droppedNote}${notRunNote}${verifyNote}`;
319
+ return {
320
+ ...agg,
321
+ agent: "map",
322
+ output,
323
+ usage: sumUsage([splitUsage, ...results.map((r) => r.usage), ...verifyResults.map((r) => r.usage)]),
324
+ structured: { ...agg.structured, items: ledger },
325
+ };
326
+ },
327
+ };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * pair — driver/navigator live pairing on the p2p comm plane. The driver executes the
3
+ * task; the navigator does an INDEPENDENT inspection of the same ground (real work that
4
+ * keeps it alive while milestones arrive), sends an early risk checklist, answers each
5
+ * milestone with a short correction, and ends with a final review. All over one-way
6
+ * `contact_peer` sends — the critic-loop's live counterpart, without sequential round
7
+ * costs. Timing is best-effort BY DESIGN (same as debate): a peer that already finalized
8
+ * is "gone" and the other proceeds solo.
9
+ *
10
+ * roster = [driver, navigator] · result = the driver's work + the navigator's review
11
+ */
12
+
13
+ import { sumUsage, summarizeFailedResults } from "../reducers.ts";
14
+ import type { AgentResult } from "../types.ts";
15
+ import { rosterSpec } from "../roster.ts";
16
+ import type { Strategy } from "../sdk.ts";
17
+
18
+ const DRIVER_PROTOCOL = [
19
+ "You are the DRIVER of a live pair: a navigator is inspecting the same ground right now.",
20
+ "Announce your plan to it first (`contact_peer` action `list`, then `send`), send a short",
21
+ "milestone note after each significant step, and send a final \"done: <summary>\" before you",
22
+ "finish. Weigh the navigator's \"[message from peer …]\" corrections and take the genuinely",
23
+ "right ones — you own the work and the final call. If the navigator is gone, proceed solo.",
24
+ ].join(" ");
25
+
26
+ const NAVIGATOR_PROTOCOL = [
27
+ "You are the NAVIGATOR of a live pair: a driver is executing this task right now — you do",
28
+ "NOT execute it yourself. First inspect the task's ground independently (the relevant code,",
29
+ "files, constraints) and `contact_peer send` the driver a short risk checklist (pitfalls,",
30
+ "edge cases). Keep inspecting deeper; whenever a driver \"[message from peer …]\" milestone",
31
+ "arrives, check it and send back ONE short correction or confirmation. Before you finish,",
32
+ "send the driver your final review, then OUTPUT that same review: a verdict plus the risks",
33
+ "that remain.",
34
+ ].join(" ");
35
+
36
+ export const pair: Strategy = {
37
+ name: "pair",
38
+ async run(input, sdk) {
39
+ const team = input.roster ? sdk.roster.team(input.roster) : [];
40
+ if (team.length < 2) throw new Error("pair: a roster of [driver, navigator] is required");
41
+ const driver = rosterSpec(team[0]!);
42
+ const navigator = rosterSpec(team[1]!);
43
+ sdk.log(`pair: ${driver.agent} drives, ${navigator.agent} navigates`);
44
+ if (sdk.limits.maxConcurrency < 2) sdk.log("pair: maxConcurrency < 2 — the live exchange degrades to sequential runs");
45
+
46
+ const results = await sdk.parallel([
47
+ () => sdk.agent({ ...driver, task: `${input.task}\n\n--- pair protocol (driver) ---\n${DRIVER_PROTOCOL}`, peers: true }),
48
+ () => sdk.agent({ ...navigator, task: `${input.task}\n\n--- pair protocol (navigator) ---\n${NAVIGATOR_PROTOCOL}`, peers: true }),
49
+ ]);
50
+ const work = results[0];
51
+ const review = results[1];
52
+ if (!work || !review) throw new Error("pair: a member run went missing");
53
+
54
+ // The driver's work IS the deliverable; the navigator's review is attached when it
55
+ // produced one, and its failure only degrades the pair to a solo run.
56
+ const lines = [work.output];
57
+ if (review.ok && review.output.trim()) lines.push(`\n--- navigator review (${navigator.agent}) ---\n${review.output.trim()}`);
58
+ const result: AgentResult = {
59
+ agent: "pair",
60
+ output: lines.join("\n"),
61
+ structured: { driver: driver.agent, navigator: navigator.agent, driverOk: work.ok, navigatorOk: review.ok },
62
+ usage: sumUsage([work, review].map((r) => r.usage)),
63
+ ok: work.ok,
64
+ };
65
+ if (!work.ok) {
66
+ const cause = summarizeFailedResults([work], "the driver failed");
67
+ result.error = cause.error;
68
+ result.failureKind = cause.failureKind;
69
+ }
70
+ return result;
71
+ },
72
+ };