@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
@@ -5,11 +5,13 @@
5
5
  * comes from the roster's personas; bias guards live in the vote reducer.
6
6
  *
7
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
8
+ * core sees the PANEL's positions **anonymised** — its own among them, unmarked, because the
9
+ * reflecting leg is a fresh session and that list is its only memory of round 1 — and casts a
10
+ * FINAL vote, revising only if a genuinely new consideration moves it (holding is fine). This
10
11
  * lets a core catch a blind spot without turning MAGI into groupthink: the positions
11
12
  * 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
+ * iterate-to-consensus — that is `council-rounds`), and dissent is always preserved. A core whose
14
+ * reflection leg fails keeps the valid vote it already cast (disclosed in the ruling footer).
13
15
  * Set `reflect: false` for a pure independent poll (uncorrelated errors, cheapest).
14
16
  */
15
17
 
@@ -55,6 +57,8 @@ function rescuedModel(c: AgentResult | undefined): string | undefined {
55
57
 
56
58
  export const magi: Strategy = {
57
59
  name: "magi",
60
+ when: "contested fork: voters pick on a closed-choice question (yes/no, A/B); majority plus dissent",
61
+ team: "magi",
58
62
  params: {
59
63
  aggregate: { type: "string", default: "majority", doc: '"majority" | "unanimity"' },
60
64
  reflect: { type: "boolean", default: true, doc: "one anonymised reflection round" },
@@ -88,7 +92,6 @@ export const magi: Strategy = {
88
92
  return cancelled(round1.map((c) => c.usage));
89
93
  }
90
94
 
91
- let candidates = opening;
92
95
  const okCount = opening.filter((c) => c.ok).length;
93
96
  const tokensBeforeReflection = round1.reduce((total, result) => total + result.usage.input + result.usage.output, 0);
94
97
  // A reflection is meaningful only when every core can participate. Under an active token
@@ -97,19 +100,19 @@ export const magi: Strategy = {
97
100
  // goes for children: a rescue already spent a child per recovered core, and exceeding
98
101
  // `maxChildren` THROWS out of sdk.agent() — turning a poll that has a ruling into no ruling.
99
102
  const childrenUsed = round1.length + round1.filter((c) => c.modelRecovery).length;
100
- const reflectionHasTokenRoom =
101
- sdk.limits.budgetTokens <= 0 || tokensBeforeReflection + team.length <= sdk.limits.budgetTokens;
102
- const reflectionHasChildRoom = childrenUsed + team.length <= sdk.limits.maxChildren;
103
- const didReflect = reflect && okCount >= 2 && reflectionHasTokenRoom && reflectionHasChildRoom;
104
- if (reflect && okCount >= 2 && !reflectionHasTokenRoom) {
105
- sdk.log(`magi: reflection skipped — token budget cannot fund the full panel (${sdk.limits.budgetTokens})`);
106
- }
107
- if (reflect && okCount >= 2 && !reflectionHasChildRoom) {
108
- sdk.log(`magi: reflection skipped — child budget cannot fund the full panel (maxChildren ${sdk.limits.maxChildren}, ${childrenUsed} used)`);
109
- }
103
+ const wantsReflection = reflect && okCount >= 2;
104
+ const skip = !wantsReflection ? undefined
105
+ : sdk.limits.budgetTokens > 0 && tokensBeforeReflection + team.length > sdk.limits.budgetTokens
106
+ ? { why: "token budget", detail: `cannot fund the full panel (${sdk.limits.budgetTokens})` }
107
+ : childrenUsed + team.length > sdk.limits.maxChildren
108
+ ? { why: "child budget", detail: `cannot fund the full panel (maxChildren ${sdk.limits.maxChildren}, ${childrenUsed} used)` }
109
+ : undefined;
110
+ const didReflect = wantsReflection && !skip;
111
+ if (skip) sdk.log(`magi: reflection skipped — ${skip.why} ${skip.detail}`);
110
112
 
113
+ let reflected: AgentResult[] = [];
111
114
  if (didReflect) {
112
- // Round 2 — each core sees the others' positions ANONYMISED (no author identity, so a
115
+ // Round 2 — each core sees the panel's positions ANONYMISED (no author identity, so a
113
116
  // core can't defer to a "senior" peer) and casts its FINAL vote. Instructed to hold
114
117
  // unless genuinely moved, so this informs without manufacturing false consensus.
115
118
  const positions = opening
@@ -123,7 +126,7 @@ export const magi: Strategy = {
123
126
  // The recovered model must survive into this round: rebuilding from `rosterSpec(m)` alone
124
127
  // would hand the rescued core back the model that just broke, so it fails again, is
125
128
  // quarantined as invalid, and the recovery buys nothing but tokens.
126
- candidates = await sdk.parallel(
129
+ reflected = await sdk.parallel(
127
130
  team.map((m, i) => () => {
128
131
  const model = rescuedModel(opening[i]);
129
132
  return sdk.agent({ ...rosterSpec(m), ...(model ? { model } : {}), task: reflectTask, outputContract: "default" });
@@ -134,11 +137,23 @@ export const magi: Strategy = {
134
137
  // DURING the reflection round is visible here only as every core settling
135
138
  // `failureKind: "abort"` — as is one on a caller that passed no signal. Without the
136
139
  // second clause a stopped run reports a normal-looking "no ruling" from a killed poll.
137
- if (sdk.signal?.aborted || candidates.every((c) => c.failureKind === "abort")) {
138
- return cancelled([...round1, ...candidates].map((c) => c.usage));
140
+ if (sdk.signal?.aborted || reflected.every((c) => c.failureKind === "abort")) {
141
+ return cancelled([...round1, ...reflected].map((c) => c.usage));
139
142
  }
140
143
  }
141
- const decision = sdk.reduce.vote(candidates, { aggregate, keepBestFallback: true });
144
+ // A reflection leg that FAILED (timeout, provider, contract...) is a missing second opinion, not a
145
+ // missing voter: the core keeps the valid vote it already cast, so a 3-core poll stays a 3-core
146
+ // poll. A STOPPED leg is never substituted. The substituted vote is not billed again (`allRuns`).
147
+ const ballots = didReflect
148
+ ? reflected.map((r, i) => {
149
+ const first = opening[i];
150
+ return !r.ok && r.failureKind !== "abort" && first?.ok ? first : r;
151
+ })
152
+ : opening;
153
+ const kept = didReflect ? ballots.filter((b, i) => b !== reflected[i]).length : 0;
154
+ // "Reflected" means at least one core cast a second-round vote; a round that failed everywhere changed nothing.
155
+ const reflectedAny = didReflect && kept < ballots.length;
156
+ const decision = sdk.reduce.vote(ballots, { aggregate, keepBestFallback: true });
142
157
 
143
158
  // Lead with the ruling (the answer); the decision/tally plumbing is a compact footer,
144
159
  // not the headline. The collapsed council card shows `headline`; the supervisor still
@@ -156,8 +171,10 @@ export const magi: Strategy = {
156
171
  const rescuedLine = rescued.length > 0
157
172
  ? ` · ${rescued.length} core${rescued.length === 1 ? "" : "s"} recovered on ${[...new Set(rescued.map((c) => c.modelRecovery?.to ?? ""))].filter(Boolean).join(", ")}`
158
173
  : "";
174
+ const keptLine = kept > 0 ? ` · ${kept} core${kept === 1 ? "" : "s"} kept their round-1 vote (reflection failed)` : "";
175
+ const skipLine = skip ? ` · reflection skipped (${skip.why})` : "";
159
176
  lines.push(
160
- `\n— magi: ${decision.status}${didReflect ? " (after 1 reflection round)" : ""}${decision.usedFallback ? " · fell back to best-by-confidence" : ""} · tally ${tally}${invalid}${rescuedLine}`,
177
+ `\n— magi: ${decision.status}${reflectedAny ? " (after 1 reflection round)" : ""}${decision.usedFallback ? " · fell back to best-by-confidence" : ""} · tally ${tally}${invalid}${rescuedLine}${keptLine}${skipLine}`,
161
178
  );
162
179
 
163
180
  const winnerResult = decision.winner?.structured?.result;
@@ -170,16 +187,16 @@ export const magi: Strategy = {
170
187
  // Usage sums EVERY run the poll actually paid for: the opening round (each logical result
171
188
  // already carries its rescue's tokens exactly once) and the reflection round, kept separate
172
189
  // from the opening one when reflection ran.
173
- const allRuns: AgentResult[] = didReflect ? [...round1, ...candidates] : round1;
190
+ const allRuns: AgentResult[] = [...round1, ...reflected];
174
191
  const result: AgentResult = {
175
192
  agent: "magi",
176
193
  output: lines.join("\n"),
177
- structured: { status: decision.status, tally: decision.tally, usedFallback: decision.usedFallback, reflected: didReflect, headline },
194
+ structured: { status: decision.status, tally: decision.tally, usedFallback: decision.usedFallback, reflected: reflectedAny, headline },
178
195
  usage: sumUsage(allRuns.map((c) => c.usage)),
179
196
  ok: decision.winner !== undefined,
180
197
  };
181
198
  if (!result.ok) {
182
- const cause = summarizeFailedResults(candidates, "magi produced no ruling");
199
+ const cause = summarizeFailedResults(ballots, "magi produced no ruling");
183
200
  result.error = cause.error;
184
201
  result.failureKind = cause.failureKind;
185
202
  }
@@ -16,6 +16,7 @@ import { fenceUntrusted } from "../../core/fence.ts";
16
16
  import { cappedList } from "../../core/format.ts";
17
17
  import { validateParallelWriteSets } from "../../core/ownership.ts";
18
18
  import type { ChildUsage } from "../../engine/stream.ts";
19
+ import { compactInlineText } from "../../ui/presentation.ts";
19
20
  import { itemLedger, type ItemLedgerEntry, sumUsage } from "../reducers.ts";
20
21
  import { parseAuxActor } from "../params.ts";
21
22
  import { rosterSpec, type RosterMember, type RosterSpec } from "../roster.ts";
@@ -144,10 +145,16 @@ async function splitIntoItems(
144
145
  task: string,
145
146
  maxItems: number,
146
147
  reserveChildren: number,
148
+ declareWriteSets: boolean,
147
149
  ): Promise<SplitOutcome> {
150
+ // With ownership on, the splitter is the only party that can declare each item's write set, so it is asked
151
+ // for the object shape itself: one instruction, not a string array here and objects in the caller's prose.
152
+ const shape = declareWriteSets
153
+ ? 'a JSON array of {"item": "<short description>", "writeSet": ["<every path a worker would edit>"]} objects (use [] for a read-only item)'
154
+ : "a JSON array of short strings";
148
155
  const split = await sdk.agent({
149
156
  ...splitter,
150
- 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}`,
157
+ task: `Break this task into independent sub-items. Return ONLY ${shape} — one per sub-item, nothing else.\n\nTask: ${task}`,
151
158
  }, { reserveChildren });
152
159
  if (!split.ok) {
153
160
  return {
@@ -263,6 +270,9 @@ function buildItemLedger(
263
270
 
264
271
  export const map: Strategy = {
265
272
  name: "map",
273
+ when: "N items found at runtime: splitter lists, worker handles each in parallel",
274
+ roster: "[splitter, worker]",
275
+ team: "swarm",
266
276
  params: {
267
277
  maxItems: { type: "number", doc: "default AND ceiling: the run's maxChildren, less the splitter's own slot" },
268
278
  peers: { type: "boolean", default: false, doc: "workers share load-bearing cross-item discoveries live" },
@@ -304,7 +314,7 @@ export const map: Strategy = {
304
314
  // conservative upper bound; the cap above already guarantees the reserved waves fit.
305
315
  const maxWorkerItems = Math.max(0, Math.floor(maxItems));
306
316
  const splitReserve = maxWorkerItems * (reviewer ? 2 : 1);
307
- const split = await splitIntoItems(sdk, splitter, input.task, maxItems, splitReserve);
317
+ const split = await splitIntoItems(sdk, splitter, input.task, maxItems, splitReserve, ownership !== "off");
308
318
  if (!split.ok) return split.result;
309
319
  const { allItems, items, dropped, usage: splitUsage } = split;
310
320
  sdk.log(`map: ${items.length} items → ${worker.agent}${peers ? " (cross-talk on)" : ""}`);
@@ -317,7 +327,7 @@ export const map: Strategy = {
317
327
  if (ownership === "enforce") enforceOwnershipOrThrow(items);
318
328
 
319
329
  const results = await runWorkers(sdk, worker, input.task, items, peers, reviewer ? items.length : 0);
320
- const agg = sdk.reduce.aggregate(results);
330
+ const agg = sdk.reduce.aggregate(results, items.map((it, i) => `item ${i + 1}: ${compactInlineText(it.item, { maxChars: 60 })}`));
321
331
  // Say what was left out. The clamp is right — a worker per item past the cap would trip the
322
332
  // pre-spawn guard and lose the whole fan-out — but an aggregate that silently covers part of
323
333
  // the splitter's list reads as a complete answer over an incomplete input set.
@@ -35,6 +35,8 @@ const NAVIGATOR_PROTOCOL = [
35
35
 
36
36
  export const pair: Strategy = {
37
37
  name: "pair",
38
+ when: "one member works, a navigator reviews live",
39
+ roster: "[driver, navigator]",
38
40
  async run(input, sdk) {
39
41
  const team = input.roster ? sdk.roster.team(input.roster) : [];
40
42
  if (team.length < 2) throw new Error("pair: a roster of [driver, navigator] is required");
@@ -44,6 +44,8 @@ function failed(results: AgentResult[], upstream: string, step: AgentResult): Ag
44
44
 
45
45
  export const pipeline: Strategy = {
46
46
  name: "pipeline",
47
+ when: "ad-hoc ordered stages you name (no installed flow needed): each member refines the previous output; the last answer is the result",
48
+ roster: "stages, in order",
47
49
  async run(input, sdk) {
48
50
  const team = input.roster ? sdk.roster.team(input.roster) : [];
49
51
  if (team.length === 0) throw new Error("pipeline: a non-empty roster is required");
@@ -10,8 +10,9 @@
10
10
  * peers?: boolean (gatherers share contradictions/corroborations live — default off) }
11
11
  */
12
12
 
13
- import { rosterSpec } from "../roster.ts";
13
+ import { rosterNodeKeys, rosterSpec } from "../roster.ts";
14
14
  import { fenceUntrusted } from "../../core/fence.ts";
15
+ import { sanitizeStatusLabel } from "../../core/display-label.ts";
15
16
  import { parseAuxActor } from "../params.ts";
16
17
  import { sumUsage, summarizeFailedResults } from "../reducers.ts";
17
18
  import type { Strategy } from "../sdk.ts";
@@ -28,6 +29,9 @@ const CROSS_TALK = [
28
29
 
29
30
  export const synthesize: Strategy = {
30
31
  name: "synthesize",
32
+ when: "many angles into ONE deliverable: gatherers in parallel, one synthesiser merges",
33
+ roster: "gatherers",
34
+ team: "review",
31
35
  params: {
32
36
  // The default IS roster[0] — declared as `rosterIndex`, never as an agent name: the
33
37
  // participant collector resolves "override, else roster[rosterIndex], else `default`",
@@ -61,10 +65,12 @@ export const synthesize: Strategy = {
61
65
  }, { reserveChildren: 1 }), // leave the mandatory merge slot; SDK reserves queued gatherers
62
66
  ),
63
67
  );
64
- const usable = results.filter((r) => r.ok && r.output.trim());
68
+ // Lens-aware labels (`reviewer · SECURITY`): the synthesizer must see WHICH lens found what.
69
+ const labels = rosterNodeKeys(team);
70
+ const usable = results.flatMap((r, i) => (r.ok && r.output.trim() ? [{ label: labels[i], output: r.output.trim() }] : []));
65
71
  if (usable.length === 0) {
66
72
  const cause = summarizeFailedResults(results, "no gatherer produced output");
67
- const reasons = results.map((r) => `[${r.agent}] ${r.error ?? "(no output)"}`).join("; ");
73
+ const reasons = results.map((r, i) => `[${labels[i]}] ${r.error ?? "(no output)"}`).join("; ");
68
74
  return {
69
75
  agent: "synthesize",
70
76
  output: `(no gatherer produced output: ${reasons})`,
@@ -74,13 +80,27 @@ export const synthesize: Strategy = {
74
80
  failureKind: cause.failureKind,
75
81
  };
76
82
  }
83
+ // A partial merge is useful, but a missing review lens is not a clean review. Carry the
84
+ // runtime coverage into BOTH the merger's input and its returned output, even if it omits it.
85
+ const missing = results.flatMap((r, i) => r.ok && r.output.trim() ? [] : [{
86
+ member: sanitizeStatusLabel(labels[i] ?? r.agent),
87
+ status: r.ok ? "empty" : "failed",
88
+ detail: sanitizeStatusLabel(r.ok ? "no output" : r.error ?? "no failure detail", "no failure detail", 200),
89
+ }]);
90
+ const coverage = missing.length > 0 ? { expected: team.length, usable: usable.length, missing } : undefined;
91
+ const coverageNote = coverage ? [
92
+ `Incomplete coverage: ${usable.length}/${team.length} gatherers returned usable findings. Missing members were not reviewed; do not present this as a complete review.`,
93
+ fenceUntrusted(missing.slice(0, 16).map((m) => `${m.member} — ${m.status}: ${m.detail}`).join("\n")),
94
+ ...(missing.length > 16 ? [`${missing.length - 16} additional missing members are recorded in the result's coverage metadata.`] : []),
95
+ ].join("\n") : "";
77
96
 
78
- const sections = usable.map((r) => `--- [${r.agent}] ---\n${fenceUntrusted(r.output.trim())}`).join("\n\n");
97
+ const sections = usable.map((u) => `--- [${u.label}] ---\n${fenceUntrusted(u.output)}`).join("\n\n");
79
98
  const final = await sdk.agent({
80
99
  ...synthesizer,
81
100
  task:
82
101
  `Synthesise the findings below into ONE coherent, de-duplicated answer to the task. ` +
83
102
  `Resolve contradictions explicitly (say which finding you trusted and why) and keep every load-bearing detail.\n\n` +
103
+ (coverageNote ? `${coverageNote}\n\n` : "") +
84
104
  `Task: ${input.task}\n\nFindings:\n${sections}`,
85
105
  });
86
106
 
@@ -88,12 +108,12 @@ export const synthesize: Strategy = {
88
108
  // the synthesiser's `result` field if it emitted one, else the first non-empty line.
89
109
  const firstLine = final.output.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
90
110
  const structuredResult = final.structured?.result;
91
- const headline = (typeof structuredResult === "string" && structuredResult.trim() ? structuredResult : firstLine).slice(0, 120);
111
+ const headline = `${coverage ? `Incomplete coverage: ${usable.length}/${team.length} · ` : ""}${typeof structuredResult === "string" && structuredResult.trim() ? structuredResult : firstLine}`.slice(0, 120);
92
112
 
93
113
  const result: AgentResult = {
94
114
  agent: "synthesize",
95
- output: final.output,
96
- structured: { ...(final.structured ?? {}), gatherers: usable.length, headline },
115
+ output: coverageNote ? `${coverageNote}\n\n${final.output}` : final.output,
116
+ structured: { ...(final.structured ?? {}), gatherers: usable.length, headline, ...(coverage ? { coverage } : {}) },
97
117
  usage: sumUsage([...results, final].map((r) => r.usage)),
98
118
  ok: final.ok,
99
119
  };
@@ -47,3 +47,34 @@ export function knownParams(name: string): Record<string, StrategyParam> | undef
47
47
  const strategy = BUILTINS[name];
48
48
  return strategy ? (strategy.params ?? {}) : undefined;
49
49
  }
50
+
51
+ /** One line per strategy for the council tool's description, generated from the registry:
52
+ * `- name(params, required*) — when; roster: positions; default team X.` Guidance on which method fits a
53
+ * problem class lives with the method, so it cannot drift from what the code does. */
54
+ export function strategyCatalog(): string {
55
+ return strategyNames()
56
+ .map((name) => {
57
+ const s = BUILTINS[name] as Strategy;
58
+ const keys = Object.keys(s.params ?? {}).map((k) => (s.required?.includes(k) ? `${k}*` : k));
59
+ const head = `- ${name}${keys.length > 0 ? `(${keys.join(", ")})` : ""}`;
60
+ // `magi` is the fallback every call already gets, so only a different default team is worth a mention.
61
+ const team = s.team && s.team !== "magi" ? `; default team ${s.team}` : "";
62
+ return `${head}${s.when ? ` — ${s.when}` : ""}${s.roster ? `; roster: ${s.roster}` : ""}${team}.`;
63
+ })
64
+ .join("\n");
65
+ }
66
+
67
+ /** Refuse a council call that cannot run, BEFORE any run, tree row or model picker exists: a
68
+ * `required` param that is missing or blank (blank counts as missing, exactly as the strategies'
69
+ * own `parseAuxActor` reads it). `undefined` means the call is sound. An UNKNOWN strategy is not
70
+ * judged here: the run itself fails with the installed list (an interactive council reports that in
71
+ * its completion follow-up, not in the launching call). */
72
+ export function missingRequiredParams(name: string, params: Record<string, unknown>): string | undefined {
73
+ const strategy = BUILTINS[name];
74
+ const missing = (strategy?.required ?? []).filter((key) => {
75
+ const value = params[key];
76
+ return value === undefined || value === null || (typeof value === "string" && !value.trim());
77
+ });
78
+ if (missing.length === 0) return undefined;
79
+ return `"${name}" requires ${missing.map((key) => `params.${key} — ${strategy?.params?.[key]?.doc ?? "required"}`).join("; ")}`;
80
+ }
@@ -1,215 +1,26 @@
1
1
  /**
2
- * Per-persona configuration, indexed by persona name and persisted as one JSON
3
- * file in the global persona folder. Intentionally open-ended: a persona that
4
- * needs settings gets an entry, others don't. Two personas that share a strategy
5
- * keep independent configs. Today it carries per-agent model assignments (so an
6
- * ensemble like magi can run its cores on *different* models); more keys later.
2
+ * Pure per-persona model configuration helpers. Persistence is owned exclusively
3
+ * by settings-store.ts (<agentDir>/persona/settings.json).
7
4
  */
8
5
 
9
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
10
- import { dirname } from "node:path";
11
-
12
6
  export interface PersonaConfig {
13
- /** Roster agent name → provider-qualified model id (e.g. "claude-pro-max-native/claude-opus-4-8"). */
7
+ /** Participant key → provider-qualified model id. */
14
8
  models?: Record<string, string>;
15
9
  }
16
10
 
17
11
  export type PersonaConfigStore = Record<string, PersonaConfig>;
18
12
 
19
- function isPlainObject(value: unknown): value is Record<string, unknown> {
20
- return typeof value === "object" && value !== null && !Array.isArray(value);
21
- }
22
-
23
- /**
24
- * Read the store. NOTE: this reader WRITES on one path — an unusable file is copied to
25
- * `${file}.bak` before the next save replaces it (once; an existing backup is never touched).
26
- */
27
- export function readPersonaConfigs(file: string): PersonaConfigStore {
28
- let text: string;
29
- try {
30
- text = readFileSync(file, "utf8");
31
- } catch {
32
- return {}; // no config yet (the common case) or unreadable → start empty
33
- }
34
- try {
35
- const parsed: unknown = JSON.parse(text);
36
- // Valid JSON that isn't a config object is as unusable as unparseable text — and the next
37
- // save replaces it just the same, so it gets the same backup treatment.
38
- if (!isPlainObject(parsed)) throw new Error("not a config object");
39
- const store: PersonaConfigStore = {};
40
- for (const [name, cfg] of Object.entries(parsed)) {
41
- if (!isPlainObject(cfg)) continue;
42
- const entry: PersonaConfig = {};
43
- if (isPlainObject(cfg.models)) {
44
- const models: Record<string, string> = {};
45
- for (const [agent, model] of Object.entries(cfg.models)) {
46
- if (typeof model === "string") models[agent] = model;
47
- }
48
- entry.models = models;
49
- }
50
- store[name] = entry;
51
- }
52
- return store;
53
- } catch {
54
- // An unusable file is about to be overwritten by the next save; keep it beside the
55
- // config so the assignments stay recoverable instead of vanishing silently. `wx` makes
56
- // this the FIRST copy only: the underlying fault (a sync client, a failing disk) tends to
57
- // recur, and a second bad parse would otherwise overwrite the one copy that still had the
58
- // user's assignments in it.
59
- try {
60
- writeFileSync(`${file}.bak`, text, { encoding: "utf8", flag: "wx" });
61
- } catch {
62
- /* best effort — an existing backup, or an unwritable dir, must not stop the session */
63
- }
64
- return {};
65
- }
66
- }
67
-
68
- /** Injection seams for the rename retry below (tests drive it on any OS). */
69
- export interface ConfigWriteIO {
70
- rename?: (from: string, to: string) => void;
71
- sleep?: (ms: number) => void;
72
- /** Test seam for the cross-process lock around the read/merge/write transaction. */
73
- acquireLock?: (file: string, sleep: (ms: number) => void) => () => void;
74
- }
75
-
76
- // A handle held by a virus scanner, a sync client or another pi instance mid-read is transient,
77
- // so a few short waits usually win the race — 25+50+75 ms of backoff at worst, on a save.
78
- const RENAME_ATTEMPTS = 4;
79
- const RENAME_BACKOFF_MS = 25;
80
- const LOCK_ATTEMPTS = 80;
81
- const LOCK_BACKOFF_MS = 25;
82
- const STALE_LOCK_MS = 5 * 60 * 1000;
83
-
84
- function sleepSync(ms: number): void {
85
- // The whole store API is synchronous (this runs on a model assignment, not on a hot path),
86
- // so the backoff has to block; Atomics.wait is the standard synchronous sleep.
87
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
88
- }
89
-
90
- /** Acquire a small sidecar lock so two independent Pi processes cannot both read the same old
91
- * config and race their read-modify-write transactions. `wx` is the portable atomic primitive on
92
- * POSIX and Windows. A very old lock is treated as abandoned after a crashed process; active
93
- * writers renew no state because the critical section is synchronous and short. */
94
- function acquireFileLock(file: string, sleep: (ms: number) => void): () => void {
95
- const lock = `${file}.lock`;
96
- for (let attempt = 1; attempt <= LOCK_ATTEMPTS; attempt++) {
97
- try {
98
- const fd = openSync(lock, "wx");
99
- try {
100
- writeSync(fd, `${process.pid}\n`, null, "utf8");
101
- } catch (error) {
102
- try {
103
- closeSync(fd);
104
- } finally {
105
- try {
106
- unlinkSync(lock);
107
- } catch {
108
- /* best effort; retain the original write error */
109
- }
110
- }
111
- throw error;
112
- }
113
- return () => {
114
- try {
115
- closeSync(fd);
116
- } finally {
117
- try {
118
- unlinkSync(lock);
119
- } catch {
120
- /* another cleanup path or an external remover already won */
121
- }
122
- }
123
- };
124
- } catch (error) {
125
- const code = (error as NodeJS.ErrnoException).code;
126
- if (code !== "EEXIST") throw error;
127
- try {
128
- if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) unlinkSync(lock);
129
- } catch {
130
- /* It disappeared between stat/unlink; retry normally. */
131
- }
132
- if (attempt < LOCK_ATTEMPTS) {
133
- sleep(LOCK_BACKOFF_MS);
134
- continue;
135
- }
136
- throw new Error(`timed out acquiring persona config lock: ${lock}`);
137
- }
138
- }
139
- throw new Error(`timed out acquiring persona config lock: ${lock}`);
140
- }
141
-
142
- /**
143
- * MERGE `store` into the file (read-modify-write), then save it.
144
- *
145
- * A removal is NOT expressible: an assignment missing from `store` is preserved, not deleted.
146
- * The merge exists so a concurrent pi instance's save survives ours, and it cannot tell "the
147
- * user cleared this" from "this store was read before that was written" — a clearing gesture
148
- * would need its own delete-aware call.
149
- */
150
- export function writePersonaConfigs(file: string, store: PersonaConfigStore, io: ConfigWriteIO = {}): void {
151
- mkdirSync(dirname(file), { recursive: true });
152
- const sleep = io.sleep ?? sleepSync;
153
- const release = (io.acquireLock ?? acquireFileLock)(file, sleep);
154
- try {
155
- // The caller holds a store read at session start; another pi instance in the same workspace
156
- // may have saved since. Re-read and merge so its work survives our whole-store write — down
157
- // to the agent, since two instances of the *same* persona assign different roster members.
158
- const merged: PersonaConfigStore = { ...readPersonaConfigs(file) };
159
- for (const [persona, cfg] of Object.entries(store)) {
160
- const prev = merged[persona];
161
- merged[persona] = prev ? { ...prev, ...cfg, models: { ...prev.models, ...cfg.models } } : cfg;
162
- }
163
- const payload = `${JSON.stringify(merged, null, 2)}\n`;
164
- // Temp file + same-volume rename, so a crash mid-write can never leave a truncated
165
- // config.json behind. That is atomic on POSIX; Windows is NOT the same — rename fails with
166
- // EPERM/EBUSY while ANY other handle holds the target open (Defender, a sync client, a second
167
- // pi instance mid-read), and the persona folder is an ordinary user directory where that is
168
- // routine. So a contended rename is retried, and then written in place: a non-atomic save
169
- // that lands beats an atomic one that loses the user's assignments every session.
170
- const tmp = `${file}.${process.pid}.tmp`;
171
- const rename = io.rename ?? renameSync;
172
- writeFileSync(tmp, payload, "utf8");
173
- for (let attempt = 1; ; attempt++) {
174
- try {
175
- rename(tmp, file);
176
- return;
177
- } catch (err) {
178
- const code = (err as NodeJS.ErrnoException).code;
179
- if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
180
- sleep(RENAME_BACKOFF_MS * attempt);
181
- continue;
182
- }
183
- try {
184
- // The scratch file must not be left behind for every pid that ever tried, whether
185
- // the in-place write succeeds or throws (the caller still sees a real failure).
186
- writeFileSync(file, payload, "utf8");
187
- } finally {
188
- try {
189
- rmSync(tmp, { force: true });
190
- } catch {
191
- /* best effort */
192
- }
193
- }
194
- return;
195
- }
196
- }
197
- } finally {
198
- release();
199
- }
200
- }
201
-
202
13
  /** The model assignments for a persona (empty object when none configured). */
203
14
  export function personaModels(store: PersonaConfigStore, persona: string): Record<string, string> {
204
- return store[persona]?.models ?? {};
15
+ return Object.hasOwn(store, persona) ? store[persona]?.models ?? {} : {};
205
16
  }
206
17
 
207
- /** Merge model assignments into a persona's config, returning a new store. */
18
+ /** Merge session model assignments without mutating the owner's saved snapshot. */
208
19
  export function withPersonaModels(
209
20
  store: PersonaConfigStore,
210
21
  persona: string,
211
22
  models: Record<string, string>,
212
23
  ): PersonaConfigStore {
213
- const prev = store[persona] ?? {};
24
+ const prev = Object.hasOwn(store, persona) ? store[persona] ?? {} : {};
214
25
  return { ...store, [persona]: { ...prev, models: { ...prev.models, ...models } } };
215
26
  }
@@ -16,7 +16,7 @@
16
16
  * > the session model (the default, NOT an assignment — it must not suppress the picker).
17
17
  *
18
18
  * The persisted assignment KEY is `agent` for a bare member — the historical shape, so every
19
- * existing `config.json` keeps applying — and `agent#<hash-of-agent+role>` for a member carrying a
19
+ * migrated model assignments keep applying — and `agent#<hash-of-agent+role>` for a member carrying a
20
20
  * role. The hash keeps the full role as the identity without persisting the role PROMPT (which is
21
21
  * an instruction, not a key, and would leak strategy prose into the user's config file). The human
22
22
  * label stays separate, so the picker and the tree can still say `reviewer · SECURITY`.
@@ -11,6 +11,7 @@ import { asBoolean, asPermission, asStringArray, parseYamlSubset, splitFrontmatt
11
11
  import type { Permission } from "../core/permissions.ts";
12
12
  import { asSystemPromptMode, type SystemPromptMode } from "../core/types.ts";
13
13
  import { parseRuntimeRosterMember, type RosterMember } from "../orchestration/roster.ts";
14
+ import { getStrategy } from "../orchestration/strategy.ts";
14
15
 
15
16
  export type OrchestrationMode = "solo" | "parallel" | "pipeline" | "strategy" | "flow";
16
17
  const ORCHESTRATION_MODES: readonly OrchestrationMode[] = ["solo", "parallel", "pipeline", "strategy", "flow"];
@@ -323,16 +324,19 @@ export function resolveCouncilInvocation(
323
324
  if (!parsed.ok) return { ok: false, error: parsed.error };
324
325
  members = parsed.members;
325
326
  }
327
+ // A call that switches to another method starts from THAT method's team and params: the profile's roster and
328
+ // params belong to the profile's own strategy (dev's `repair` pair would otherwise be seated as magi voters).
329
+ const switched = strategy !== (base?.strategy || "magi");
326
330
  const roster = members
327
331
  ? ""
328
332
  : request.roster?.trim() ||
333
+ (switched ? getStrategy(strategy)?.team : undefined) ||
329
334
  base?.roster ||
330
335
  sourcePersona?.orchestration?.roster ||
331
336
  (!requestedName ? activePersona?.orchestration?.roster : undefined) ||
332
337
  "magi";
333
- // Profile params belong to the profile's strategy. A per-call strategy switch starts
334
- // with that strategy's own defaults; only explicitly supplied params cross the switch.
335
- const inheritedParams = strategy !== (base?.strategy || "magi") ? {} : base?.params ?? {};
338
+ // Only explicitly supplied params cross a strategy switch.
339
+ const inheritedParams = switched ? {} : base?.params ?? {};
336
340
  const value: ResolvedCouncilInvocation = {
337
341
  strategy,
338
342
  roster,