@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,173 @@
1
+ /**
2
+ * PersonaController — applies a persona to the host session and restores
3
+ * baselines symmetrically. Drives an injected `PersonaHost` (a subset of the Pi
4
+ * ExtensionAPI) so the snapshot/restore discipline is unit-testable.
5
+ *
6
+ * Invariants (lose-nothing): model/thinking baseline is snapshotted ONCE on the
7
+ * first override and restored when a later persona omits the axis; a
8
+ * declared-but-unavailable model/invalid thinking keeps the current value with
9
+ * no override and no restore; tools are restored from the FULL registry.
10
+ */
11
+
12
+ import { type CapabilityPermissions, type EffectiveCapabilities, resolveCapabilities } from "../core/capabilities.ts";
13
+ import { sanitizeStatusLabel } from "../core/display-label.ts";
14
+ import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
15
+ import { type GateResult, gateToolCall } from "./gating.ts";
16
+ import { composeSystemPrompt, type Persona } from "./persona.ts";
17
+
18
+ /** Opaque model handle — the controller does not care about its internals. */
19
+ export interface ModelHandle {
20
+ provider: string;
21
+ id: string;
22
+ }
23
+
24
+ /** The minimal host surface the controller drives (a subset of ExtensionAPI). */
25
+ export interface PersonaHost {
26
+ allToolNames(): string[];
27
+ knownAgents(): string[];
28
+ setActiveTools(names: string[]): void;
29
+ getThinkingLevel(): ThinkingLevel;
30
+ setThinkingLevel(level: ThinkingLevel): void;
31
+ getModel(): ModelHandle | undefined;
32
+ findModel(ref: string): ModelHandle | undefined;
33
+ setModel(model: ModelHandle): void | Promise<void>;
34
+ setStatus(text: string | undefined): void;
35
+ /** Optional: a declared-but-unavailable model/thinking would otherwise fail silently. */
36
+ warn?(message: string): void;
37
+ }
38
+
39
+ export class PersonaController {
40
+ private active: Persona | undefined;
41
+ private toolsRestricted = false;
42
+ private baselineModel: ModelHandle | undefined;
43
+ private modelOverridden = false;
44
+ private baselineThinking: ThinkingLevel | undefined;
45
+ private thinkingOverridden = false;
46
+
47
+ private readonly host: PersonaHost;
48
+ private readonly delegateDefaultAllow: boolean;
49
+ private caps: EffectiveCapabilities | undefined;
50
+
51
+ constructor(host: PersonaHost, delegateDefaultAllow = true) {
52
+ this.host = host;
53
+ this.delegateDefaultAllow = delegateDefaultAllow;
54
+ }
55
+
56
+ get activePersona(): Persona | undefined {
57
+ return this.active;
58
+ }
59
+
60
+ async activate(persona: Persona): Promise<void> {
61
+ this.active = persona;
62
+ this.caps = this.resolveCaps(persona);
63
+ this.host.setStatus(sanitizeStatusLabel(persona.label, persona.name));
64
+ await this.applyModel(persona);
65
+ this.applyThinking(persona);
66
+ this.applyTools(persona);
67
+ }
68
+
69
+ async deactivate(): Promise<void> {
70
+ this.active = undefined;
71
+ this.caps = undefined;
72
+ this.host.setStatus(undefined);
73
+ this.restoreTools();
74
+ await this.restoreModel();
75
+ this.restoreThinking();
76
+ }
77
+
78
+ /** For the `before_agent_start` hook: the persona-composed prompt, or undefined.
79
+ * `spine` is the session's shared behavioral layer (docs/SPINE.md) — a persona-less
80
+ * turn returns undefined and the hook applies the layer itself. */
81
+ composePrompt(base: string, spine?: string): string | undefined {
82
+ return this.active ? composeSystemPrompt(base, this.active, spine) : undefined;
83
+ }
84
+
85
+ /** For the `tool_call` hook: a block result, or undefined to allow. */
86
+ gate(toolName: string, input: unknown): GateResult | undefined {
87
+ return this.active && this.caps ? gateToolCall(this.caps, this.active.label, toolName, input) : undefined;
88
+ }
89
+
90
+ /** The active persona's resolved capabilities (for diagnostics / `/doctor`). */
91
+ get capabilities(): EffectiveCapabilities | undefined {
92
+ return this.caps;
93
+ }
94
+
95
+ private resolveCaps(persona: Persona): EffectiveCapabilities {
96
+ const permissions: CapabilityPermissions = {};
97
+ if (persona.tools) permissions.tools = persona.tools;
98
+ if (persona.delegate) permissions.delegate = persona.delegate;
99
+ return resolveCapabilities({
100
+ allToolNames: this.host.allToolNames(),
101
+ knownAgents: this.host.knownAgents(),
102
+ permissions,
103
+ delegateDefaultAllow: this.delegateDefaultAllow,
104
+ });
105
+ }
106
+
107
+ private async applyModel(persona: Persona): Promise<void> {
108
+ if (persona.model) {
109
+ const model = this.host.findModel(persona.model);
110
+ if (model) {
111
+ if (!this.modelOverridden) {
112
+ this.baselineModel = this.host.getModel();
113
+ this.modelOverridden = true;
114
+ }
115
+ await this.host.setModel(model);
116
+ } else {
117
+ this.host.warn?.(
118
+ `persona "${persona.name}" declared model "${persona.model}" which is not configured; keeping the current model`,
119
+ );
120
+ }
121
+ } else {
122
+ await this.restoreModel();
123
+ }
124
+ }
125
+
126
+ private async restoreModel(): Promise<void> {
127
+ if (!this.modelOverridden) return;
128
+ this.modelOverridden = false;
129
+ const baseline = this.baselineModel;
130
+ this.baselineModel = undefined;
131
+ if (baseline) await this.host.setModel(baseline);
132
+ }
133
+
134
+ private applyThinking(persona: Persona): void {
135
+ if (persona.thinking !== undefined) {
136
+ if (isThinkingLevel(persona.thinking)) {
137
+ if (!this.thinkingOverridden) {
138
+ this.baselineThinking = this.host.getThinkingLevel();
139
+ this.thinkingOverridden = true;
140
+ }
141
+ this.host.setThinkingLevel(persona.thinking);
142
+ }
143
+ // declared-but-invalid → keep current (no override, no restore)
144
+ } else {
145
+ this.restoreThinking();
146
+ }
147
+ }
148
+
149
+ private restoreThinking(): void {
150
+ if (!this.thinkingOverridden) return;
151
+ this.thinkingOverridden = false;
152
+ const baseline = this.baselineThinking;
153
+ this.baselineThinking = undefined;
154
+ if (baseline) this.host.setThinkingLevel(baseline);
155
+ }
156
+
157
+ private applyTools(persona: Persona): void {
158
+ if (persona.tools) {
159
+ // Use the resolved capability set so a tools-restricted persona still keeps
160
+ // `delegate` active (unless it explicitly denied it) — delegation is preserved.
161
+ this.host.setActiveTools([...(this.caps?.tools ?? new Set<string>())]);
162
+ this.toolsRestricted = true;
163
+ } else {
164
+ this.restoreTools();
165
+ }
166
+ }
167
+
168
+ private restoreTools(): void {
169
+ if (!this.toolsRestricted) return;
170
+ this.host.setActiveTools(this.host.allToolNames());
171
+ this.toolsRestricted = false;
172
+ }
173
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Persona delegation/tool gating — pure. The `tool_call` hook consults the
3
+ * persona's resolved `EffectiveCapabilities` (built once on activation) so every
4
+ * tool call / delegate target passes the same unified, deny-wins gate (I4).
5
+ * Delegate target extraction reads structured params (`agent` / `tasks[].agent`),
6
+ * never fragile text parsing.
7
+ */
8
+
9
+ import { canCallTool, canDelegateTo, type EffectiveCapabilities } from "../core/capabilities.ts";
10
+
11
+ /** Pull the delegate target agent names out of a delegate tool call's input. */
12
+ export function extractDelegateTargets(input: unknown): string[] {
13
+ if (!input || typeof input !== "object") return [];
14
+ const o = input as Record<string, unknown>;
15
+ const targets: string[] = [];
16
+ if (typeof o.agent === "string") targets.push(o.agent);
17
+ if (Array.isArray(o.tasks)) {
18
+ for (const t of o.tasks) {
19
+ if (t && typeof t === "object" && typeof (t as Record<string, unknown>).agent === "string") {
20
+ targets.push((t as Record<string, unknown>).agent as string);
21
+ }
22
+ }
23
+ }
24
+ return targets;
25
+ }
26
+
27
+ /** The tool overrides a delegate call requests for its sub-agents (single + tasks[]). */
28
+ export function extractDelegateTools(input: unknown): string[] {
29
+ const out: string[] = [];
30
+ const collect = (o: Record<string, unknown>): void => {
31
+ if (Array.isArray(o.tools)) for (const t of o.tools) if (typeof t === "string") out.push(t);
32
+ };
33
+ if (input && typeof input === "object") {
34
+ const o = input as Record<string, unknown>;
35
+ collect(o);
36
+ if (Array.isArray(o.tasks)) {
37
+ for (const t of o.tasks) if (t && typeof t === "object") collect(t as Record<string, unknown>);
38
+ }
39
+ }
40
+ return [...new Set(out)];
41
+ }
42
+
43
+ export interface GateResult {
44
+ block: true;
45
+ reason: string;
46
+ }
47
+
48
+ /** Decide whether a tool call is blocked by the active capabilities; undefined = allow. */
49
+ export function gateToolCall(
50
+ caps: EffectiveCapabilities,
51
+ personaLabel: string,
52
+ toolName: string,
53
+ input: unknown,
54
+ delegateTool = "delegate",
55
+ ): GateResult | undefined {
56
+ if (!canCallTool(caps, toolName)) {
57
+ return { block: true, reason: `Persona "${personaLabel}" may not use tool: ${toolName}.` };
58
+ }
59
+ if (toolName !== delegateTool) return undefined;
60
+
61
+ const blocked = extractDelegateTargets(input).filter((t) => !canDelegateTo(caps, t));
62
+ if (blocked.length > 0) {
63
+ // Self-correcting: name what IS allowed, so a persona-gated refusal teaches the fix
64
+ // instead of reading like "delegation doesn't work here".
65
+ const allowed = [...caps.delegateTargets];
66
+ const hint =
67
+ allowed.length > 0
68
+ ? ` Allowed targets: ${allowed.slice(0, 12).join(", ")}${allowed.length > 12 ? ", …" : ""}.`
69
+ : " This persona has no delegate targets.";
70
+ return { block: true, reason: `Persona "${personaLabel}" may not delegate to: ${blocked.join(", ")}.${hint}` };
71
+ }
72
+ // I4: a sub-agent's tool override cannot exceed the persona's own capabilities —
73
+ // otherwise a tools-restricted persona could escalate via `delegate({ tools: [...] })`.
74
+ const escalated = extractDelegateTools(input).filter((t) => !canCallTool(caps, t));
75
+ if (escalated.length > 0) {
76
+ return {
77
+ block: true,
78
+ reason: `Persona "${personaLabel}" cannot grant a sub-agent tools it lacks: ${escalated.join(", ")}.`,
79
+ };
80
+ }
81
+ return undefined;
82
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Running the active persona's strategy — shared by the `/orchestrate` command
3
+ * and the mandatory turn-interception (input hook). Testable: the engine is
4
+ * injected, so no real `pi` spawn is needed to verify the wiring.
5
+ */
6
+
7
+ import type { RunLimits } from "../core/capabilities.ts";
8
+ import { makeRoster, type RosterMember } from "../orchestration/roster.ts";
9
+ import {
10
+ type AgentProgress,
11
+ type AgentStatus,
12
+ makeSDK,
13
+ type SDKDeps,
14
+ type SteerFn,
15
+ type StrategyEngine,
16
+ type StrategyInput,
17
+ } from "../orchestration/sdk.ts";
18
+ import { getStrategy, strategyNames } from "../orchestration/strategy.ts";
19
+ import type { AgentResult } from "../orchestration/types.ts";
20
+ import type { OrchestrationGrammar } from "./persona.ts";
21
+
22
+ /** The strategy a persona's grammar runs: explicit strategy, or `parallel` → fanout. */
23
+ export function resolveStrategyName(orch: OrchestrationGrammar): string | undefined {
24
+ if (orch.strategy) return orch.strategy;
25
+ if (orch.mode === "parallel") return "fanout";
26
+ if (orch.mode === "pipeline") return "pipeline";
27
+ // A mode that names *what* to run without naming it is a misconfiguration, not "nothing to
28
+ // run" — the persona's mandatory orchestration would otherwise silently never fire.
29
+ if (orch.mode === "strategy") throw new Error('orchestration "mode: strategy" needs a "strategy:" name');
30
+ if (orch.mode === "flow" && !orch.flow) throw new Error('orchestration "mode: flow" needs a "flow:" name');
31
+ return undefined;
32
+ }
33
+
34
+ export interface RunStrategyDeps {
35
+ engine: StrategyEngine;
36
+ teams: Record<string, RosterMember[]>;
37
+ limits: RunLimits;
38
+ signal?: AbortSignal;
39
+ /** The session's own model (`provider/id`) — a strategy's last-resort recovery model for a
40
+ * member whose own model broke with no healthy peer to borrow from. */
41
+ sessionModel?: string;
42
+ log?: (message: string) => void;
43
+ /** Per-agent lifecycle, for live UI (which roster agent is running/done + its result).
44
+ * `key` is a run-unique display id (disambiguates same-agent roster-role members). */
45
+ onAgentStatus?: (agent: string, status: AgentStatus, result?: AgentResult, key?: string) => void;
46
+ /** Per-agent streaming progress (rolling output), for live UI. */
47
+ onAgentProgress?: (agent: string, progress: AgentProgress, key?: string) => void;
48
+ /** Called as each agent starts with a handle to abort just that agent (UI stop). */
49
+ onAgentStart?: (agent: string, abort: () => void, key?: string) => void;
50
+ /** Called once an agent is live with a handle to steer it (in-process engine only). */
51
+ onAgentSteerable?: (agent: string, steer: SteerFn, key?: string) => void;
52
+ /** See `SDKDeps.canSpawn` — every strategy spawn, not only `delegate`. */
53
+ canSpawn?: (agent: string) => boolean;
54
+ }
55
+
56
+ /** Run the persona's strategy on a task, or return null if it has no runnable strategy. */
57
+ export async function runPersonaStrategy(
58
+ orch: OrchestrationGrammar,
59
+ task: string,
60
+ deps: RunStrategyDeps,
61
+ ): Promise<AgentResult | null> {
62
+ const name = resolveStrategyName(orch);
63
+ if (!name) return null; // no mode/strategy → nothing to run (e.g. a solo persona); caller runs normally
64
+ const strategy = getStrategy(name);
65
+ // A NAMED-but-unknown strategy is a misconfiguration, not "nothing to run" — fail loudly
66
+ // so the council/flow surfaces it instead of an opaque "no ruling".
67
+ if (!strategy) throw new Error(`unknown strategy "${name}" (available: ${strategyNames().join(", ")})`);
68
+
69
+ const sdkDeps: SDKDeps = { engine: deps.engine, roster: makeRoster(deps.teams), limits: deps.limits };
70
+ if (deps.signal) sdkDeps.signal = deps.signal;
71
+ if (deps.sessionModel) sdkDeps.sessionModel = deps.sessionModel;
72
+ if (deps.log) sdkDeps.log = deps.log;
73
+ if (deps.onAgentStatus) sdkDeps.onAgentStatus = deps.onAgentStatus;
74
+ if (deps.onAgentProgress) sdkDeps.onAgentProgress = deps.onAgentProgress;
75
+ if (deps.onAgentStart) sdkDeps.onAgentStart = deps.onAgentStart;
76
+ if (deps.onAgentSteerable) sdkDeps.onAgentSteerable = deps.onAgentSteerable;
77
+ if (deps.canSpawn) sdkDeps.canSpawn = deps.canSpawn;
78
+
79
+ const input: StrategyInput = { task, params: orch.params ?? {} };
80
+ if (orch.roster) input.roster = orch.roster;
81
+
82
+ return strategy.run(input, makeSDK(sdkDeps));
83
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Persona definition + parsing — the supervisor identity and its orchestration
3
+ * grammar. Pure module (uses only the core helpers, no Pi imports).
4
+ *
5
+ * A persona file is Markdown: YAML-subset frontmatter + a body (the supervisor
6
+ * system prompt). `persona: true` marks it switchable. The optional
7
+ * `orchestration:` block is the control surface; absent ⇒ L0 (opportunistic).
8
+ */
9
+
10
+ import { asBoolean, asPermission, asStringArray, parseYamlSubset, splitFrontmatter } from "../core/frontmatter.ts";
11
+ import type { Permission } from "../core/permissions.ts";
12
+ import { asSystemPromptMode, type SystemPromptMode } from "../core/types.ts";
13
+
14
+ export type OrchestrationMode = "solo" | "parallel" | "pipeline" | "strategy" | "flow";
15
+ const ORCHESTRATION_MODES: readonly OrchestrationMode[] = ["solo", "parallel", "pipeline", "strategy", "flow"];
16
+
17
+ /** The declarative orchestration grammar carried by a persona (§4.2 of the spec). */
18
+ export interface OrchestrationGrammar {
19
+ mode: OrchestrationMode;
20
+ strategy?: string;
21
+ flow?: string;
22
+ roster?: string;
23
+ /** Strategy parameters (e.g. rounds, aggregate, critic). */
24
+ params?: Record<string, unknown>;
25
+ }
26
+
27
+ /**
28
+ * A tool-driven council: which strategy + roster + params the `council` tool runs
29
+ * on demand. Unlike `orchestration` it does NOT trigger the mandatory input-hook —
30
+ * the supervisor calls the council, then executes the ruling. Fully data-driven: a
31
+ * new ensemble (more members, a different vote, a multi-round strategy) is just a
32
+ * new team + (optional) strategy + a persona declaring them here.
33
+ */
34
+ export interface CouncilSpec {
35
+ strategy: string;
36
+ roster?: string;
37
+ params?: Record<string, unknown>;
38
+ }
39
+
40
+ /** A council block as authored — `strategy` may be supplied by a `preset` (expanded at load). */
41
+ export interface CouncilDraft {
42
+ strategy?: string;
43
+ roster?: string;
44
+ params?: Record<string, unknown>;
45
+ /** A named preset (presets/<name>.preset.json) providing defaults; authored fields override. */
46
+ preset?: string;
47
+ }
48
+
49
+ /** Optional, fully data-driven runtime discipline for any persona. No persona name has special
50
+ * behavior: a project/user persona opts into the same packet, contract, and write-set gates by
51
+ * declaring this block in frontmatter. */
52
+ export interface DelegationPolicy {
53
+ /** Require every delegate leg to carry the complete structured `brief`. */
54
+ requireBrief?: boolean;
55
+ /** Default output contract applied to delegate legs that omit one. */
56
+ outputContract?: string;
57
+ /** Require ownership declarations for parallel writers and reject overlapping paths. */
58
+ requireDisjointWrites?: boolean;
59
+ /** Require declared verifier agents to start after, not concurrently with, a mutation. */
60
+ requireFreshVerification?: boolean;
61
+ /** Agents treated as verifiers for the runtime stale-verification concurrency gate. */
62
+ verificationAgents?: string[];
63
+ }
64
+
65
+ export interface Persona {
66
+ name: string;
67
+ label: string;
68
+ /** `persona: true` marks a file as a switchable supervisor persona. */
69
+ isPersona: boolean;
70
+ description?: string;
71
+ model?: string;
72
+ thinking?: string;
73
+ systemPromptMode: SystemPromptMode;
74
+ delegate?: Permission;
75
+ tools?: Permission;
76
+ /** Absent ⇒ L0 opportunistic delegation. */
77
+ orchestration?: OrchestrationGrammar;
78
+ /** Tool-driven council the `council` tool runs (no mandatory firing). After load, any
79
+ * `preset` is expanded so `strategy`/`roster`/`params` are concrete. */
80
+ council?: CouncilDraft;
81
+ /** Optional delegation runtime/brief policy; usable by any custom persona. */
82
+ delegation?: DelegationPolicy;
83
+ /** Opt into the comm plane: give async children a `contact_supervisor` tool (§4.9). */
84
+ coaching?: boolean;
85
+ /** `spine: false` opts this persona out of the shared behavioral layer (docs/SPINE.md) —
86
+ * for short verdict personas that do not need the baseline. Only an explicit false is
87
+ * recorded; absent ⇒ the session-level setting decides. */
88
+ spine?: boolean;
89
+ /** The Markdown body — the supervisor system prompt. */
90
+ body: string;
91
+ /** Where it was loaded from (for diagnostics / `/doctor`). */
92
+ source: string;
93
+ }
94
+
95
+ function parseOrchestration(raw: unknown): OrchestrationGrammar | undefined {
96
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
97
+ const o = raw as Record<string, unknown>;
98
+ const mode: OrchestrationMode =
99
+ typeof o.mode === "string" && (ORCHESTRATION_MODES as readonly string[]).includes(o.mode)
100
+ ? (o.mode as OrchestrationMode)
101
+ : "solo";
102
+ const grammar: OrchestrationGrammar = { mode };
103
+ if (typeof o.strategy === "string" && o.strategy.trim()) grammar.strategy = o.strategy.trim();
104
+ if (typeof o.flow === "string" && o.flow.trim()) grammar.flow = o.flow.trim();
105
+ if (typeof o.roster === "string" && o.roster.trim()) grammar.roster = o.roster.trim();
106
+ if (o.params && typeof o.params === "object" && !Array.isArray(o.params)) {
107
+ grammar.params = o.params as Record<string, unknown>;
108
+ }
109
+ return grammar;
110
+ }
111
+
112
+ /**
113
+ * Parse a persona file. Returns `null` when `name` is missing (required).
114
+ * `systemPromptMode` defaults to `append` (a persona augments Pi's base prompt).
115
+ */
116
+ export function parsePersona(content: string, source: string): Persona | null {
117
+ const { frontmatter, body } = splitFrontmatter(content);
118
+ const fm = parseYamlSubset(frontmatter);
119
+ const name = typeof fm.name === "string" ? fm.name.trim() : "";
120
+ if (!name) return null;
121
+
122
+ const label = typeof fm.label === "string" && fm.label.trim() ? fm.label : name;
123
+ const persona: Persona = {
124
+ name,
125
+ label,
126
+ isPersona: fm.persona === true,
127
+ systemPromptMode: asSystemPromptMode(fm.systemPromptMode, "append"),
128
+ body,
129
+ source,
130
+ };
131
+ if (typeof fm.description === "string" && fm.description.trim()) persona.description = fm.description.trim();
132
+ if (typeof fm.model === "string" && fm.model.trim()) persona.model = fm.model.trim();
133
+ if (typeof fm.thinking === "string" && fm.thinking.trim()) persona.thinking = fm.thinking.trim();
134
+
135
+ const delegate = asPermission(fm.delegate);
136
+ if (delegate) persona.delegate = delegate;
137
+ const tools = asPermission(fm.tools);
138
+ if (tools) persona.tools = tools;
139
+
140
+ const orchestration = parseOrchestration(fm.orchestration);
141
+ if (orchestration) persona.orchestration = orchestration;
142
+ const council = parseCouncil(fm.council);
143
+ if (council) persona.council = council;
144
+ const delegation = parseDelegationPolicy(fm.delegation);
145
+ if (delegation) persona.delegation = delegation;
146
+ if (asBoolean(fm.coaching) === true) persona.coaching = true;
147
+ if (asBoolean(fm.spine) === false) persona.spine = false;
148
+
149
+ return persona;
150
+ }
151
+
152
+ function parseDelegationPolicy(value: unknown): DelegationPolicy | undefined {
153
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
154
+ const o = value as Record<string, unknown>;
155
+ const policy: DelegationPolicy = {};
156
+ const requireBrief = asBoolean(o.requireBrief);
157
+ if (requireBrief !== undefined) policy.requireBrief = requireBrief;
158
+ const requireDisjointWrites = asBoolean(o.requireDisjointWrites);
159
+ if (requireDisjointWrites !== undefined) policy.requireDisjointWrites = requireDisjointWrites;
160
+ const requireFreshVerification = asBoolean(o.requireFreshVerification);
161
+ if (requireFreshVerification !== undefined) policy.requireFreshVerification = requireFreshVerification;
162
+ const verificationAgents = asStringArray(o.verificationAgents);
163
+ if (verificationAgents) policy.verificationAgents = verificationAgents;
164
+ if (typeof o.outputContract === "string" && o.outputContract.trim()) policy.outputContract = o.outputContract.trim();
165
+ return Object.keys(policy).length > 0 ? policy : undefined;
166
+ }
167
+
168
+ /** Parse a persona's `council:` block (strategy + roster + params, or just a `preset`). */
169
+ function parseCouncil(value: unknown): CouncilDraft | undefined {
170
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
171
+ const o = value as Record<string, unknown>;
172
+ const strategy = typeof o.strategy === "string" && o.strategy.trim() ? o.strategy.trim() : "";
173
+ const preset = typeof o.preset === "string" && o.preset.trim() ? o.preset.trim() : "";
174
+ if (!strategy && !preset) return undefined; // a council needs at least a strategy or a preset
175
+ const spec: CouncilDraft = {};
176
+ if (strategy) spec.strategy = strategy;
177
+ if (preset) spec.preset = preset;
178
+ if (typeof o.roster === "string" && o.roster.trim()) spec.roster = o.roster.trim();
179
+ if (o.params && typeof o.params === "object" && !Array.isArray(o.params)) {
180
+ spec.params = o.params as Record<string, unknown>;
181
+ }
182
+ return spec;
183
+ }
184
+
185
+ /** Expand a council `preset` (presets/<name>.preset.json) into concrete fields: the preset
186
+ * supplies defaults, authored fields win, and `params` shallow-merge (authored over preset).
187
+ * The `preset` key is consumed. An unknown preset just drops the key (authored fields kept). */
188
+ export function expandCouncilPreset(draft: CouncilDraft, presets: Record<string, Partial<CouncilSpec>>): CouncilDraft {
189
+ if (!draft.preset) return draft;
190
+ const base = presets[draft.preset];
191
+ const { preset: _consumed, ...authored } = draft;
192
+ if (!base) return authored;
193
+ const merged: CouncilDraft = { ...base, ...authored };
194
+ if (base.params || authored.params) merged.params = { ...base.params, ...authored.params };
195
+ return merged;
196
+ }
197
+
198
+ /** Per-call council selection. `persona` borrows only that persona's expanded
199
+ * council declaration; it never activates the persona or imports its prompt,
200
+ * model, tools, or permissions. Explicit strategy/roster/params remain local
201
+ * overrides for backward compatibility. */
202
+ export interface CouncilInvocation {
203
+ persona?: string | undefined;
204
+ strategy?: string | undefined;
205
+ roster?: string | undefined;
206
+ params?: Record<string, unknown> | undefined;
207
+ }
208
+
209
+ export interface ResolvedCouncilInvocation {
210
+ strategy: string;
211
+ roster: string;
212
+ params: Record<string, unknown>;
213
+ /** Persona whose council declaration supplied the defaults, when any. */
214
+ persona?: string;
215
+ }
216
+
217
+ export type CouncilInvocationResolution =
218
+ | { ok: true; value: ResolvedCouncilInvocation }
219
+ | { ok: false; error: string };
220
+
221
+ /** Resolve a council call without mutating the active persona.
222
+ *
223
+ * An explicit persona is intentionally strict: it must exist and declare a
224
+ * usable council. Silently falling back to MAGI would make `persona: "solo"`
225
+ * look authoritative while actually running an unrelated ensemble.
226
+ */
227
+ export function resolveCouncilInvocation(
228
+ personas: readonly Persona[],
229
+ activePersona: Persona | undefined,
230
+ request: CouncilInvocation,
231
+ ): CouncilInvocationResolution {
232
+ const requestedName = request.persona?.trim();
233
+ let sourcePersona: Persona | undefined;
234
+
235
+ if (requestedName) {
236
+ sourcePersona = personas.find((persona) => persona.isPersona && persona.name === requestedName);
237
+ if (!sourcePersona) {
238
+ const available = personas
239
+ .filter((persona) => persona.isPersona && Boolean(persona.council?.strategy))
240
+ .map((persona) => persona.name)
241
+ .sort();
242
+ return {
243
+ ok: false,
244
+ error: `no persona named "${requestedName}". Council personas: ${available.join(", ") || "(none)"}`,
245
+ };
246
+ }
247
+ if (!sourcePersona.council?.strategy) {
248
+ return {
249
+ ok: false,
250
+ error: `persona "${requestedName}" declares no usable council`,
251
+ };
252
+ }
253
+ } else if (activePersona?.council?.strategy) {
254
+ sourcePersona = activePersona;
255
+ }
256
+
257
+ const base = sourcePersona?.council;
258
+ const strategy = request.strategy?.trim() || base?.strategy || "magi";
259
+ const roster =
260
+ request.roster?.trim() ||
261
+ base?.roster ||
262
+ sourcePersona?.orchestration?.roster ||
263
+ (!requestedName ? activePersona?.orchestration?.roster : undefined) ||
264
+ "magi";
265
+ const value: ResolvedCouncilInvocation = {
266
+ strategy,
267
+ roster,
268
+ params: { ...(base?.params ?? {}), ...(request.params ?? {}) },
269
+ };
270
+ if (sourcePersona) value.persona = sourcePersona.name;
271
+ return { ok: true, value };
272
+ }
273
+
274
+ /** Compose the turn's system prompt from the base prompt, the spine, and a persona.
275
+ * The spine is the shared behavioral layer (docs/SPINE.md); absent/empty — the default —
276
+ * leaves the composition byte-identical to the pre-spine one. */
277
+ export function composeSystemPrompt(base: string, persona: Persona, spine?: string): string {
278
+ const layer = persona.spine === false ? "" : (spine ?? "").trim();
279
+ // No layer (the default, and `spine: false`) ⇒ the pre-spine composition, byte for byte.
280
+ // The `replace` test HAS to come first here, exactly as it did pre-spine: a replace persona
281
+ // with an empty body yielded an empty prompt, and quietly promoting that to Pi's base prompt
282
+ // would be a behavior change on the OFF path — which docs/SPINE.md promises there isn't one.
283
+ if (!layer) {
284
+ if (persona.systemPromptMode === "replace") return persona.body;
285
+ if (!persona.body.trim()) return base;
286
+ return `${base}\n\n${persona.body}`;
287
+ }
288
+ // An empty body has nothing to append — and nothing to replace with: the turn keeps Pi's
289
+ // base prompt, lifted by the spine exactly like a persona-less turn.
290
+ if (!persona.body.trim()) return `${base}\n\n${layer}`;
291
+ // `replace` drops Pi's base, so the spine becomes the only scaffolding that persona gets.
292
+ const head = persona.systemPromptMode === "replace" ? layer : `${base}\n\n${layer}`;
293
+ return `${head}\n\n${persona.body}`;
294
+ }