@aeondave/pi-persona 1.17.1 → 1.17.4

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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -15,11 +15,14 @@ import type { AgentResult, FailureKind } from "./types.ts";
15
15
 
16
16
  export type PhaseStatus = "running" | "done" | "failed";
17
17
 
18
- /** One phase ready to run: its spec, the built task, and its upstream phases' results. */
18
+ /** One phase ready to run: its spec, the built task, its upstream phases' results, and — in
19
+ * reference mode — the DIRECT upstream refs granted to this phase's legs (nothing transitive). */
19
20
  export interface FlowPhaseRun {
20
21
  phase: FlowPhase;
21
22
  task: string;
22
23
  upstream: Record<string, AgentResult>;
24
+ /** Empty in inline mode; the phase's direct needs' archived refs in reference mode. */
25
+ resultRefs: readonly string[];
23
26
  }
24
27
 
25
28
  export type RunPhaseFn = (run: FlowPhaseRun) => Promise<AgentResult>;
@@ -32,17 +35,34 @@ export interface FlowJournalEntry {
32
35
  output: string;
33
36
  /** A gated phase whose checkpoint was approved — so a resume doesn't re-prompt. */
34
37
  gateApproved?: boolean;
38
+ /** Reference handoff: the archived `result:<UUID>` this phase's output lives under — the
39
+ * body itself is NOT journaled in that mode (resume reads it back via the archive). */
40
+ resultRef?: string;
41
+ /** The archive-record schema version the `resultRef` line was written against. */
42
+ resultVersion?: 1;
35
43
  }
36
44
 
37
45
  /** A resume map value: a phase result plus whether its checkpoint gate was already approved. */
38
- export type ResumedResult = AgentResult & { gateApproved?: boolean };
46
+ export type ResumedResult = AgentResult & {
47
+ gateApproved?: boolean;
48
+ resultVersion?: 1;
49
+ /** Reference handoff: the archived ref this phase was journaled under (from the journal). */
50
+ resultRef?: string;
51
+ /** The archived body for a phase's ref could not be read back (resume, or a live capture
52
+ * that produced no ref) — dependents block on this need; nothing re-runs or falls back. */
53
+ refUnavailable?: string;
54
+ };
39
55
 
40
56
  export interface FlowRunDeps {
41
57
  /** Run one phase's strategy → its result (wraps `runPersonaStrategy` in prod). */
42
58
  runPhase: RunPhaseFn;
43
- /** The pinned `flow@hash` for this run (I3) — recorded in every journal line. */
59
+ /** The pinned run identity (I3: `flowRunHash` = flow + task + cwd) — recorded in every journal line. */
44
60
  hash: string;
45
61
  signal?: AbortSignal;
62
+ /** Reference handoff: read a journaled ref's stored body back from the terminal archive —
63
+ * how a RESUME rebuilds a done phase's output without re-running it. Required for a
64
+ * reference-mode resume that has refs to restore; absent ⇒ every ref reads unavailable. */
65
+ readArchivedResult?: (ref: string) => { ok: true; body: string; status: "done" | "failed" | "stopped"; truncated?: boolean } | { ok: false; error: string };
46
66
  /** Grace an aborted wave gets to unwind cooperatively before its still-running phases are
47
67
  * force-settled (defaults to {@link ABORT_GRACE_MS}); shrinkable in tests. */
48
68
  abortGraceMs?: number;
@@ -71,6 +91,17 @@ export interface FlowOutcome {
71
91
  /** The run signal fired before every phase settled ok. A cancelled flow is NOT the same as one
72
92
  * that merely finished without an answer, and callers report it differently. */
73
93
  cancelled?: boolean;
94
+ /** The first checkpoint (in spec order) that was not approved, when that is the ONLY reason the
95
+ * flow did not finish: a stop the operator chose, not a failure. `ok` stays false and `error`
96
+ * stays "blocked: …" (a mandatory flow must keep failing closed); `output` is the gated
97
+ * phase's own answer plus a stop note. */
98
+ stoppedAtGate?: string;
99
+ }
100
+
101
+ /** What a finished flow IS to a reader. A checkpoint the operator declined is a STOP, their choice, never a
102
+ * failure: the one predicate the tool result, the tree row and the cards share. */
103
+ export function flowDisposition(outcome: { ok: boolean; stoppedAtGate?: string | undefined }): "done" | "stopped" | "failed" {
104
+ return outcome.ok ? "done" : outcome.stoppedAtGate !== undefined ? "stopped" : "failed";
74
105
  }
75
106
 
76
107
  /** How long an aborted wave is given to unwind COOPERATIVELY before its still-running phases are
@@ -112,37 +143,94 @@ function within(p: Promise<unknown>, ms: number): Promise<void> {
112
143
  });
113
144
  }
114
145
 
115
- /** Build a phase's task: the base task plus each upstream phase's output as context. */
146
+ /** Reference handoff: how much of an upstream body is embedded as a PREVIEW (contract 2).
147
+ * The full report stays in the archive; the dependent pages it with `read_result`. */
148
+ export const RESULT_PREVIEW_CHARS = 1200;
149
+
150
+ /** Build a phase's task. Inline (the default, first-class): the base task plus each upstream
151
+ * phase's FULL fenced output. Reference: per upstream phase ONLY a bounded fenced preview +
152
+ * the archived ref + its status + how to read more — never the full body. */
116
153
  function buildPhaseTask(baseTask: string, spec: FlowSpec, phase: FlowPhase, upstream: Record<string, AgentResult>): string {
117
154
  const parts = [`Flow "${spec.name}" — phase "${phase.id}" (strategy: ${phase.strategy}).`, baseTask];
118
155
  const ups = Object.entries(upstream);
119
- if (ups.length > 0) {
120
- parts.push("\nUpstream results to build on:");
121
- for (const [id, r] of ups) parts.push(`\n--- ${id} ---\n${fenceUntrusted(r.output)}`);
156
+ if (ups.length === 0) return parts.join("\n");
157
+ if (spec.handoff === "reference") {
158
+ parts.push("\nUpstream results (archived references — read more with the read_result tool):");
159
+ for (const [id, r] of ups) {
160
+ const ref = r.resultReceipt?.ref;
161
+ parts.push(`\n--- ${id} ---`);
162
+ parts.push(`status: ${r.ok ? "done" : "failed"}`);
163
+ if (ref === undefined) {
164
+ parts.push(`result reference unavailable: ${r.output.slice(0, 200) || "(no output)"}`);
165
+ continue;
166
+ }
167
+ parts.push(`result ref: ${ref}`);
168
+ if (r.resultReceipt?.truncated) parts.push("Archive retention truncated the original report; the stored body is partial even after reading every page.");
169
+ parts.push(`preview (first ${RESULT_PREVIEW_CHARS} of ${r.output.length} chars):\n${fenceUntrusted(r.output.slice(0, RESULT_PREVIEW_CHARS))}${r.output.length > RESULT_PREVIEW_CHARS ? "\n(preview truncated — use read_result for the rest)" : ""}`);
170
+ parts.push(`Read the full report with the read_result tool, e.g. {"ref":"${ref}","offset":0,"limit":8000}; follow each reply's nextOffset until endOfStoredBody.`);
171
+ }
172
+ return parts.join("\n");
122
173
  }
174
+ parts.push("\nUpstream results to build on:");
175
+ for (const [id, r] of ups) parts.push(`\n--- ${id} ---\n${fenceUntrusted(r.output)}`);
123
176
  return parts.join("\n");
124
177
  }
125
178
 
126
179
  export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDeps): Promise<FlowOutcome> {
127
180
  const byId = new Map(spec.phases.map((p) => [p.id, p]));
181
+ const reference = spec.handoff === "reference";
182
+ /** One journal write, mode-aware: reference lines carry the ref (+version marker) and never
183
+ * the body; inline lines stay byte-identical to the historical format. */
184
+ const journalSettled = (id: string, r: AgentResult, gateApproved = false): void => {
185
+ if (!deps.journal) return;
186
+ const ref = r.resultReceipt?.ref;
187
+ deps.journal(reference
188
+ ? { phase: id, hash: deps.hash, ok: r.ok, output: "", ...(r.ok && ref !== undefined ? { resultRef: ref, resultVersion: 1 as const } : {}), ...(gateApproved ? { gateApproved: true } : {}) }
189
+ : { phase: id, hash: deps.hash, ok: r.ok, output: r.output, ...(gateApproved ? { gateApproved: true } : {}) });
190
+ };
191
+ /** Reference resume: rebuild a done phase's output from the terminal archive. A ref that
192
+ * cannot be read back marks the phase `refUnavailable` — its dependents BLOCK (no respawn,
193
+ * no inline fallback, no re-run of the done phase); the error says why. */
194
+ const restoreResumed = (r: ResumedResult): ResumedResult => {
195
+ if (r.refUnavailable) return { ...r, output: "" };
196
+ const ref = r.resultRef;
197
+ if (ref === undefined) {
198
+ return { ...r, output: "", refUnavailable: "the phase was journaled without an archived result reference" };
199
+ }
200
+ if (r.resultVersion !== 1) return { ...r, output: "", refUnavailable: "unsupported or missing archived result journal version" };
201
+ const read = deps.readArchivedResult?.(ref);
202
+ if (!read) return { ...r, output: "", refUnavailable: `archived result ${ref} is unreadable (no archive reader is wired for this run)` };
203
+ if (!read.ok) return { ...r, output: "", refUnavailable: `archived result ${ref} is unreadable (${read.error})` };
204
+ if (read.status !== "done") return { ...r, output: "", refUnavailable: `archived result ${ref} has status ${read.status}; expected done` };
205
+ return { ...r, output: read.body, resultReceipt: { ...(r.resultReceipt ?? {}), ref, ...(read.truncated === undefined ? {} : { truncated: read.truncated }) } };
206
+ };
128
207
  // A gated phase only satisfies its dependents once its checkpoint is "passed" (or it blocks
129
208
  // them once "rejected"). Resume pre-seeds gates the journal already recorded as approved.
130
209
  const gateState = new Map<string, "passed" | "rejected">();
131
- const done = new Map<string, AgentResult>();
132
- for (const [id, r] of Object.entries(deps.resume ?? {})) {
210
+ const done = new Map<string, ResumedResult>();
211
+ for (const [id, r0] of Object.entries(deps.resume ?? {})) {
133
212
  const gated = byId.get(id)?.gate === true;
134
213
  // A gated phase resumed as ok but WITHOUT a recorded approval (the process died between
135
214
  // journaling its completion and its gate decision) must RE-RUN, not be treated as done —
136
215
  // otherwise it sits in `done` with an unresolved gate and its dependents can never run.
137
- if (gated && r.ok && !(r as ResumedResult).gateApproved) continue;
216
+ let r: ResumedResult = r0;
217
+ if (reference && r0.ok) {
218
+ const restored = restoreResumed(r0);
219
+ r = restored.resultReceipt?.ref !== undefined ? restored : { ...restored, refUnavailable: restored.refUnavailable ?? "the phase has no archived result reference" };
220
+ }
221
+ // An invalid reference cannot turn this historical checkpoint into newly paid work.
222
+ if (gated && r.ok && !r.gateApproved && !r.refUnavailable) continue;
138
223
  done.set(id, r);
139
- if (gated && (r as ResumedResult).gateApproved) gateState.set(id, "passed");
224
+ if (gated && r0.gateApproved) gateState.set(id, "passed");
140
225
  }
141
226
  // A FAILED gated phase has no checkpoint to resolve — its gate must not keep dependents
142
227
  // from being considered (needFailed then records them "blocked", exactly like a failed
143
228
  // non-gated need; otherwise they'd never become ready and silently vanish, stuck ⏳).
144
229
  const gateResolved = (id: string): boolean => !byId.get(id)?.gate || gateState.has(id) || done.get(id)?.ok === false;
145
230
  const needFailed = (n: string): boolean => (done.has(n) && !done.get(n)?.ok) || gateState.get(n) === "rejected";
231
+ /** An OK need whose archived reference cannot serve dependents (unreadable at resume, or a
232
+ * live capture that produced no ref). The need stays DONE — only its dependents block. */
233
+ const needUnavailable = (n: string): boolean => (done.get(n)?.refUnavailable ?? "") !== "";
146
234
  let remaining = spec.phases.filter((p) => !done.has(p.id));
147
235
  let cancelled = false;
148
236
  // Keep blocked dependents distinct from phases that actually ran and failed. The flow's
@@ -159,12 +247,18 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
159
247
  const ready = remaining.filter((p) => (p.needs ?? []).every((n) => done.has(n) && gateResolved(n)));
160
248
  if (ready.length === 0) break; // acyclic + progress guarantees this can't happen
161
249
 
162
- // A phase whose any need FAILED or whose gate was REJECTED is blocked — record, don't run.
163
- const blocked = ready.filter((p) => (p.needs ?? []).some((n) => needFailed(n)));
250
+ // A phase whose any need FAILED, whose gate was REJECTED, or (reference mode) whose need's
251
+ // archived ref cannot be read — record, don't run.
252
+ const blocked = ready.filter((p) => (p.needs ?? []).some((n) => needFailed(n) || needUnavailable(n)));
164
253
  for (const p of blocked) {
165
254
  blockedPhaseIds.add(p.id);
166
255
  const gateReject = (p.needs ?? []).some((n) => gateState.get(n) === "rejected");
167
- const error = gateReject ? "blocked: a checkpoint gate was not approved" : "blocked: an upstream phase failed";
256
+ const unavailableNeed = (p.needs ?? []).find((n) => needUnavailable(n));
257
+ const error = gateReject
258
+ ? "blocked: a checkpoint gate was not approved"
259
+ : unavailableNeed !== undefined
260
+ ? `blocked: upstream phase "${unavailableNeed}" result reference is unavailable (${done.get(unavailableNeed)?.refUnavailable})`
261
+ : "blocked: an upstream phase failed";
168
262
  const r: AgentResult = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
169
263
  done.set(p.id, r);
170
264
  deps.onPhase?.(p.id, "failed", r);
@@ -185,28 +279,38 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
185
279
  const u = done.get(n);
186
280
  if (u) upstream[n] = u;
187
281
  }
282
+ // Reference mode: the phase's legs are granted EXACTLY its direct needs' refs —
283
+ // never a transitive ancestor's (the reader scope mirrors the task's previews).
284
+ const resultRefs: readonly string[] = reference
285
+ ? (p.needs ?? []).map((n) => done.get(n)?.resultReceipt?.ref).filter((x): x is string => x !== undefined)
286
+ : [];
188
287
  const task = buildPhaseTask(baseTask, spec, p, upstream);
189
288
  // A throwing phase (e.g. a misconfigured strategy) is a phase FAILURE, not a
190
289
  // crashed DAG — record it failed so its dependents block and the flow finishes.
191
- let r: AgentResult;
290
+ let r: ResumedResult;
192
291
  try {
193
- r = await deps.runPhase({ phase: p, task, upstream });
292
+ r = await deps.runPhase({ phase: p, task, upstream, resultRefs });
194
293
  } catch (err) {
195
294
  const error = err instanceof Error ? err.message : String(err);
196
295
  r = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
197
296
  }
297
+ // Reference mode is a durability contract: a phase that finished without an archived
298
+ // ref cannot serve its dependents. It stays done — dependents BLOCK on it below.
299
+ if (reference && r.ok && r.resultReceipt?.ref === undefined) {
300
+ r = { ...r, refUnavailable: "the phase completed without an archived result reference" };
301
+ }
198
302
  // The run already gave up on this phase and moved on. Its UI node is torn down and
199
303
  // its outcome is already reported, so re-drive neither — but work that DID complete
200
304
  // is still worth keeping: journal it so a RESUME reuses the output instead of paying
201
305
  // for the same phase a second time. A late FAILURE is not journaled — there is
202
306
  // nothing to reuse, and a resume has to re-run it anyway.
203
307
  if (forced) {
204
- if (r.ok) deps.journal?.({ phase: p.id, hash: deps.hash, ok: true, output: r.output });
308
+ if (r.ok) journalSettled(p.id, r);
205
309
  return;
206
310
  }
207
311
  settled.set(p.id, r);
208
312
  deps.onPhase?.(p.id, r.ok ? "done" : "failed", r);
209
- deps.journal?.({ phase: p.id, hash: deps.hash, ok: r.ok, output: r.output });
313
+ journalSettled(p.id, r);
210
314
  }),
211
315
  );
212
316
 
@@ -246,16 +350,26 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
246
350
  if (!phase?.gate || !r.ok || gateState.has(id)) continue;
247
351
  const approved = deps.approveGate ? await deps.approveGate(phase, r) : true;
248
352
  gateState.set(id, approved ? "passed" : "rejected");
249
- if (approved) deps.journal?.({ phase: id, hash: deps.hash, ok: true, output: r.output, gateApproved: true });
353
+ if (approved) journalSettled(id, r, true);
250
354
  }
251
355
  remaining = remaining.filter((p) => !done.has(p.id));
252
356
  }
253
357
 
358
+ // Missing reference evidence is a failed resume, even when the saved phase is a sink
359
+ // with no dependent to expose the problem. Retain it in done to prevent any respawn.
360
+ for (const [id, result] of done) {
361
+ if (result.refUnavailable) done.set(id, {
362
+ ...result, ok: false, error: `Archived result reference is unavailable: ${result.refUnavailable}`, failureKind: "contract",
363
+ });
364
+ }
254
365
  const results: Record<string, AgentResult> = Object.fromEntries(done);
255
366
  const needed = new Set(spec.phases.flatMap((p) => p.needs ?? []));
256
367
  const terminals = spec.phases.filter((p) => !needed.has(p.id));
257
368
  const terminalOutput = terminals
258
- .map((p) => done.get(p.id)?.output ?? "")
369
+ .map((p) => {
370
+ const result = done.get(p.id);
371
+ return `${result?.output ?? ""}${result?.resultReceipt?.truncated ? "\n[pi-persona] Archive retention truncated this report; this is the partial stored body." : ""}`;
372
+ })
259
373
  .filter((s) => s.trim())
260
374
  .join("\n\n---\n\n");
261
375
  const ok = spec.phases.every((p) => done.get(p.id)?.ok ?? false);
@@ -270,6 +384,12 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
270
384
  const failure = cancellation
271
385
  ? failures.find((entry) => entry.result.failureKind === "abort")
272
386
  : rootFailures[0] ?? failures[0];
387
+ // A rejected checkpoint is a STOP, not a failure, only when nothing else went wrong: a real phase
388
+ // failure or a cancellation is the more important fact and keeps its ordinary report. A rejected
389
+ // gate with no dependents blocks nothing, so the flow is still `ok` and was not stopped.
390
+ const stoppedAtGate = !cancellation && !ok && rootFailures.length === 0
391
+ ? spec.phases.find((p) => gateState.get(p.id) === "rejected")?.id
392
+ : undefined;
273
393
  const failedPhase = failure?.phase.id;
274
394
  const error = cancellation ? (failure?.result.error ?? CANCELLED_ERROR) : failure?.result.error;
275
395
  const failureKind = cancellation ? "abort" as const : failure?.result.failureKind;
@@ -278,24 +398,32 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
278
398
  : `Flow failed: ${error ?? "the flow did not complete successfully"}`;
279
399
  const cancellationText = `Flow cancelled${failedPhase ? ` at phase "${failedPhase}"` : ""}: ${error ?? CANCELLED_ERROR}`;
280
400
  const outputAlreadySignalsCancellation = /\b(?:cancel(?:led|lation)?|abort(?:ed)?|stopp?ed)\b/i.test(terminalOutput);
281
- const output = cancellation
282
- ? terminalOutput.trim()
283
- ? outputAlreadySignalsCancellation
284
- ? terminalOutput
285
- : `${terminalOutput}\n\n${cancellationText}`
286
- : cancellationText
287
- : terminalOutput.trim()
288
- ? terminalOutput
289
- : failure
290
- ? failureText
291
- : terminalOutput;
401
+ const terminalReport = (): string => {
402
+ // A rejected checkpoint answers with the gated phase's own output: that is what the operator was
403
+ // asked to approve, and dropping it on a "no" would throw away work already paid for. Another
404
+ // branch that finished keeps its answer beside it.
405
+ if (stoppedAtGate !== undefined) {
406
+ return [
407
+ done.get(stoppedAtGate)?.output ?? "",
408
+ terminalOutput,
409
+ `- flow stopped at checkpoint "${stoppedAtGate}": not approved (declined, dismissed, or no interactive session). Later phases did not run.`,
410
+ ].filter((part) => part.trim()).join("\n\n");
411
+ }
412
+ if (cancellation) {
413
+ if (!terminalOutput.trim()) return cancellationText;
414
+ return outputAlreadySignalsCancellation ? terminalOutput : `${terminalOutput}\n\n${cancellationText}`;
415
+ }
416
+ if (terminalOutput.trim()) return terminalOutput;
417
+ return failure ? failureText : terminalOutput;
418
+ };
292
419
  return {
293
420
  ok,
294
421
  results,
295
- output,
422
+ output: terminalReport(),
296
423
  ...(failedPhase ? { failedPhase } : {}),
297
424
  ...(error ? { error } : {}),
298
425
  ...(failureKind ? { failureKind } : {}),
299
426
  ...(cancelled ? { cancelled: true } : {}),
427
+ ...(stoppedAtGate !== undefined ? { stoppedAtGate } : {}),
300
428
  };
301
429
  }
@@ -22,18 +22,30 @@ export interface FlowPhase {
22
22
  export interface FlowSpec {
23
23
  name: string;
24
24
  description?: string;
25
+ /** How a dependent phase receives its upstream phases' output. `inline` (the default,
26
+ * and still first-class) embeds each upstream result's full fenced body in the dependent's
27
+ * task; `reference` embeds only a bounded preview plus the archived result reference — the
28
+ * dependent reads the rest through the scoped `read_result` tool (see flow-run.ts). */
29
+ handoff?: "inline" | "reference";
25
30
  phases: FlowPhase[];
26
31
  }
27
32
 
28
33
  export type FlowParse = { ok: true; flow: FlowSpec } | { ok: false; error: string };
29
34
  export type TopoResult = { ok: true; order: string[] } | { ok: false; error: string };
30
35
 
31
- /** The pinned `flow@hash` for a run (I3): a stable sha256 over the spec, so a hot reload
32
- * of the file can't change an in-flight run and a resume only matches the same flow. */
36
+ /** A stable sha256 over the spec — the flow half of a run's identity, and the journal file name's
37
+ * hash (`journalFileName`): a hot reload of the file can't change an in-flight run. */
33
38
  export function flowHash(spec: FlowSpec): string {
34
39
  return stableHash(spec);
35
40
  }
36
41
 
42
+ /** The pinned run identity (I3) recorded in every journal line: flow + task + cwd. A resume
43
+ * skips finished phases only for the SAME flow on the SAME task in the SAME project; a failed
44
+ * `flow X` on task A must never feed its phases' output to `flow X` on task B. */
45
+ export function flowRunHash(spec: FlowSpec, task: string, cwd: string): string {
46
+ return stableHash({ flow: flowHash(spec), task: task.trim(), cwd });
47
+ }
48
+
37
49
  function isObject(x: unknown): x is Record<string, unknown> {
38
50
  return typeof x === "object" && x !== null && !Array.isArray(x);
39
51
  }
@@ -104,6 +116,12 @@ export function parseFlow(content: string): FlowParse {
104
116
 
105
117
  const flow: FlowSpec = { name: raw.name, phases };
106
118
  if (typeof raw.description === "string") flow.description = raw.description;
119
+ if (raw.handoff !== undefined) {
120
+ if (raw.handoff !== "inline" && raw.handoff !== "reference") {
121
+ return { ok: false, error: `flow.handoff must be "inline" or "reference" (got ${JSON.stringify(raw.handoff)})` };
122
+ }
123
+ flow.handoff = raw.handoff;
124
+ }
107
125
  return { ok: true, flow };
108
126
  }
109
127
 
@@ -89,21 +89,25 @@ export function itemLedger(
89
89
  });
90
90
  }
91
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) => {
92
+ /** Combine fan-out results into one labeled-section result; ok iff all ok. `labels` (aligned to
93
+ * `results`) name each section by lens or item instead of the bare agent; the label is kept as
94
+ * `member` only when it adds something, so a label-free call is byte-identical to before. */
95
+ export function aggregateResults(results: AgentResult[], labels?: readonly string[]): AgentResult {
96
+ const labelOf = (r: AgentResult, i: number): string => labels?.[i] ?? r.agent;
97
+ const sections = results.map((r, i) => {
95
98
  const status = r.ok ? "ok" : `failed${r.failureKind ? ` (${r.failureKind})` : ""}`;
96
99
  const body = r.output || r.error || "(no output)";
97
100
  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}`;
101
+ return `### [${labelOf(r, i)}] ${status}\n\n${body}${cause}`;
99
102
  });
100
103
  const aggregate: AgentResult = {
101
104
  agent: "aggregate",
102
105
  output: sections.join("\n\n---\n\n"),
103
106
  structured: {
104
107
  count: results.length,
105
- results: results.map((r) => ({
108
+ results: results.map((r, i) => ({
106
109
  agent: r.agent,
110
+ ...(labelOf(r, i) !== r.agent ? { member: labelOf(r, i) } : {}),
107
111
  ok: r.ok,
108
112
  output: r.output,
109
113
  error: r.error ?? null,
@@ -72,7 +72,7 @@ export function humanizeAggregateResult(r: AgentResult): string | undefined {
72
72
  for (const value of members) {
73
73
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
74
74
  const member = value as Record<string, unknown>;
75
- const agent = typeof member.agent === "string" && member.agent.trim() ? member.agent.trim() : "member";
75
+ const agent = field(member, "member") ?? field(member, "agent") ?? "member";
76
76
  const ok = member.ok === true;
77
77
  const output = typeof member.output === "string" ? member.output : "";
78
78
  const structured = member.structured && typeof member.structured === "object" && !Array.isArray(member.structured)
@@ -3,7 +3,7 @@
3
3
  * Referenced by a persona's `orchestration.roster` or `council.roster`.
4
4
  *
5
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
6
+ * agent inline — `{ agent, role?, model?, thinking?, skills? }` — so an ensemble of distinct
7
7
  * perspectives can be built from a SINGLE agent (e.g. one `reviewer` run three times
8
8
  * with different `role`s and models) instead of one .md file per perspective. That is
9
9
  * the "skills + role provide specialisation, not files" principle applied to rosters.
@@ -14,6 +14,9 @@
14
14
  */
15
15
 
16
16
  import { asBoolean, asStringArray, parseYamlSubset } from "../core/frontmatter.ts";
17
+ import { stripTerminalControls } from "../core/fence.ts";
18
+ import { parseLegThinking } from "../core/leg-thinking.ts";
19
+ import { isThinkingLevel, THINKING_LEVELS, type ThinkingLevel } from "../core/types.ts";
17
20
  import type { Roster } from "./sdk.ts";
18
21
 
19
22
  /** A roster member: a bare agent name, or an inline specialisation of one agent. `tools`/
@@ -21,13 +24,14 @@ import type { Roster } from "./sdk.ts";
21
24
  * knobs (see `tools/delegate.ts`'s `DelegateTask`). */
22
25
  export type RosterMember =
23
26
  | string
24
- | { agent: string; role?: string; model?: string; skills?: string[]; tools?: string[]; isolation?: "none" | "worktree"; mcp?: boolean };
27
+ | RosterSpec;
25
28
 
26
29
  /** The normalised run-spec fields a member contributes (agent + any specialisation). */
27
30
  export interface RosterSpec {
28
31
  agent: string;
29
32
  role?: string;
30
33
  model?: string;
34
+ thinking?: ThinkingLevel;
31
35
  skills?: string[];
32
36
  tools?: string[];
33
37
  isolation?: "none" | "worktree";
@@ -43,6 +47,8 @@ export function rosterSpec(member: RosterMember): RosterSpec {
43
47
  const spec: RosterSpec = { agent: member.agent };
44
48
  if (member.role?.trim()) spec.role = member.role.trim();
45
49
  if (member.model?.trim()) spec.model = member.model.trim();
50
+ const thinking = parseLegThinking(member.thinking);
51
+ if (thinking !== undefined) spec.thinking = thinking;
46
52
  if (member.skills && member.skills.length > 0) spec.skills = member.skills;
47
53
  if (member.tools !== undefined) spec.tools = member.tools;
48
54
  if (member.isolation !== undefined) spec.isolation = member.isolation;
@@ -57,19 +63,56 @@ const ROLE_STOPWORDS = new Set([
57
63
  "ON", "IN", "OF", "TO", "IS", "IT", "BE", "DO", "AS", "AT", "BY", "AN", "A",
58
64
  ]);
59
65
 
66
+ const GIT_REF = /^(?:HEAD|ORIG_HEAD|FETCH_HEAD|MERGE_HEAD|CHERRY_PICK_HEAD|REBASE_HEAD)(?:[~^].*)?$/i;
67
+ const HASH = /^(?:0x)?[\da-f]{7,40}$/i;
68
+
69
+ function technicalRoleToken(token: string): boolean {
70
+ const trimmed = token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
71
+ return token.includes("_")
72
+ || token.includes("/")
73
+ || token.includes("\\")
74
+ || token.includes("$")
75
+ || token.includes("=")
76
+ || (trimmed.includes(":") && !trimmed.endsWith(":"))
77
+ || GIT_REF.test(trimmed)
78
+ || HASH.test(trimmed)
79
+ || /^[\p{L}\p{N}_.-]+\.[\p{L}\p{N}]{1,8}$/u.test(trimmed);
80
+ }
81
+
82
+ function roleWords(role: string): string[] {
83
+ // Inline/fenced code is not a role label. An unmatched opening fence also hides the
84
+ // remainder instead of leaking its contents into the fallback label.
85
+ const withoutCode = role.replace(/(`{1,3})[\s\S]*?\1/g, " ").replace(/`{1,3}[\s\S]*$/g, " ");
86
+ return withoutCode.split(/\s+/).flatMap((raw) => {
87
+ const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
88
+ return word && !technicalRoleToken(raw) && !technicalRoleToken(word) ? [word] : [];
89
+ });
90
+ }
91
+
92
+ function boundedRoleHint(text: string): string {
93
+ const points = Array.from(text);
94
+ return points.length > 20 ? `${points.slice(0, 19).join("")}…` : text;
95
+ }
96
+
60
97
  /** 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. */
98
+ * (e.g. "SECURITY" from "Focus ONLY on the SECURITY lens…"), else a short human-readable
99
+ * prefix. Technical identifiers, code, paths, hashes and generic Git refs are omitted; a
100
+ * role with no readable words gets the neutral "role" hint. Used by presentation and
101
+ * run-unique UI keys, with roster seeding and SDK keys sharing this exact function. Pure. */
63
102
  export function roleHint(role: string): string {
64
- const words = role.split(/\s+/);
103
+ // Roles become picker/tree chrome. Strip terminal sequences before token parsing so their
104
+ // numeric parameters cannot be mistaken for visible prose, then remove bidi formatting
105
+ // controls that could visually reorder or disguise the resulting label.
106
+ const readable = stripTerminalControls(role).replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u206f]/gu, "");
107
+ const words = roleWords(readable);
65
108
  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;
109
+ if (/^\p{L}{3,}$/u.test(w) && w === w.toUpperCase() && !ROLE_STOPWORDS.has(w)) {
110
+ return boundedRoleHint(w);
69
111
  }
70
112
  }
71
- const brief = words.slice(0, 4).join(" ").replace(/[:.,;].*$/, "").trim();
72
- return brief.length > 20 ? `${brief.slice(0, 20)}…` : brief || "role";
113
+ const brief = words.slice(0, 4);
114
+ while (brief.length && ROLE_STOPWORDS.has(brief[brief.length - 1]!.toUpperCase())) brief.pop();
115
+ return boundedRoleHint(brief.join(" ")) || "role";
73
116
  }
74
117
 
75
118
  /** The base UI label for a member: `agent`, or `agent · HINT` when it carries a role —
@@ -99,6 +142,7 @@ export function rosterNodeKeys(members: RosterMember[]): string[] {
99
142
  * LENIENT, and deliberately so: this is the STATIC `teams.yaml` path (authored, trusted project
100
143
  * data), where a member with a badly-typed field is normalised, not rejected — a bad `model`
101
144
  * is dropped and a numeric `tools` entry is coerced to its string, so the panel still runs.
145
+ * Explicit `thinking` is always strict: a typo must never inherit a different effort.
102
146
  * Anything RUNTIME-supplied (a `council` call's `members`, an aux actor param) must go through
103
147
  * {@link parseRuntimeRosterMember} instead: there, a dropped `tools` silently WIDENS a leg to
104
148
  * the agent's default tool permissions, so a malformed field has to be refused, not normalised.
@@ -108,11 +152,13 @@ export function parseRosterMember(raw: unknown): RosterMember | undefined {
108
152
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
109
153
  const o = raw as Record<string, unknown>;
110
154
  if (typeof o.agent !== "string" || !o.agent.trim()) return undefined;
111
- const m: { agent: string; role?: string; model?: string; skills?: string[]; tools?: string[]; isolation?: "none" | "worktree"; mcp?: boolean } = {
155
+ const m: RosterSpec = {
112
156
  agent: o.agent.trim(),
113
157
  };
114
158
  if (typeof o.role === "string" && o.role.trim()) m.role = o.role.trim();
115
159
  if (typeof o.model === "string" && o.model.trim()) m.model = o.model.trim();
160
+ const thinking = parseLegThinking(o.thinking);
161
+ if (thinking !== undefined) m.thinking = thinking;
116
162
  const skills = asStringArray(o.skills);
117
163
  if (skills) m.skills = skills;
118
164
  const tools = asStringArray(o.tools);
@@ -126,7 +172,7 @@ export function parseRosterMember(raw: unknown): RosterMember | undefined {
126
172
  }
127
173
 
128
174
  /** Every field a member map may carry — anything else is a typo, not a silent no-op. */
129
- const MEMBER_FIELDS = new Set(["agent", "role", "model", "skills", "tools", "isolation", "mcp"]);
175
+ const MEMBER_FIELDS = new Set(["agent", "role", "model", "thinking", "skills", "tools", "isolation", "mcp"]);
130
176
 
131
177
  export type RuntimeMember = { ok: true; member: RosterMember } | { ok: false; error: string };
132
178
 
@@ -155,17 +201,13 @@ export function parseRuntimeRosterMember(raw: unknown): RuntimeMember {
155
201
  }
156
202
  const unknownKeys = Object.keys(o).filter((k) => !MEMBER_FIELDS.has(k));
157
203
  if (unknownKeys.length > 0) {
158
- return { ok: false, error: `unknown member field(s) ${unknownKeys.join(", ")} — expected agent, role, model, skills, tools, isolation, mcp` };
204
+ return { ok: false, error: `unknown member field(s) ${unknownKeys.join(", ")} — expected agent, role, model, thinking, skills, tools, isolation, mcp` };
205
+ }
206
+ const member: RosterSpec = { agent: o.agent.trim() };
207
+ if (o.thinking !== undefined) {
208
+ if (!isThinkingLevel(o.thinking)) return { ok: false, error: `"thinking" must be one of: ${THINKING_LEVELS.join(", ")}` };
209
+ member.thinking = o.thinking;
159
210
  }
160
- const member: {
161
- agent: string;
162
- role?: string;
163
- model?: string;
164
- skills?: string[];
165
- tools?: string[];
166
- isolation?: "none" | "worktree";
167
- mcp?: boolean;
168
- } = { agent: o.agent.trim() };
169
211
  // A blank string is "unset" (the lenient path's own reading); a non-string is a type error.
170
212
  for (const key of ["role", "model"] as const) {
171
213
  const value = o[key];
@@ -199,7 +241,13 @@ export function parseTeams(yaml: string): Record<string, RosterMember[]> {
199
241
  const teams: Record<string, RosterMember[]> = {};
200
242
  for (const [name, value] of Object.entries(raw)) {
201
243
  const items: unknown[] = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
202
- const members = items.map(parseRosterMember).filter((m): m is RosterMember => m !== undefined);
244
+ const members = items.map((item, index) => {
245
+ try {
246
+ return parseRosterMember(item);
247
+ } catch (error) {
248
+ throw new Error(`team ${name}[${index}]: ${error instanceof Error ? error.message : String(error)}`);
249
+ }
250
+ }).filter((m): m is RosterMember => m !== undefined);
203
251
  if (members.length > 0) teams[name] = members;
204
252
  }
205
253
  return teams;