@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.
- package/AGENTS.md +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/companion.ts +64 -0
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
- 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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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] = {
|
|
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,
|
|
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 & {
|
|
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
|
-
/**
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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,
|
|
143
|
-
for (const [id,
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
174
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
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
|
-
|
|
|
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
|
|
62
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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)
|
|
72
|
-
|
|
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:
|
|
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(
|
|
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;
|