@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,107 @@
1
+ /**
2
+ * Bounded-concurrency fan-out — the one place concurrency is implemented.
3
+ * Pure (no I/O): order-preserving, clamps the limit, and never runs more than
4
+ * `concurrency` callbacks at once. Used by `parallel()` in the Strategy SDK and
5
+ * by the delegate tool's parallel mode.
6
+ */
7
+
8
+ export async function mapWithConcurrency<TIn, TOut>(
9
+ items: TIn[],
10
+ concurrency: number,
11
+ fn: (item: TIn, index: number) => Promise<TOut>,
12
+ ): Promise<TOut[]> {
13
+ if (items.length === 0) return [];
14
+ const requested = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
15
+ const limit = Math.max(1, Math.min(requested, items.length));
16
+ const results = new Array<TOut>(items.length);
17
+ let next = 0;
18
+ // Contained rejection, two halves. CONTAINMENT: once a callback rejects no worker pulls another
19
+ // item, so a failed fan-out can't keep spawning siblings whose results are already destined for
20
+ // the bin. LATENCY: the batch rejects with the FIRST error the moment it lands. A callback
21
+ // already in flight cannot be cancelled from here, so awaiting it would not stop the work — it
22
+ // would only delay the report, and the rejections that reach this path are the run-fatal ones
23
+ // (`sdk.parallel`: a limit breach, a throwing host callback) that an operator must see now
24
+ // rather than after the slowest healthy leg, which for a delegated agent is minutes. The worker
25
+ // swallows into `failure`, so an abandoned one can never surface as an unhandled rejection.
26
+ let failure: { error: unknown } | undefined;
27
+ let onFailure = (): void => { /* replaced below */ };
28
+ const fatal = new Promise<void>((resolve) => {
29
+ onFailure = resolve;
30
+ });
31
+ const worker = async (): Promise<void> => {
32
+ while (!failure) {
33
+ const index = next++;
34
+ if (index >= items.length) return;
35
+ try {
36
+ results[index] = await fn(items[index]!, index);
37
+ } catch (error) {
38
+ failure ??= { error };
39
+ onFailure();
40
+ }
41
+ }
42
+ };
43
+ await Promise.race([Promise.all(Array.from({ length: limit }, () => worker())), fatal]);
44
+ if (failure) throw failure.error;
45
+ return results;
46
+ }
47
+
48
+ /**
49
+ * A counting semaphore for concurrency across INDEPENDENT launches (where
50
+ * `mapWithConcurrency` can't apply because the jobs don't arrive as one batch) —
51
+ * e.g. async delegate runs launched across several tool calls. FIFO, throw-safe:
52
+ * the slot is released even when the job rejects.
53
+ */
54
+ export class Semaphore {
55
+ private available: number;
56
+ private readonly waiters: Array<() => void> = [];
57
+
58
+ constructor(slots: number) {
59
+ this.available = Math.max(1, Number.isFinite(slots) ? Math.floor(slots) : 1);
60
+ }
61
+
62
+ /** Run `fn` once a slot is free; the slot is released when it settles. A signal removes a
63
+ * queued waiter immediately, so a stopped background leg can never start later. */
64
+ async with<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
65
+ await this.acquire(signal);
66
+ try {
67
+ if (signal?.aborted) throw this.abortError();
68
+ return await fn();
69
+ } finally {
70
+ this.release();
71
+ }
72
+ }
73
+
74
+ private acquire(signal?: AbortSignal): Promise<void> {
75
+ if (signal?.aborted) return Promise.reject(this.abortError());
76
+ if (this.available > 0) {
77
+ this.available -= 1;
78
+ return Promise.resolve();
79
+ }
80
+ return new Promise((resolve, reject) => {
81
+ const grant = (): void => {
82
+ signal?.removeEventListener("abort", onAbort);
83
+ resolve();
84
+ };
85
+ const onAbort = (): void => {
86
+ const index = this.waiters.indexOf(grant);
87
+ if (index >= 0) this.waiters.splice(index, 1);
88
+ signal?.removeEventListener("abort", onAbort);
89
+ reject(this.abortError());
90
+ };
91
+ this.waiters.push(grant);
92
+ signal?.addEventListener("abort", onAbort, { once: true });
93
+ });
94
+ }
95
+
96
+ private abortError(): Error {
97
+ const error = new Error("Semaphore wait aborted");
98
+ error.name = "AbortError";
99
+ return error;
100
+ }
101
+
102
+ private release(): void {
103
+ const next = this.waiters.shift();
104
+ if (next) next(); // hand the slot straight to the next waiter
105
+ else this.available += 1;
106
+ }
107
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared param normalisation for strategies. Unknown/junk values fall back rather than
3
+ * silently changing semantics (I2 is lenient on unknown KEYS; a declared number that is
4
+ * NaN/negative still has to mean something honest).
5
+ */
6
+
7
+ /** A finite integer ≥ 1, else `fallback`. Fractional values floor. */
8
+ export function positiveInteger(value: unknown, fallback: number): number {
9
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback;
10
+ return Math.max(1, Math.floor(value));
11
+ }
12
+
13
+ /**
14
+ * A best-of-N threshold: positive integer, then clamped to the roster size.
15
+ * An unclamped `bestOf: 10` on a 3-member panel can never be reached, so every round
16
+ * would fall through to best-by-confidence while the header still claimed "best-of-10".
17
+ */
18
+ export function clampBestOf(value: unknown, rosterSize: number): { bestOf: number; clamped: boolean } {
19
+ const majority = Math.floor(rosterSize / 2) + 1;
20
+ const requested = positiveInteger(value, majority);
21
+ const bestOf = Math.min(requested, Math.max(1, rosterSize));
22
+ return { bestOf, clamped: bestOf !== requested };
23
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Reducers fold many `AgentResult`s into one. This module holds the two non-voting
3
+ * reducers — usage summation and labeled aggregation (for fanout); the voting
4
+ * reducer with the richer status union (winner|tie|no_consensus|invalid_outputs)
5
+ * lives in `voting.ts`.
6
+ */
7
+
8
+ import { type ChildUsage, emptyUsage } from "../engine/stream.ts";
9
+ import type { AgentResult, FailureKind } from "./types.ts";
10
+
11
+ export interface FailedResultCause {
12
+ error: string;
13
+ failureKind: FailureKind;
14
+ }
15
+
16
+ /** Preserve the most actionable cause when a whole panel yields no usable result.
17
+ * User cancellation wins even in a mixed panel. Otherwise a homogeneous technical
18
+ * failure survives; mixed failures or successful-but-protocol-invalid outputs are
19
+ * classified at the enclosing strategy's contract boundary. */
20
+ export function summarizeFailedResults(
21
+ results: AgentResult[],
22
+ fallbackError: string,
23
+ fallbackKind: FailureKind = "contract",
24
+ ): FailedResultCause {
25
+ const aborted = results.find((result) => !result.ok && result.failureKind === "abort");
26
+ if (aborted) return { error: aborted.error ?? "the run was aborted", failureKind: "abort" };
27
+
28
+ const failed = results.filter((result) => !result.ok);
29
+ if (failed.length > 0 && failed.length === results.length) {
30
+ const kinds = new Set(failed.map((result) => result.failureKind ?? "agent"));
31
+ if (kinds.size === 1) {
32
+ const failureKind = kinds.values().next().value as FailureKind;
33
+ const error = failed.find((result) => result.error)?.error ?? fallbackError;
34
+ return { error, failureKind };
35
+ }
36
+ }
37
+
38
+ return { error: fallbackError, failureKind: fallbackKind };
39
+ }
40
+
41
+ export function sumUsage(usages: ChildUsage[]): ChildUsage {
42
+ const total = emptyUsage();
43
+ for (const u of usages) {
44
+ total.input += u.input;
45
+ total.output += u.output;
46
+ total.cacheRead += u.cacheRead;
47
+ total.cacheWrite += u.cacheWrite;
48
+ total.cost += u.cost;
49
+ total.turns += u.turns;
50
+ }
51
+ return total;
52
+ }
53
+
54
+ export type ItemStatus = "completed" | "failed" | "not-run";
55
+
56
+ /** One status entry per `map` sub-item — the batch's audit trail, independent of `ownership`. */
57
+ export interface ItemLedgerEntry {
58
+ index: number;
59
+ item: string;
60
+ status: ItemStatus;
61
+ agent?: string;
62
+ failureKind?: FailureKind;
63
+ error?: string;
64
+ writeSet?: string[];
65
+ }
66
+
67
+ /** One ledger entry per item the splitter produced: `completed`/`failed` for an item a worker
68
+ * actually ran (from `results`, in item order), `not-run` for the `dropped` tail that never got
69
+ * a worker slot. Pure; carries the item's declared `writeSet` through when the caller kept it. */
70
+ export function itemLedger(
71
+ allItems: readonly { item: string; writeSet?: string[] }[],
72
+ results: readonly AgentResult[],
73
+ dropped: number,
74
+ ): ItemLedgerEntry[] {
75
+ const ranCount = Math.max(0, allItems.length - dropped);
76
+ return allItems.map((it, index) => {
77
+ const writeSet = it.writeSet ? { writeSet: it.writeSet } : {};
78
+ const result = index < ranCount ? results[index] : undefined;
79
+ if (!result) return { index, item: it.item, status: "not-run", ...writeSet };
80
+ return {
81
+ index,
82
+ item: it.item,
83
+ status: result.ok ? "completed" : "failed",
84
+ agent: result.agent,
85
+ ...(result.failureKind ? { failureKind: result.failureKind } : {}),
86
+ ...(!result.ok && result.error ? { error: result.error } : {}),
87
+ ...writeSet,
88
+ };
89
+ });
90
+ }
91
+
92
+ /** Combine fan-out results into one labeled-section result; ok iff all ok. */
93
+ export function aggregateResults(results: AgentResult[]): AgentResult {
94
+ const sections = results.map((r) => {
95
+ const status = r.ok ? "ok" : `failed${r.failureKind ? ` (${r.failureKind})` : ""}`;
96
+ const body = r.output || r.error || "(no output)";
97
+ const cause = !r.ok && r.error && r.output && r.error !== r.output ? `\n\nError: ${r.error}` : "";
98
+ return `### [${r.agent}] ${status}\n\n${body}${cause}`;
99
+ });
100
+ const aggregate: AgentResult = {
101
+ agent: "aggregate",
102
+ output: sections.join("\n\n---\n\n"),
103
+ structured: {
104
+ count: results.length,
105
+ results: results.map((r) => ({
106
+ agent: r.agent,
107
+ ok: r.ok,
108
+ output: r.output,
109
+ error: r.error ?? null,
110
+ failureKind: r.failureKind ?? null,
111
+ structured: r.structured ?? null,
112
+ })),
113
+ },
114
+ usage: sumUsage(results.map((r) => r.usage)),
115
+ ok: results.every((r) => r.ok),
116
+ };
117
+ if (!aggregate.ok) {
118
+ const cause = summarizeFailedResults(results, "one or more agents failed", "agent");
119
+ aggregate.error = cause.error;
120
+ aggregate.failureKind = cause.failureKind;
121
+ }
122
+ return aggregate;
123
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared rendering of a deliberation result — turn a member's structured fields into
3
+ * readable prose, NOT the raw JSON envelope. Used by every vote-style strategy (`magi`,
4
+ * `council-rounds`) so a ruling/dissent reads as text, never as tool JSON.
5
+ */
6
+
7
+ import { compactInlineText } from "../ui/presentation.ts";
8
+ import type { AgentResult } from "./types.ts";
9
+
10
+ /** Child-authored chrome: strip terminal control sequences and bound the display width. */
11
+ function safeChrome(value: string, max: number): string {
12
+ return compactInlineText(value, { maxChars: max });
13
+ }
14
+
15
+ function field(s: Record<string, unknown> | undefined, key: string): string | undefined {
16
+ const value = s?.[key];
17
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
18
+ }
19
+
20
+ function oneLine(value: string, max = 220): string {
21
+ const compact = value.replace(/\s+/g, " ").trim();
22
+ return compact.length > max ? `${compact.slice(0, max - 1)}…` : compact;
23
+ }
24
+
25
+ function memberMeta(s: Record<string, unknown> | undefined): string[] {
26
+ const meta: string[] = [];
27
+ const vote = field(s, "vote");
28
+ if (vote) meta.push(`vote ${vote}`);
29
+ const confidence = s?.confidence;
30
+ if (typeof confidence === "number" && Number.isFinite(confidence)) {
31
+ meta.push(`confidence ${Math.round(Math.max(0, Math.min(1, confidence)) * 100)}%`);
32
+ }
33
+ return meta;
34
+ }
35
+
36
+ /** A member's human-readable position from its structured fields (result + output). */
37
+ export function readableRuling(r: AgentResult): string {
38
+ const s = r.structured;
39
+ if (s) {
40
+ const parts: string[] = [];
41
+ const result = field(s, "result");
42
+ const output = field(s, "output");
43
+ if (result) parts.push(result);
44
+ if (output && output !== result) parts.push(output);
45
+ if (parts.length > 0) return parts.join("\n\n");
46
+ }
47
+ return r.output.trim();
48
+ }
49
+
50
+ /** A bounded one-line headline for collapsed strategy/tool presentations. */
51
+ export function rulingHeadline(r: AgentResult): string | undefined {
52
+ const headline = oneLine(readableRuling(r), 120);
53
+ return headline || undefined;
54
+ }
55
+
56
+ /** Compact UI-only view of one child result. Structured fields win so JSON envelopes
57
+ * never leak into the agent overlay; unstructured agents retain a prose fallback. */
58
+ export function compactMemberResult(r: AgentResult): string {
59
+ const s = r.structured;
60
+ if (!s) return oneLine(r.output) || "(no output)";
61
+ const meta = memberMeta(s);
62
+ const summary = (field(s, "result") ?? field(s, "output") ?? r.output.trim()) || "(no output)";
63
+ return `${meta.length > 0 ? `${meta.join(" · ")}\n` : ""}${oneLine(summary)}`;
64
+ }
65
+
66
+ /** Human UI rendering for aggregate/fanout results. The aggregate contract itself is
67
+ * untouched; this consumes its retained member envelopes only for presentation. */
68
+ export function humanizeAggregateResult(r: AgentResult): string | undefined {
69
+ const members = r.structured?.results;
70
+ if (!Array.isArray(members)) return undefined;
71
+ const lines: string[] = [];
72
+ for (const value of members) {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
74
+ const member = value as Record<string, unknown>;
75
+ const agent = typeof member.agent === "string" && member.agent.trim() ? member.agent.trim() : "member";
76
+ const ok = member.ok === true;
77
+ const output = typeof member.output === "string" ? member.output : "";
78
+ const structured = member.structured && typeof member.structured === "object" && !Array.isArray(member.structured)
79
+ ? (member.structured as Record<string, unknown>)
80
+ : undefined;
81
+ const view: AgentResult = { agent, output, usage: r.usage, ok };
82
+ if (structured) view.structured = structured;
83
+ const error = typeof member.error === "string" && member.error.trim() ? oneLine(member.error) : undefined;
84
+ const failureKind = typeof member.failureKind === "string" && member.failureKind.trim() ? member.failureKind.trim() : undefined;
85
+ const failure = !ok
86
+ ? `${failureKind ? ` (${failureKind})` : ""}${error ? `: ${error}` : ""}`
87
+ : "";
88
+ lines.push(`${ok ? "✓" : "✗"} ${agent}${failure}\n${compactMemberResult(view)}`);
89
+ }
90
+ return lines.length > 0 ? lines.join("\n\n") : undefined;
91
+ }
92
+
93
+ export interface CouncilResultPresentation {
94
+ headline: string | undefined;
95
+ status: string | undefined;
96
+ tally: Record<string, number> | undefined;
97
+ usedFallback: boolean | undefined;
98
+ body: string;
99
+ }
100
+
101
+ /** Plain Pi result text; the call renderer owns the single `council …` title. */
102
+ export function formatCouncilResult(input: CouncilResultPresentation, expanded: boolean, expandHint = "ctrl+o"): string {
103
+ const body = input.body.trim();
104
+ if (expanded) return body || "(the council returned no ruling)";
105
+ const firstBodyLine = body.split("\n").find((line) => line.trim())?.trim();
106
+ // headline/status/tally carry the winning member's own words (its `result`/`vote`), and
107
+ // only `body` is sanitized upstream — so bound and strip them here. The tally keys in
108
+ // particular are whole prose answers whenever a member omits its `vote` field.
109
+ const status = safeChrome(input.status?.replace(/_/g, " ") ?? "", 48);
110
+ const verdict = safeChrome(input.headline?.trim() || firstBodyLine || status || "(ruling)", 96);
111
+ const meta: string[] = [];
112
+ if (status && status.toLowerCase() !== verdict.toLowerCase()) meta.push(status);
113
+ if (input.usedFallback) meta.push("confidence fallback");
114
+ const tally = input.tally
115
+ ? safeChrome(Object.entries(input.tally).map(([key, count]) => `${key}=${count}`).join(", "), 120)
116
+ : "";
117
+ if (tally) meta.push(`tally ${tally}`);
118
+ return `${verdict}${meta.length > 0 ? ` · ${meta.join(" · ")}` : ""} · ${expandHint}`;
119
+ }
120
+
121
+ /** A one-line dissent entry: who, how they voted, and their one-line position. */
122
+ export function dissentLine(r: AgentResult): string {
123
+ const s = r.structured;
124
+ const vote = field(s, "vote") ?? "?";
125
+ const summary = field(s, "result") ?? field(s, "output") ?? r.output.trim();
126
+ return `[${r.agent} · ${vote}] ${oneLine(summary || "(no output)")}`;
127
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Roster loading — `teams.yaml` maps a team name to an ordered list of members.
3
+ * Referenced by a persona's `orchestration.roster` or `council.roster`.
4
+ *
5
+ * A member is either a bare agent name (`- scout`) OR a map that specialises ONE
6
+ * agent inline — `{ agent, role?, model?, skills? }` — so an ensemble of distinct
7
+ * perspectives can be built from a SINGLE agent (e.g. one `reviewer` run three times
8
+ * with different `role`s and models) instead of one .md file per perspective. That is
9
+ * the "skills + role provide specialisation, not files" principle applied to rosters.
10
+ *
11
+ * Pure: reuses the core YAML-subset parser. `rosterSpec` normalises a member into the
12
+ * run-spec fields the SDK's `agent()` accepts; `makeRoster` adapts a team map to the
13
+ * SDK's `Roster` interface (unknown team → empty list).
14
+ */
15
+
16
+ import { asBoolean, asStringArray, parseYamlSubset } from "../core/frontmatter.ts";
17
+ import type { Roster } from "./sdk.ts";
18
+
19
+ /** A roster member: a bare agent name, or an inline specialisation of one agent. `tools`/
20
+ * `isolation`/`mcp` bring a roster member to parity with an ad-hoc `delegate` task's own
21
+ * knobs (see `tools/delegate.ts`'s `DelegateTask`). */
22
+ export type RosterMember =
23
+ | string
24
+ | { agent: string; role?: string; model?: string; skills?: string[]; tools?: string[]; isolation?: "none" | "worktree"; mcp?: boolean };
25
+
26
+ /** The normalised run-spec fields a member contributes (agent + any specialisation). */
27
+ export interface RosterSpec {
28
+ agent: string;
29
+ role?: string;
30
+ model?: string;
31
+ skills?: string[];
32
+ tools?: string[];
33
+ isolation?: "none" | "worktree";
34
+ mcp?: boolean;
35
+ }
36
+
37
+ /** Normalise a member into the fields `sdk.agent()` accepts (a bare name → just `agent`).
38
+ * `tools`/`isolation`/`mcp` map onto the SAME `AgentRunSpec` fields the `delegate` path's
39
+ * `specOf()` uses for these three concepts — including explicit `none`/`false` overrides —
40
+ * so a roster member and an ad-hoc task behave identically once specialised. */
41
+ export function rosterSpec(member: RosterMember): RosterSpec {
42
+ if (typeof member === "string") return { agent: member };
43
+ const spec: RosterSpec = { agent: member.agent };
44
+ if (member.role?.trim()) spec.role = member.role.trim();
45
+ if (member.model?.trim()) spec.model = member.model.trim();
46
+ if (member.skills && member.skills.length > 0) spec.skills = member.skills;
47
+ if (member.tools !== undefined) spec.tools = member.tools;
48
+ if (member.isolation !== undefined) spec.isolation = member.isolation;
49
+ if (member.mcp !== undefined) spec.mcp = member.mcp;
50
+ return spec;
51
+ }
52
+
53
+ // Emphasised keywords in a role are the lens; small connective words are not — drop
54
+ // them so "Focus ONLY on the SECURITY lens…" hints "SECURITY", not "ONLY".
55
+ const ROLE_STOPWORDS = new Set([
56
+ "ONLY", "THE", "AND", "OR", "NOT", "FOR", "YOU", "YOUR", "ALL", "ANY", "USE", "VIA", "ONE",
57
+ "ON", "IN", "OF", "TO", "IS", "IT", "BE", "DO", "AS", "AT", "BY", "AN", "A",
58
+ ]);
59
+
60
+ /** A short emphasis hint from a member's role text — the first salient ALL-CAPS keyword
61
+ * (e.g. "SECURITY" from "Focus ONLY on the SECURITY lens…"), else the first few words.
62
+ * Used to disambiguate same-agent roster-role members in the UI tree. Pure. */
63
+ export function roleHint(role: string): string {
64
+ const words = role.split(/\s+/);
65
+ for (const w of words) {
66
+ const clean = w.replace(/[^A-Za-z0-9]/g, "");
67
+ if (clean.length >= 3 && /[A-Z]/.test(clean) && clean === clean.toUpperCase() && !ROLE_STOPWORDS.has(clean)) {
68
+ return clean;
69
+ }
70
+ }
71
+ const brief = words.slice(0, 4).join(" ").replace(/[:.,;].*$/, "").trim();
72
+ return brief.length > 20 ? `${brief.slice(0, 20)}…` : brief || "role";
73
+ }
74
+
75
+ /** The base UI label for a member: `agent`, or `agent · HINT` when it carries a role —
76
+ * so an ensemble of one agent under several roles shows as distinct nodes. Pure. */
77
+ export function memberBaseLabel(member: RosterMember): string {
78
+ const s = rosterSpec(member);
79
+ return s.role ? `${s.agent} · ${roleHint(s.role)}` : s.agent;
80
+ }
81
+
82
+ /** Disambiguated node keys for a roster, aligned to input order: the base label, with a
83
+ * `#N` suffix only when the same base repeats. This is the SAME derivation the SDK applies
84
+ * per `agent()` call (base from role, occurrence-suffixed), so the tree's seeded "queued"
85
+ * nodes line up with the live ones instead of collapsing three same-name members into one.
86
+ * Pure. */
87
+ export function rosterNodeKeys(members: RosterMember[]): string[] {
88
+ const seen = new Map<string, number>();
89
+ return members.map((m) => {
90
+ const base = memberBaseLabel(m);
91
+ const n = (seen.get(base) ?? 0) + 1;
92
+ seen.set(base, n);
93
+ return n === 1 ? base : `${base}#${n}`;
94
+ });
95
+ }
96
+
97
+ /** Coerce one raw YAML value into a roster member (string name, or `{ agent, … }` map). */
98
+ function toMember(raw: unknown): RosterMember | undefined {
99
+ if (typeof raw === "string") return raw.trim() ? raw.trim() : undefined;
100
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
101
+ const o = raw as Record<string, unknown>;
102
+ if (typeof o.agent !== "string" || !o.agent.trim()) return undefined;
103
+ const m: { agent: string; role?: string; model?: string; skills?: string[]; tools?: string[]; isolation?: "none" | "worktree"; mcp?: boolean } = {
104
+ agent: o.agent.trim(),
105
+ };
106
+ if (typeof o.role === "string" && o.role.trim()) m.role = o.role.trim();
107
+ if (typeof o.model === "string" && o.model.trim()) m.model = o.model.trim();
108
+ const skills = asStringArray(o.skills);
109
+ if (skills) m.skills = skills;
110
+ const tools = asStringArray(o.tools);
111
+ if (tools) m.tools = tools;
112
+ if (o.isolation === "worktree" || o.isolation === "none") m.isolation = o.isolation;
113
+ const mcp = asBoolean(o.mcp);
114
+ if (mcp !== undefined) m.mcp = mcp;
115
+ return m;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ export function parseTeams(yaml: string): Record<string, RosterMember[]> {
121
+ const raw = parseYamlSubset(yaml);
122
+ const teams: Record<string, RosterMember[]> = {};
123
+ for (const [name, value] of Object.entries(raw)) {
124
+ const items: unknown[] = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
125
+ const members = items.map(toMember).filter((m): m is RosterMember => m !== undefined);
126
+ if (members.length > 0) teams[name] = members;
127
+ }
128
+ return teams;
129
+ }
130
+
131
+ export function makeRoster(teams: Record<string, RosterMember[]>): Roster {
132
+ return { team: (name: string) => teams[name] ?? [] };
133
+ }