@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,228 @@
1
+ /**
2
+ * pi-persona configuration resolved from environment variables.
3
+ *
4
+ * Pure module — takes an injected env record so it is fully testable. The
5
+ * canonical PI_PERSONA_* names are defined here once.
6
+ */
7
+
8
+ import { isThinkingLevel } from "./types.ts";
9
+
10
+ export interface PiPersonaConfig {
11
+ disabled: boolean;
12
+ extraDirs: string[];
13
+ defaultPersona?: string;
14
+ keybinding: string;
15
+ persist: boolean;
16
+ delegateDefaultAllow: boolean;
17
+ seed: boolean;
18
+ /** Explicit override for the persisted-state file (else a global default). */
19
+ stateFile?: string;
20
+ /** Thinking level forced on delegated children (else the supervisor's level).
21
+ * Spawned children must carry an *explicit* level or they fall into the model's
22
+ * default mode ("adaptive"), which some models reject. */
23
+ childThinking?: string;
24
+ /** Engine backend for sub-agents: "inproc" (run in-process via `createAgentSession`,
25
+ * the default) or "child" (spawn `pi -p`, the baseline). */
26
+ engine?: "child" | "inproc";
27
+ /** Fast stall/message wakeup interval (ms): while async children run, a tick checks progress but
28
+ * stays SILENT unless a leg NEWLY looks stalled or a sub-agent messaged the supervisor — so a
29
+ * healthy run never interrupts. On by default (30000); PI_PERSONA_PEEK_MS=0 opts out (the routine
30
+ * check-in below is independent). */
31
+ peekEveryMs: number;
32
+ /** Routine check-in interval (ms): how often, while async children run, the idle supervisor gets
33
+ * a compact progress digest so it can catch a leg going off-track (not stalled — just wrong) and
34
+ * step in early. Deliberately slow so it is an occasional glance, not a poll, and independent of
35
+ * the fast stall/message wakeup. 300000 (5 min) by default; PI_PERSONA_CHECKIN_MS=0 opts out. */
36
+ checkInEveryMs: number;
37
+ /** Per-agent hard wall-clock cap (ms): an OPT-IN lifetime ceiling. When set (>0) it settles even
38
+ * a busy-but-non-converging child the idle watchdog (reset on every event) never catches.
39
+ * DISABLED by default (0 = no cap / unlimited) so a healthy, progressing child runs to completion
40
+ * instead of being killed mid-work; the idle watchdog + startup deadline + token budget remain the
41
+ * always-on backstops. Set PI_PERSONA_AGENT_MAX_MS=<ms> to arm a hard cap. */
42
+ agentHardTimeoutMs: number;
43
+ /** Per-agent STARTUP deadline (ms): a child that produces no progress of its own (no completed
44
+ * turn / tokens / streamed assistant output) within this window is killed as a stalled start.
45
+ * It exists because the idle watchdog cannot catch a hung start — that watchdog is re-armed by
46
+ * ANY stdout, and a child emits header/noise before it does any work.
47
+ *
48
+ * The window must cover the WHOLE cold start, not just init: spawn, pi init, MCP init, and the
49
+ * first provider response. A queued or rate-limited provider (a 429 carrying a 60s
50
+ * retry-after, then retried), a cold serverless endpoint, or a local model doing a long prompt
51
+ * eval on CPU can all exceed a tight window while perfectly healthy — and this deadline KILLS,
52
+ * where the idle watchdog would have let the leg live. Killing healthy work costs the user
53
+ * more than a dead leg settling slowly, so the default is deliberately generous.
54
+ * 300000 (5 min) by default; PI_PERSONA_AGENT_STARTUP_MS=0 disables it. */
55
+ agentStartupTimeoutMs: number;
56
+ /** Delegation nudge: when a delegating supervisor grinds a RUN of hands-on commands by hand (a
57
+ * by-hand sweep) without a hand-off, append a reminder to the offending tool's result. On by
58
+ * default; PI_PERSONA_NUDGE=off opts out. */
59
+ nudge: boolean;
60
+ /** Cross-process broker (spec B1-B7): on by default so child-engine legs (MCP, worktree,
61
+ * `PI_PERSONA_ENGINE=child`) expose the same steer/contact_supervisor plane in-process
62
+ * runs already have. The host still starts lazily on the first child-engine build.
63
+ * PI_PERSONA_BROKER=off restores pre-broker spawn env (no host, no extra env vars). */
64
+ broker: boolean;
65
+ /** External agent-to-agent plane (exocom): independent top-level instances in one workspace
66
+ * discover + message each other. Opt-in, OFF by default (PI_PERSONA_EXOCOM=1 / --exocom),
67
+ * additionally gated by the persona's canUseBus. */
68
+ exocom: boolean;
69
+ /** How many settled-but-uncollected async runs {@link AsyncRunTracker} retains before
70
+ * FIFO-evicting the oldest completed ones. 25 by default (today's hardcoded bound); set
71
+ * PI_PERSONA_ASYNC_RETAIN=<n> (a finite value >= 1) to raise it under heavy fan-out. A
72
+ * value < 1 (junk, negative, or 0 — not a meaningful retention bound) keeps the default. */
73
+ asyncRetain: number;
74
+ /** Opt-in wider {@link DelegationLedger} veto key: OFF (default) keys purely on
75
+ * `agent+model+task` (today's veto *behavior* — unchanged; the key bytes themselves have
76
+ * moved on, NUL → space → `\x1f`, but record/vet always share the same key() so it
77
+ * cancels out). ON additionally folds in `role`/`tools`/`isolation`, so a genuine retry
78
+ * that only changes those isn't falsely vetoed as "identical" by the 2-strike anti-loop
79
+ * guard. Truthy convention mirrors exocom — see {@link OFF_WORDS}. */
80
+ ledgerV2: boolean;
81
+ /** Source selector for the spine — the shared behavioral layer injected between Pi's base
82
+ * prompt and the persona body (docs/SPINE.md). `""` ⇒ off (the default; composition stays
83
+ * byte-identical to pre-spine), `"on"` ⇒ the user-dir `spine.md` if present else the
84
+ * bundled one, anything else ⇒ that path. Off convention mirrors exocom (see
85
+ * {@link OFF_WORDS}); the value is otherwise kept VERBATIM because it is a path. */
86
+ spine: string;
87
+ /** Source selector for the spine DELEGATED LEGS get — the worker variant, a different prompt
88
+ * doing a different job. Same grammar as {@link PiPersonaConfig.spine}, and it FOLLOWS that
89
+ * value unless PI_PERSONA_SPINE_LEGS is set: one switch still lifts both roles, while the two
90
+ * together express the four measurement arms of docs/SPINE.md (off / supervisor-only /
91
+ * legs-only / both), which one selector cannot. */
92
+ spineLegs: string;
93
+ /** Files-changed report for a non-worktree leg (`engine/change-report.ts`): on by default,
94
+ * matching the existing behavior. Costs up to two `git status` spawns per leg (one at launch,
95
+ * one at settle) with no per-leg way to skip it otherwise — a large monorepo running many
96
+ * parallel legs may want to opt out. Off-word convention mirrors `broker`/`exocom`
97
+ * ({@link OFF_WORDS}); PI_PERSONA_LEG_CHANGE_REPORT=off/0/false disables it. */
98
+ changeReport: boolean;
99
+ }
100
+
101
+ type Env = Record<string, string | undefined>;
102
+
103
+ /** The words that mean "off" for an opt-in switch. `false`/`no` are in here because they are
104
+ * what a user actually types: without them `PI_PERSONA_EXOCOM=false` reads as truthy and
105
+ * `PI_PERSONA_SPINE=false` is reinterpreted as a relative file PATH. Exported (read-only) so
106
+ * the `{@link OFF_WORDS}` references on the switches above resolve for a reader holding only
107
+ * the emitted types — a link into a module-private const documents nothing outside this file. */
108
+ export const OFF_WORDS: ReadonlySet<string> = new Set(["", "off", "0", "false", "no"]);
109
+
110
+ /** The words that mean "on". Symmetric with {@link OFF_WORDS} and needed for the same reason: a
111
+ * switch whose value can also be a PATH cannot treat "not off" as "on", so `1`/`true`/`yes` —
112
+ * which the broker's own docs teach users to type — would otherwise be resolved as relative
113
+ * filenames. */
114
+ export const ON_WORDS: ReadonlySet<string> = new Set(["on", "1", "true", "yes"]);
115
+
116
+ /** One definition of "off", shared by the switches whose value is a plain flag or a path. The
117
+ * older boolean switches (`persist`, `nudge`, `seed`, `disabled`, `delegateDefaultAllow`)
118
+ * predate it and keep their own published conventions — changing those would alter what an
119
+ * existing user's environment means. Unset counts as off. `broker` uses this helper with a
120
+ * default-on pin (`PI_PERSONA_BROKER ?? "on"`). */
121
+ function isOff(value: string | undefined): boolean {
122
+ return OFF_WORDS.has((value ?? "").trim().toLowerCase());
123
+ }
124
+
125
+ /** The spine selectors' shared value grammar: an off word ⇒ `""`, an on word ⇒ `"on"`, anything
126
+ * else is a PATH. Only the SELECTOR words are matched case-insensitively; a path survives
127
+ * verbatim, case included, because filesystems are case-sensitive. */
128
+ function spineSelector(raw: string): string {
129
+ if (isOff(raw)) return "";
130
+ return ON_WORDS.has(raw.trim().toLowerCase()) ? "on" : raw;
131
+ }
132
+
133
+ function splitDirs(value: string | undefined): string[] {
134
+ if (!value) return [];
135
+ return value
136
+ .split(/[;,]/)
137
+ .map((s) => s.trim())
138
+ .filter((s) => s.length > 0);
139
+ }
140
+
141
+ export function resolveConfig(env: Env): PiPersonaConfig {
142
+ const disableFlag = env.PI_PERSONA_DISABLE;
143
+ const def = env.PI_PERSONA_DEFAULT?.trim();
144
+
145
+ const config: PiPersonaConfig = {
146
+ disabled: disableFlag !== undefined && disableFlag.length > 0,
147
+ extraDirs: splitDirs(env.PI_PERSONA_DIRS),
148
+ keybinding: env.PI_PERSONA_KEY?.trim() || "f8",
149
+ persist: env.PI_PERSONA_PERSIST?.trim().toLowerCase() !== "off",
150
+ // "deny" locks the delegate roster down; the shared OFF_WORDS are accepted as the same
151
+ // request (additive — "off" previously meant default-ALLOW, a security default a user
152
+ // could believe they had tightened while changing nothing).
153
+ delegateDefaultAllow: (() => {
154
+ const v = env.PI_PERSONA_DELEGATE_DEFAULT?.trim().toLowerCase();
155
+ if (v === undefined || v === "") return true; // unset stays default-allow
156
+ return v !== "deny" && !OFF_WORDS.has(v);
157
+ })(),
158
+ // Opt-in: auto-install the bundled defaults on first run ONLY when explicitly enabled with
159
+ // `PI_PERSONA_SEED=on`. Default off — personas are installed via `/persona seed|restore`.
160
+ seed: env.PI_PERSONA_SEED?.trim().toLowerCase() === "on",
161
+ peekEveryMs: 30_000,
162
+ checkInEveryMs: 300_000,
163
+ // Opt-in: 0 = no hard cap (a healthy child runs to completion). The idle watchdog
164
+ // (RUN_LIMITS.timeoutMs, reset on progress) + startup deadline + token budget are the
165
+ // always-on backstops; set PI_PERSONA_AGENT_MAX_MS=<ms> to arm a wall-clock ceiling.
166
+ agentHardTimeoutMs: 0,
167
+ agentStartupTimeoutMs: 300_000,
168
+ // On unless explicitly turned off (mirrors PI_PERSONA_PERSIST's `!== "off"` convention).
169
+ nudge: env.PI_PERSONA_NUDGE?.trim().toLowerCase() !== "off",
170
+ // On unless explicitly turned off, so an MCP/worktree/child-engine async leg is
171
+ // steerable without a hidden env pin. Off-words restore pre-broker spawn env.
172
+ broker: !isOff(env.PI_PERSONA_BROKER ?? "on"),
173
+ exocom: false,
174
+ asyncRetain: 25,
175
+ ledgerV2: false,
176
+ spine: "",
177
+ spineLegs: "",
178
+ changeReport: !isOff(env.PI_PERSONA_LEG_CHANGE_REPORT ?? "on"),
179
+ };
180
+ // A valid finite value >= 0 sets the interval (0 opts out); junk/negative keeps the default.
181
+ const peekRaw = env.PI_PERSONA_PEEK_MS?.trim();
182
+ if (peekRaw !== undefined && peekRaw !== "") {
183
+ const peek = Number(peekRaw);
184
+ if (Number.isFinite(peek) && peek >= 0) config.peekEveryMs = peek;
185
+ }
186
+ const checkInRaw = env.PI_PERSONA_CHECKIN_MS?.trim();
187
+ if (checkInRaw !== undefined && checkInRaw !== "") {
188
+ const checkIn = Number(checkInRaw);
189
+ if (Number.isFinite(checkIn) && checkIn >= 0) config.checkInEveryMs = checkIn;
190
+ }
191
+ const hardRaw = env.PI_PERSONA_AGENT_MAX_MS?.trim();
192
+ if (hardRaw !== undefined && hardRaw !== "") {
193
+ const hard = Number(hardRaw);
194
+ if (Number.isFinite(hard) && hard >= 0) config.agentHardTimeoutMs = hard;
195
+ }
196
+ const startupRaw = env.PI_PERSONA_AGENT_STARTUP_MS?.trim();
197
+ if (startupRaw !== undefined && startupRaw !== "") {
198
+ const startup = Number(startupRaw);
199
+ if (Number.isFinite(startup) && startup >= 0) config.agentStartupTimeoutMs = startup;
200
+ }
201
+ // A finite value >= 1 sets the retention bound; 0 isn't meaningful here (unlike the interval
202
+ // knobs above, where 0 opts out) so it — like junk/negative — falls back to the default 25.
203
+ const asyncRetainRaw = env.PI_PERSONA_ASYNC_RETAIN?.trim();
204
+ if (asyncRetainRaw !== undefined && asyncRetainRaw !== "") {
205
+ const retain = Number(asyncRetainRaw);
206
+ if (Number.isFinite(retain) && retain >= 1) config.asyncRetain = retain;
207
+ }
208
+ if (def) config.defaultPersona = def;
209
+ const stateFile = env.PI_PERSONA_STATE_FILE?.trim();
210
+ if (stateFile) config.stateFile = stateFile;
211
+ const childThinking = env.PI_PERSONA_CHILD_THINKING?.trim().toLowerCase();
212
+ if (childThinking && isThinkingLevel(childThinking)) config.childThinking = childThinking;
213
+ // In-process is the default backend; opt back to the spawn-based child engine with
214
+ // PI_PERSONA_ENGINE=child.
215
+ const engine = env.PI_PERSONA_ENGINE?.trim().toLowerCase();
216
+ config.engine = engine === "child" ? "child" : "inproc";
217
+ config.exocom = !isOff(env.PI_PERSONA_EXOCOM);
218
+ config.ledgerV2 = !isOff(env.PI_PERSONA_LEDGER_V2);
219
+ config.spine = spineSelector(env.PI_PERSONA_SPINE?.trim() ?? "");
220
+ // The legs' selector FOLLOWS the supervisor's unless it is set, so one switch still lifts both
221
+ // roles while the pair expresses all four measurement arms (docs/SPINE.md). Present-but-empty
222
+ // counts as UNSET here rather than as the off word it is elsewhere: `PI_PERSONA_SPINE_LEGS=` is
223
+ // how a shell clears a variable, and an inherited empty splitting the legs off from the
224
+ // supervisor would be a silent, invisible arm nobody chose.
225
+ const legsRaw = env.PI_PERSONA_SPINE_LEGS?.trim();
226
+ config.spineLegs = legsRaw ? spineSelector(legsRaw) : config.spine;
227
+ return config;
228
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Structured return contracts — the substrate that makes voting/judging
3
+ * mechanical (reducers read fields generically) and that is PINNED per run.
4
+ *
5
+ * Pure module (Node stdlib only, no Pi imports). A contract is a small typed
6
+ * schema; `pinContract` freezes a `name@hash` for a run so hot-reloading a
7
+ * contract file never changes an in-flight run (guardrails I3).
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+
12
+ export type FieldType = "string" | "number" | "boolean" | "enum";
13
+
14
+ export interface FieldSpec {
15
+ type: FieldType;
16
+ required?: boolean;
17
+ /** Allowed values for `type: "enum"`. */
18
+ values?: string[];
19
+ /** Inclusive bounds for `type: "number"`. */
20
+ min?: number;
21
+ max?: number;
22
+ /** Minimum string length for `type: "string"` — a non-empty / substantive gate (e.g. a proof
23
+ * line that must carry a real command + live output, not an empty placeholder). */
24
+ minLength?: number;
25
+ }
26
+
27
+ export interface ContractDef {
28
+ name: string;
29
+ fields: Record<string, FieldSpec>;
30
+ }
31
+
32
+ export interface PinnedContract {
33
+ name: string;
34
+ hash: string;
35
+ def: ContractDef;
36
+ }
37
+
38
+ export interface ValidationResult {
39
+ ok: boolean;
40
+ errors: string[];
41
+ /** The validated object (present only when `ok`). */
42
+ value?: Record<string, unknown>;
43
+ }
44
+
45
+ /** The built-in default contract: pi-fork's four sections + voting substrate. */
46
+ export const DEFAULT_CONTRACT: ContractDef = {
47
+ name: "default",
48
+ fields: {
49
+ result: { type: "string", required: true },
50
+ output: { type: "string" },
51
+ evidence: { type: "string" },
52
+ learnings: { type: "string" },
53
+ vote: { type: "string" },
54
+ confidence: { type: "number", min: 0, max: 1 },
55
+ stance: { type: "enum", values: ["approve", "reject", "revise"] },
56
+ dissent: { type: "string" },
57
+ },
58
+ };
59
+
60
+ const FIELD_TYPES: readonly FieldType[] = ["string", "number", "boolean", "enum"];
61
+
62
+ export type ContractParse = { ok: true; def: ContractDef } | { ok: false; error: string };
63
+
64
+ /** Parse a `contracts/<name>.contract.json` file into a validated {@link ContractDef}.
65
+ * Data-driven authoring surface (spec §4.6): drop a JSON file, reference it by name via a
66
+ * persona/strategy `outputContract`. Reducers then read its fields generically. */
67
+ export function parseContract(content: string): ContractParse {
68
+ let raw: unknown;
69
+ try {
70
+ raw = JSON.parse(content);
71
+ } catch {
72
+ return { ok: false, error: "contract is not valid JSON" };
73
+ }
74
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ok: false, error: "contract must be a JSON object" };
75
+ const o = raw as Record<string, unknown>;
76
+ if (typeof o.name !== "string" || !o.name.trim()) return { ok: false, error: "contract.name is required" };
77
+ if (typeof o.fields !== "object" || o.fields === null || Array.isArray(o.fields)) return { ok: false, error: "contract.fields must be an object" };
78
+
79
+ const fields: Record<string, FieldSpec> = {};
80
+ for (const [fname, rawSpec] of Object.entries(o.fields as Record<string, unknown>)) {
81
+ if (typeof rawSpec !== "object" || rawSpec === null || Array.isArray(rawSpec)) return { ok: false, error: `field "${fname}" must be an object` };
82
+ const s = rawSpec as Record<string, unknown>;
83
+ if (typeof s.type !== "string" || !(FIELD_TYPES as readonly string[]).includes(s.type)) {
84
+ return { ok: false, error: `field "${fname}" has an invalid type (string|number|boolean|enum)` };
85
+ }
86
+ const spec: FieldSpec = { type: s.type as FieldType };
87
+ if (s.required === true) spec.required = true;
88
+ if (spec.type === "enum") {
89
+ if (!Array.isArray(s.values) || !s.values.every((v): v is string => typeof v === "string") || s.values.length === 0) {
90
+ return { ok: false, error: `enum field "${fname}" needs a non-empty string "values" list` };
91
+ }
92
+ spec.values = s.values;
93
+ }
94
+ if (typeof s.min === "number") spec.min = s.min;
95
+ if (typeof s.max === "number") spec.max = s.max;
96
+ if (typeof s.minLength === "number") spec.minLength = s.minLength;
97
+ fields[fname] = spec;
98
+ }
99
+ return { ok: true, def: { name: o.name, fields } };
100
+ }
101
+
102
+ /** Does this text parse as JSON? The only honest test of a candidate. */
103
+ function parses(candidate: string): boolean {
104
+ if (candidate.length === 0) return false;
105
+ try {
106
+ JSON.parse(candidate);
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Every fenced code block in the text, last one first. The contract asks for the JSON at
115
+ * the END ("prose before it is fine, nothing after it"), so the last fence is the answer
116
+ * and any earlier fence is quoted evidence in the report.
117
+ */
118
+ function fencedBlocks(s: string): string[] {
119
+ const out: string[] = [];
120
+ const re = /```[^\n`]*\n([\s\S]*?)```/g;
121
+ let m = re.exec(s);
122
+ while (m !== null) {
123
+ if (m[1] !== undefined) out.push(m[1].trim());
124
+ m = re.exec(s);
125
+ }
126
+ return out.reverse();
127
+ }
128
+
129
+ /**
130
+ * Scan backwards from one closer for a BALANCED object/array: walk from `close` to its
131
+ * matching opener, tracking string state so a brace inside a string value never miscounts.
132
+ * This is what survives prose that itself contains braces (a report quoting code or a
133
+ * `${…}`), where a naive first-`{` scan slices from the prose and yields garbage.
134
+ */
135
+ function balancedFrom(s: string, close: number): string | undefined {
136
+ const open = s[close] === "}" ? "{" : "[";
137
+ const shut = s[close];
138
+ let depth = 0;
139
+ let inString = false;
140
+ let escaped = false;
141
+ // Walk backwards. `escaped` needs a forward look: a quote is literal when preceded by an
142
+ // ODD number of backslashes, so count them rather than tracking a single flag.
143
+ for (let i = close; i >= 0; i--) {
144
+ const ch = s[i];
145
+ if (ch === '"') {
146
+ let backslashes = 0;
147
+ for (let j = i - 1; j >= 0 && s[j] === "\\"; j--) backslashes++;
148
+ escaped = backslashes % 2 === 1;
149
+ if (!escaped) inString = !inString;
150
+ continue;
151
+ }
152
+ if (inString) continue;
153
+ if (ch === shut) depth++;
154
+ else if (ch === open) {
155
+ depth--;
156
+ if (depth === 0) return s.slice(i, close + 1);
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ /** Candidates are cheap; the cap only bounds pathological prose dense with closers. */
163
+ const MAX_JSON_CANDIDATES = 64;
164
+
165
+ /**
166
+ * Every balanced object/array in the text, walking closers RIGHT-TO-LEFT (the contract
167
+ * asks for the answer at the end). One candidate per closer is not enough: trailing prose
168
+ * like "option [2]" or "Done {thanks}." yields a slice that parses (or mis-slices into
169
+ * prose) and must not shadow the real answer object sitting behind it.
170
+ */
171
+ function* balancedFromEnd(s: string): Generator<string> {
172
+ const seen = new Set<string>();
173
+ let yielded = 0;
174
+ for (let close = s.length - 1; close >= 0 && yielded < MAX_JSON_CANDIDATES; close--) {
175
+ const ch = s[close];
176
+ if (ch !== "}" && ch !== "]") continue;
177
+ const slice = balancedFrom(s, close);
178
+ if (slice !== undefined && !seen.has(slice)) {
179
+ seen.add(slice);
180
+ yielded++;
181
+ yield slice;
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * All JSON candidates of a reply, most-specific first, deduped: the whole text, each
188
+ * fenced block last-first (plus the balanced objects inside a fence that holds prose
189
+ * around the JSON), then every balanced object/array of the whole text right-to-left,
190
+ * and finally the legacy outermost slice (error shaping only — it may not parse).
191
+ */
192
+ function* jsonCandidates(text: string): Generator<string> {
193
+ const seen = new Set<string>();
194
+ const emit = function* (c: string | undefined): Generator<string> {
195
+ if (c !== undefined && c.length > 0 && !seen.has(c)) {
196
+ seen.add(c);
197
+ yield c;
198
+ }
199
+ };
200
+ const s = text.trim();
201
+ yield* emit(s);
202
+ for (const block of fencedBlocks(s)) {
203
+ yield* emit(block);
204
+ yield* balancedFromEnd(block);
205
+ }
206
+ yield* balancedFromEnd(s);
207
+ // Legacy fallback: outermost slice. If nothing parsed, this only shapes the error.
208
+ const starts = [s.indexOf("{"), s.indexOf("[")].filter((i) => i >= 0);
209
+ if (starts.length > 0) {
210
+ const start = Math.min(...starts);
211
+ const end = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
212
+ if (end > start) yield* emit(s.slice(start, end + 1));
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Pull a JSON candidate out of an LLM reply before parsing. Models very often wrap
218
+ * structured output in a ```json fence or surround it with prose ("Here you go: {…}"),
219
+ * which makes a raw `JSON.parse` throw and silently fails the whole contract (the magi
220
+ * bug: every member's vote excluded as "invalid output").
221
+ *
222
+ * Candidates are tried most-specific first and the FIRST ONE THAT ACTUALLY PARSES wins,
223
+ * so a heuristic can never win by producing confident garbage — with one refinement:
224
+ * contracts always want a JSON OBJECT, so among parseable candidates an object beats an
225
+ * array/scalar (trailing prose like "option [2]" parses as JSON but can never be the
226
+ * answer; the real object behind it is).
227
+ * 1. the whole text (already-clean JSON);
228
+ * 2. each fenced block, last fence first (the contract puts the answer last, so an
229
+ * earlier fence is quoted evidence);
230
+ * 3. every balanced object/array scanned backwards, string-aware, right-to-left.
231
+ * Falls back to the legacy outermost-slice so genuinely-malformed output still fails
232
+ * parsing naturally with the same shape of error as before.
233
+ *
234
+ * Regressions this closes: a report whose PROSE contains a brace (quoted code, a `${…}`)
235
+ * followed by the fenced answer; and a correct answer followed by prose carrying its own
236
+ * bracketed token ("I picked option [2]"), which used to shadow the real object and fail
237
+ * a leg whose work was correct ("contract <name> failed: output was not valid JSON").
238
+ */
239
+ export function extractJsonCandidate(text: string): string {
240
+ let firstParseable: string | undefined;
241
+ for (const candidate of jsonCandidates(text)) {
242
+ if (!parses(candidate)) continue;
243
+ if (candidate.startsWith("{")) return candidate;
244
+ firstParseable ??= candidate;
245
+ }
246
+ if (firstParseable !== undefined) return firstParseable;
247
+ // Nothing parsed: return the last (legacy outermost-slice) candidate if it exists,
248
+ // else the trimmed text — both only shape the downstream parse error.
249
+ let last: string | undefined;
250
+ for (const candidate of jsonCandidates(text)) last = candidate;
251
+ return last ?? text.trim();
252
+ }
253
+
254
+ /** One human-readable ballot line for a field: `- vote (string, required, one of: a | b)`. */
255
+ function fieldLine(name: string, spec: FieldSpec): string {
256
+ const parts: string[] = [spec.type];
257
+ if (spec.required) parts.push("required");
258
+ if (spec.type === "enum" && spec.values) parts.push(`one of: ${spec.values.join(" | ")}`);
259
+ if (spec.min !== undefined && spec.max !== undefined) parts.push(`${spec.min}..${spec.max}`);
260
+ else if (spec.min !== undefined) parts.push(`>= ${spec.min}`);
261
+ else if (spec.max !== undefined) parts.push(`<= ${spec.max}`);
262
+ if (spec.minLength !== undefined) parts.push(`min ${spec.minLength} chars`);
263
+ return `- ${name} (${parts.join(", ")})`;
264
+ }
265
+
266
+ /**
267
+ * The task preamble that tells a member HOW to satisfy `outputContract` — derived
268
+ * mechanically from the (pinned) def and appended by BOTH engine backends whenever a
269
+ * spec carries a contract. Without this, only agents whose own .md hand-writes the JSON
270
+ * format (e.g. the MAGI cores) could be convened by a voting strategy; a generic
271
+ * executor would answer in prose and be quarantined as an invalid output. Live-drive
272
+ * verified: debate over two bare `operator`s fails exactly that way without it.
273
+ */
274
+ export function contractInstructions(def: ContractDef): string {
275
+ const lines = [
276
+ `--- output contract (${def.name}) ---`,
277
+ "End your FINAL answer with a single JSON object (prose before it is fine, nothing after it) with these fields:",
278
+ ...Object.entries(def.fields).map(([n, s]) => fieldLine(n, s)),
279
+ "The object is parsed mechanically. Include every required field; omit fields you have nothing for.",
280
+ ];
281
+ if (def.fields.vote) {
282
+ // Vote hygiene (the MAGI cores hand-write this in their .md; a generic agent needs it
283
+ // here): qualifiers make equivalent votes tally apart ("esm" vs "esm (but only if…)").
284
+ lines.push(
285
+ 'For "vote": the BARE option token only — for "X or Y?" vote "x" or "y", no qualifiers or conditions (put nuance in result/output) so equivalent votes tally together.',
286
+ );
287
+ }
288
+ return lines.join("\n");
289
+ }
290
+
291
+ export interface ParseResult {
292
+ ok: boolean;
293
+ /** The validated object (present only when `ok`). */
294
+ value?: Record<string, unknown>;
295
+ error?: string;
296
+ }
297
+
298
+ /** Unwrap (fences/prose), JSON-parse, and validate an agent's raw output against a
299
+ * contract in one step — the shared path for every engine backend. Every candidate is
300
+ * tried until one BOTH parses and validates: a candidate that parses but is not the
301
+ * answer object (trailing "option [2]", quoted code) must not quarantine a correct,
302
+ * already-billed leg as an invalid output. */
303
+ export function parseAndValidate(output: string, def: ContractDef): ParseResult {
304
+ let firstErrors: string[] | undefined;
305
+ for (const candidate of jsonCandidates(output)) {
306
+ let parsed: unknown;
307
+ try {
308
+ parsed = JSON.parse(candidate);
309
+ } catch {
310
+ continue;
311
+ }
312
+ const v = validateAgainst(def, parsed);
313
+ if (v.ok && v.value) return { ok: true, value: v.value };
314
+ firstErrors ??= v.errors;
315
+ }
316
+ const errors = firstErrors ?? ["output was not valid JSON"];
317
+ return { ok: false, error: `contract ${def.name} failed: ${errors.join("; ")}` };
318
+ }
319
+
320
+ /** Deterministic, key-order-independent serialisation for hashing. */
321
+ function stableStringify(v: unknown): string {
322
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
323
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
324
+ const obj = v as Record<string, unknown>;
325
+ const keys = Object.keys(obj).sort();
326
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
327
+ }
328
+
329
+ /** A stable sha256 over any JSON-ish value (key-order-independent) — the pinning hash. */
330
+ export function stableHash(value: unknown): string {
331
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
332
+ }
333
+
334
+ /** Freeze a contract for a run: `name` + a stable sha256 over its definition. */
335
+ export function pinContract(def: ContractDef): PinnedContract {
336
+ return { name: def.name, hash: stableHash(def), def };
337
+ }
338
+
339
+ /** Every contract name a spec may reference: the built-in `default` plus the installed files. */
340
+ export function installedContractNames(defs: Record<string, ContractDef>): string[] {
341
+ return [...new Set(["default", ...Object.keys(defs)])];
342
+ }
343
+
344
+ /** Validate a structured value against a contract. Unknown extra fields are kept. */
345
+ export function validateAgainst(def: ContractDef, value: unknown): ValidationResult {
346
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
347
+ return { ok: false, errors: ["expected a JSON object"] };
348
+ }
349
+ const obj = value as Record<string, unknown>;
350
+ const errors: string[] = [];
351
+
352
+ for (const [name, spec] of Object.entries(def.fields)) {
353
+ const fv = obj[name];
354
+ const present = fv !== undefined && fv !== null;
355
+ if (!present) {
356
+ if (spec.required) errors.push(`missing required field: ${name}`);
357
+ continue;
358
+ }
359
+ switch (spec.type) {
360
+ case "string":
361
+ if (typeof fv !== "string") {
362
+ errors.push(`field ${name} must be a string`);
363
+ break;
364
+ }
365
+ if (spec.minLength !== undefined && fv.length < spec.minLength) {
366
+ errors.push(`field ${name} must be at least ${spec.minLength} characters`);
367
+ }
368
+ break;
369
+ case "number":
370
+ if (typeof fv !== "number" || Number.isNaN(fv)) {
371
+ errors.push(`field ${name} must be a number`);
372
+ break;
373
+ }
374
+ if (spec.min !== undefined && fv < spec.min) errors.push(`field ${name} must be >= ${spec.min}`);
375
+ if (spec.max !== undefined && fv > spec.max) errors.push(`field ${name} must be <= ${spec.max}`);
376
+ break;
377
+ case "boolean":
378
+ if (typeof fv !== "boolean") errors.push(`field ${name} must be a boolean`);
379
+ break;
380
+ case "enum": {
381
+ const values = spec.values ?? [];
382
+ if (typeof fv !== "string" || !values.includes(fv)) {
383
+ errors.push(`field ${name} must be one of: ${values.join(", ")}`);
384
+ }
385
+ break;
386
+ }
387
+ }
388
+ }
389
+
390
+ return errors.length === 0 ? { ok: true, errors, value: obj } : { ok: false, errors };
391
+ }