@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.
- package/AGENTS.md +103 -64
- package/CHANGELOG.md +310 -1
- package/README.md +37 -23
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +280 -92
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +52 -17
- package/docs/MONITORS.md +62 -3
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +139 -38
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +15 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +26 -0
- package/src/bridge.ts +28 -10
- package/src/bus/broker/client.ts +119 -21
- package/src/bus/broker/host.ts +123 -8
- package/src/bus/broker/messages.ts +31 -4
- package/src/bus/contact.ts +113 -38
- package/src/bus/peers.ts +15 -0
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +436 -353
- package/src/core/capabilities.ts +1 -1
- package/src/core/config.ts +15 -10
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +1 -1
- package/src/core/timer.ts +34 -9
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +95 -27
- package/src/engine/async.ts +213 -155
- package/src/engine/child.ts +12 -4
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +99 -53
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +10 -20
- 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 +47 -18
- 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/engine.ts +54 -28
- package/src/extension/hooks.ts +952 -868
- package/src/extension/identity.ts +41 -75
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +32 -7
- package/src/extension.ts +2965 -2611
- package/src/loader.ts +11 -3
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +33 -6
- package/src/orchestration/flow-run.ts +160 -32
- package/src/orchestration/flow.ts +20 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -11
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +2 -0
- package/src/persona/persona.ts +8 -4
- 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/background-run.ts +151 -0
- package/src/tools/council.ts +108 -66
- package/src/tools/delegate-tool.ts +349 -283
- package/src/tools/delegate.ts +35 -216
- 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-work.ts +18 -53
- package/src/tools/exocom.ts +35 -22
- package/src/tools/flow.ts +103 -30
- package/src/tools/intercom-tool.ts +202 -128
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +34 -9
- package/src/tools/monitor.ts +58 -6
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +34 -15
- package/src/ui/agent-overlay.ts +65 -16
- package/src/ui/agent-tree.ts +127 -19
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/persona-settings.ts +9 -3
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +366 -0
- package/src/ui/wake-rows.ts +262 -0
- 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,
|
|
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 & {
|
|
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 `
|
|
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
|
-
/**
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
|
132
|
-
for (const [id,
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
163
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
/**
|
|
32
|
-
* of the file can't change an in-flight run
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
|
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;
|