@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
package/src/loader.ts CHANGED
@@ -120,8 +120,12 @@ export function loadDefinitions(dirs: ScopedDir[]): LoadResult {
120
120
  const agents: AgentConfig[] = [];
121
121
  for (const f of agentMerge.resolved) {
122
122
  if (collisionNames.has(f.name)) continue;
123
- const agent = parseAgent(content.get(f.path) ?? "", f.path);
124
- if (agent) agents.push(agent);
123
+ try {
124
+ const agent = parseAgent(content.get(f.path) ?? "", f.path);
125
+ if (agent) agents.push(agent);
126
+ } catch (error) {
127
+ warnings.push(`${f.path}: ${error instanceof Error ? error.message : String(error)} — skipped`);
128
+ }
125
129
  }
126
130
 
127
131
  return { personas, agents, shadowed: [...personaMerge.shadowed, ...agentMerge.shadowed], collisions, warnings };
@@ -212,7 +216,11 @@ export function loadTeams(files: string[]): Record<string, RosterMember[]> {
212
216
  } catch {
213
217
  continue;
214
218
  }
215
- Object.assign(merged, parseTeams(content));
219
+ try {
220
+ Object.assign(merged, parseTeams(content));
221
+ } catch (error) {
222
+ throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
216
224
  }
217
225
  return merged;
218
226
  }
@@ -0,0 +1,15 @@
1
+ /** Exact metadata for one engine attempt; display hints are never participant identities. */
2
+ import type { ThinkingLevel } from "../core/types.ts";
3
+
4
+ export interface AgentAttemptIdentity {
5
+ readonly agent: string;
6
+ readonly role?: string;
7
+ readonly model?: string;
8
+ readonly thinking?: ThinkingLevel;
9
+ }
10
+
11
+ /** Copy primitives only: no task, tool grants, result references or mutable caller object. */
12
+ export function agentAttemptIdentity(spec: AgentAttemptIdentity): AgentAttemptIdentity {
13
+ return Object.freeze({ agent: spec.agent, ...(spec.role !== undefined ? { role: spec.role } : {}),
14
+ ...(spec.model !== undefined ? { model: spec.model } : {}), ...(spec.thinking !== undefined ? { thinking: spec.thinking } : {}) });
15
+ }
@@ -59,7 +59,13 @@ export function parseJournal(content: string, hash: string): Record<string, Resu
59
59
  if (e.hash !== hash || typeof e.phase !== "string") continue;
60
60
  if (e.ok) {
61
61
  const prior = out[e.phase];
62
- out[e.phase] = { agent: e.phase, output: e.output ?? "", usage: emptyUsage(), ok: true };
62
+ out[e.phase] = {
63
+ agent: e.phase, output: e.output ?? "", usage: emptyUsage(), ok: true,
64
+ ...(typeof e.resultRef === "string" ? { resultRef: e.resultRef } : {}),
65
+ ...(e.resultVersion === 1 ? { resultVersion: 1 as const } :
66
+ e.resultRef !== undefined || e.resultVersion !== undefined
67
+ ? { refUnavailable: "unsupported or missing archived result journal version" } : {}),
68
+ };
63
69
  // Preserve an approval from either this line or an earlier line for the same phase.
64
70
  if (e.gateApproved || prior?.gateApproved) out[e.phase]!.gateApproved = true;
65
71
  } else delete out[e.phase];
@@ -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,10 +35,23 @@ 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). */
@@ -43,6 +59,10 @@ export interface FlowRunDeps {
43
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;
@@ -123,37 +143,97 @@ function within(p: Promise<unknown>, ms: number): Promise<void> {
123
143
  });
124
144
  }
125
145
 
126
- /** 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. */
127
153
  function buildPhaseTask(baseTask: string, spec: FlowSpec, phase: FlowPhase, upstream: Record<string, AgentResult>): string {
128
154
  const parts = [`Flow "${spec.name}" — phase "${phase.id}" (strategy: ${phase.strategy}).`, baseTask];
129
155
  const ups = Object.entries(upstream);
130
- if (ups.length > 0) {
131
- parts.push("\nUpstream results to build on:");
132
- 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
+ const preview = r.output.slice(0, 200);
165
+ parts.push(preview
166
+ ? `result reference unavailable:\n${fenceUntrusted(preview)}`
167
+ : "result reference unavailable: (no output)");
168
+ continue;
169
+ }
170
+ parts.push(`result ref: ${ref}`);
171
+ if (r.resultReceipt?.truncated) parts.push("Archive retention truncated the original report; the stored body is partial even after reading every page.");
172
+ 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)" : ""}`);
173
+ 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.`);
174
+ }
175
+ return parts.join("\n");
133
176
  }
177
+ parts.push("\nUpstream results to build on:");
178
+ for (const [id, r] of ups) parts.push(`\n--- ${id} ---\n${fenceUntrusted(r.output)}`);
134
179
  return parts.join("\n");
135
180
  }
136
181
 
137
182
  export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDeps): Promise<FlowOutcome> {
138
183
  const byId = new Map(spec.phases.map((p) => [p.id, p]));
184
+ const reference = spec.handoff === "reference";
185
+ /** One journal write, mode-aware: reference lines carry the ref (+version marker) and never
186
+ * the body; inline lines stay byte-identical to the historical format. */
187
+ const journalSettled = (id: string, r: AgentResult, gateApproved = false): void => {
188
+ if (!deps.journal) return;
189
+ const ref = r.resultReceipt?.ref;
190
+ deps.journal(reference
191
+ ? { phase: id, hash: deps.hash, ok: r.ok, output: "", ...(r.ok && ref !== undefined ? { resultRef: ref, resultVersion: 1 as const } : {}), ...(gateApproved ? { gateApproved: true } : {}) }
192
+ : { phase: id, hash: deps.hash, ok: r.ok, output: r.output, ...(gateApproved ? { gateApproved: true } : {}) });
193
+ };
194
+ /** Reference resume: rebuild a done phase's output from the terminal archive. A ref that
195
+ * cannot be read back marks the phase `refUnavailable` — its dependents BLOCK (no respawn,
196
+ * no inline fallback, no re-run of the done phase); the error says why. */
197
+ const restoreResumed = (r: ResumedResult): ResumedResult => {
198
+ if (r.refUnavailable) return { ...r, output: "" };
199
+ const ref = r.resultRef;
200
+ if (ref === undefined) {
201
+ return { ...r, output: "", refUnavailable: "the phase was journaled without an archived result reference" };
202
+ }
203
+ if (r.resultVersion !== 1) return { ...r, output: "", refUnavailable: "unsupported or missing archived result journal version" };
204
+ const read = deps.readArchivedResult?.(ref);
205
+ if (!read) return { ...r, output: "", refUnavailable: `archived result ${ref} is unreadable (no archive reader is wired for this run)` };
206
+ if (!read.ok) return { ...r, output: "", refUnavailable: `archived result ${ref} is unreadable (${read.error})` };
207
+ if (read.status !== "done") return { ...r, output: "", refUnavailable: `archived result ${ref} has status ${read.status}; expected done` };
208
+ return { ...r, output: read.body, resultReceipt: { ...(r.resultReceipt ?? {}), ref, ...(read.truncated === undefined ? {} : { truncated: read.truncated }) } };
209
+ };
139
210
  // A gated phase only satisfies its dependents once its checkpoint is "passed" (or it blocks
140
211
  // them once "rejected"). Resume pre-seeds gates the journal already recorded as approved.
141
212
  const gateState = new Map<string, "passed" | "rejected">();
142
- const done = new Map<string, AgentResult>();
143
- for (const [id, r] of Object.entries(deps.resume ?? {})) {
213
+ const done = new Map<string, ResumedResult>();
214
+ for (const [id, r0] of Object.entries(deps.resume ?? {})) {
144
215
  const gated = byId.get(id)?.gate === true;
145
216
  // A gated phase resumed as ok but WITHOUT a recorded approval (the process died between
146
217
  // journaling its completion and its gate decision) must RE-RUN, not be treated as done —
147
218
  // otherwise it sits in `done` with an unresolved gate and its dependents can never run.
148
- if (gated && r.ok && !(r as ResumedResult).gateApproved) continue;
219
+ let r: ResumedResult = r0;
220
+ if (reference && r0.ok) {
221
+ const restored = restoreResumed(r0);
222
+ r = restored.resultReceipt?.ref !== undefined ? restored : { ...restored, refUnavailable: restored.refUnavailable ?? "the phase has no archived result reference" };
223
+ }
224
+ // An invalid reference cannot turn this historical checkpoint into newly paid work.
225
+ if (gated && r.ok && !r.gateApproved && !r.refUnavailable) continue;
149
226
  done.set(id, r);
150
- if (gated && (r as ResumedResult).gateApproved) gateState.set(id, "passed");
227
+ if (gated && r0.gateApproved) gateState.set(id, "passed");
151
228
  }
152
229
  // A FAILED gated phase has no checkpoint to resolve — its gate must not keep dependents
153
230
  // from being considered (needFailed then records them "blocked", exactly like a failed
154
231
  // non-gated need; otherwise they'd never become ready and silently vanish, stuck ⏳).
155
232
  const gateResolved = (id: string): boolean => !byId.get(id)?.gate || gateState.has(id) || done.get(id)?.ok === false;
156
233
  const needFailed = (n: string): boolean => (done.has(n) && !done.get(n)?.ok) || gateState.get(n) === "rejected";
234
+ /** An OK need whose archived reference cannot serve dependents (unreadable at resume, or a
235
+ * live capture that produced no ref). The need stays DONE — only its dependents block. */
236
+ const needUnavailable = (n: string): boolean => (done.get(n)?.refUnavailable ?? "") !== "";
157
237
  let remaining = spec.phases.filter((p) => !done.has(p.id));
158
238
  let cancelled = false;
159
239
  // Keep blocked dependents distinct from phases that actually ran and failed. The flow's
@@ -170,12 +250,18 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
170
250
  const ready = remaining.filter((p) => (p.needs ?? []).every((n) => done.has(n) && gateResolved(n)));
171
251
  if (ready.length === 0) break; // acyclic + progress guarantees this can't happen
172
252
 
173
- // A phase whose any need FAILED or whose gate was REJECTED is blocked — record, don't run.
174
- const blocked = ready.filter((p) => (p.needs ?? []).some((n) => needFailed(n)));
253
+ // A phase whose any need FAILED, whose gate was REJECTED, or (reference mode) whose need's
254
+ // archived ref cannot be read — record, don't run.
255
+ const blocked = ready.filter((p) => (p.needs ?? []).some((n) => needFailed(n) || needUnavailable(n)));
175
256
  for (const p of blocked) {
176
257
  blockedPhaseIds.add(p.id);
177
258
  const gateReject = (p.needs ?? []).some((n) => gateState.get(n) === "rejected");
178
- const error = gateReject ? "blocked: a checkpoint gate was not approved" : "blocked: an upstream phase failed";
259
+ const unavailableNeed = (p.needs ?? []).find((n) => needUnavailable(n));
260
+ const error = gateReject
261
+ ? "blocked: a checkpoint gate was not approved"
262
+ : unavailableNeed !== undefined
263
+ ? `blocked: upstream phase "${unavailableNeed}" result reference is unavailable (${done.get(unavailableNeed)?.refUnavailable})`
264
+ : "blocked: an upstream phase failed";
179
265
  const r: AgentResult = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
180
266
  done.set(p.id, r);
181
267
  deps.onPhase?.(p.id, "failed", r);
@@ -196,28 +282,38 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
196
282
  const u = done.get(n);
197
283
  if (u) upstream[n] = u;
198
284
  }
285
+ // Reference mode: the phase's legs are granted EXACTLY its direct needs' refs —
286
+ // never a transitive ancestor's (the reader scope mirrors the task's previews).
287
+ const resultRefs: readonly string[] = reference
288
+ ? (p.needs ?? []).map((n) => done.get(n)?.resultReceipt?.ref).filter((x): x is string => x !== undefined)
289
+ : [];
199
290
  const task = buildPhaseTask(baseTask, spec, p, upstream);
200
291
  // A throwing phase (e.g. a misconfigured strategy) is a phase FAILURE, not a
201
292
  // crashed DAG — record it failed so its dependents block and the flow finishes.
202
- let r: AgentResult;
293
+ let r: ResumedResult;
203
294
  try {
204
- r = await deps.runPhase({ phase: p, task, upstream });
295
+ r = await deps.runPhase({ phase: p, task, upstream, resultRefs });
205
296
  } catch (err) {
206
297
  const error = err instanceof Error ? err.message : String(err);
207
298
  r = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
208
299
  }
300
+ // Reference mode is a durability contract: a phase that finished without an archived
301
+ // ref cannot serve its dependents. It stays done — dependents BLOCK on it below.
302
+ if (reference && r.ok && r.resultReceipt?.ref === undefined) {
303
+ r = { ...r, refUnavailable: "the phase completed without an archived result reference" };
304
+ }
209
305
  // The run already gave up on this phase and moved on. Its UI node is torn down and
210
306
  // its outcome is already reported, so re-drive neither — but work that DID complete
211
307
  // is still worth keeping: journal it so a RESUME reuses the output instead of paying
212
308
  // for the same phase a second time. A late FAILURE is not journaled — there is
213
309
  // nothing to reuse, and a resume has to re-run it anyway.
214
310
  if (forced) {
215
- if (r.ok) deps.journal?.({ phase: p.id, hash: deps.hash, ok: true, output: r.output });
311
+ if (r.ok) journalSettled(p.id, r);
216
312
  return;
217
313
  }
218
314
  settled.set(p.id, r);
219
315
  deps.onPhase?.(p.id, r.ok ? "done" : "failed", r);
220
- deps.journal?.({ phase: p.id, hash: deps.hash, ok: r.ok, output: r.output });
316
+ journalSettled(p.id, r);
221
317
  }),
222
318
  );
223
319
 
@@ -257,16 +353,26 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
257
353
  if (!phase?.gate || !r.ok || gateState.has(id)) continue;
258
354
  const approved = deps.approveGate ? await deps.approveGate(phase, r) : true;
259
355
  gateState.set(id, approved ? "passed" : "rejected");
260
- if (approved) deps.journal?.({ phase: id, hash: deps.hash, ok: true, output: r.output, gateApproved: true });
356
+ if (approved) journalSettled(id, r, true);
261
357
  }
262
358
  remaining = remaining.filter((p) => !done.has(p.id));
263
359
  }
264
360
 
361
+ // Missing reference evidence is a failed resume, even when the saved phase is a sink
362
+ // with no dependent to expose the problem. Retain it in done to prevent any respawn.
363
+ for (const [id, result] of done) {
364
+ if (result.refUnavailable) done.set(id, {
365
+ ...result, ok: false, error: `Archived result reference is unavailable: ${result.refUnavailable}`, failureKind: "contract",
366
+ });
367
+ }
265
368
  const results: Record<string, AgentResult> = Object.fromEntries(done);
266
369
  const needed = new Set(spec.phases.flatMap((p) => p.needs ?? []));
267
370
  const terminals = spec.phases.filter((p) => !needed.has(p.id));
268
371
  const terminalOutput = terminals
269
- .map((p) => done.get(p.id)?.output ?? "")
372
+ .map((p) => {
373
+ const result = done.get(p.id);
374
+ return `${result?.output ?? ""}${result?.resultReceipt?.truncated ? "\n[pi-persona] Archive retention truncated this report; this is the partial stored body." : ""}`;
375
+ })
270
376
  .filter((s) => s.trim())
271
377
  .join("\n\n---\n\n");
272
378
  const ok = spec.phases.every((p) => done.get(p.id)?.ok ?? false);
@@ -22,6 +22,11 @@ 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
 
@@ -111,6 +116,12 @@ export function parseFlow(content: string): FlowParse {
111
116
 
112
117
  const flow: FlowSpec = { name: raw.name, phases };
113
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
+ }
114
125
  return { ok: true, flow };
115
126
  }
116
127
 
@@ -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;