pi-agent-fleet 0.3.0 → 0.5.0

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/src/command.ts CHANGED
@@ -1,19 +1,19 @@
1
- import { writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, stopCanvas, updateWidget } from "./controller.js";
2
+ import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
3
+ import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
5
4
  import { openInBrowser, listFleetRoots } from "./canvas.js";
6
5
  import { insertWorkers } from "./insert.js";
7
6
  import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
8
- import { resolveModelReference } from "./model-resolution.js";
7
+ import { listModelRefs, resolveModelReference } from "./model-resolution.js";
9
8
  import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
9
+ import { recoverLatestFleet } from "./fleet-recovery.js";
10
10
  import { resetForRelaunch, writeState } from "./state.js";
11
11
  import { buildWidgetLines } from "./ui.js";
12
12
  import { renderDag } from "./viz.js";
13
13
 
14
14
  export function registerFleetCommand(pi: ExtensionAPI): void {
15
15
  pi.registerCommand("fleet", {
16
- description: "Fleet commands: /fleet viz, /fleet status, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet relaunch <node_id> [model]",
16
+ description: "Fleet commands: /fleet viz, /fleet status, /fleet models, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet continue, /fleet relaunch <node_id> [model]",
17
17
  handler: async (args, ctx) => {
18
18
  const [cmd, target] = args.trim().split(/\s+/);
19
19
  if (cmd === "configure") {
@@ -32,7 +32,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
32
32
  return;
33
33
  }
34
34
  const prefs = await loadPreferences();
35
- const r = setPreference(prefs, key, value);
35
+ const r = setPreference(prefs, key, value, ctx.modelRegistry);
36
36
  if (!r.ok) {
37
37
  ctx.ui.notify(r.error, "error");
38
38
  return;
@@ -50,15 +50,19 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
50
50
  const key = field.split(":")[0] as (typeof PREFERENCE_KEYS)[number];
51
51
  const input = await ctx.ui.input(`${key} (current: ${prefs[key] ?? "—"}):`, "empty clears");
52
52
  if (input === undefined) break;
53
+ let err: string | undefined;
53
54
  const next = input.trim().length === 0
54
55
  ? clearPreference(prefs, key)
55
56
  : (() => {
56
- const r = setPreference(prefs, key, input.trim());
57
- if (!r.ok) return undefined;
57
+ const r = setPreference(prefs, key, input.trim(), ctx.modelRegistry);
58
+ if (!r.ok) {
59
+ err = r.error;
60
+ return undefined;
61
+ }
58
62
  return r.prefs;
59
63
  })();
60
64
  if (next === undefined) {
61
- ctx.ui.notify(`invalid value for ${key}`, "error");
65
+ ctx.ui.notify(err ?? `invalid value for ${key}`, "error");
62
66
  continue;
63
67
  }
64
68
  await savePreferences(next);
@@ -87,26 +91,36 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
87
91
  ctx.ui.notify(`fleet canvas: ${url}`, "info");
88
92
  return;
89
93
  }
90
- const active = activeFleet.current;
94
+ if (cmd === "models") {
95
+ ctx.ui.notify(`available models:\n${listModelRefs(ctx.modelRegistry).join("\n")}`, "info");
96
+ return;
97
+ }
98
+ const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
99
+ if (active) activeFleet.current ??= active;
91
100
  if (!active) {
92
101
  ctx.ui.notify("no fleet planned yet", "warning");
93
102
  return;
94
103
  }
95
104
  if (cmd === "viz") {
105
+ active.widgetVisible = true;
96
106
  const lines = renderDag(active.spec, active.state).split("\n");
97
107
  ctx.ui.setWidget("fleet", lines);
108
+ ctx.ui.notify("fleet widget visible; fleet canvas link: " + (await ensureCanvas(ctx)).url, "info");
98
109
  return;
99
110
  }
100
111
  if (cmd === "status" || cmd === "") {
101
- ctx.ui.setWidget("fleet", buildWidgetLines(active.spec, active.state));
112
+ const server = await ensureCanvas(ctx);
113
+ ctx.ui.notify(`${await statusText(active)}\n\nfleet canvas: ${server.url}`, "info");
102
114
  return;
103
115
  }
104
116
  if (cmd === "clear") {
117
+ active.widgetVisible = false;
105
118
  ctx.ui.setWidget("fleet", []);
119
+ ctx.ui.notify("fleet widget hidden", "info");
106
120
  return;
107
121
  }
108
122
  if (cmd === "kill") {
109
- const text = await killFleet(target ?? "");
123
+ const text = await killFleet(target ?? "", ctx.cwd);
110
124
  const severity = text.includes("kill") && !text.startsWith("unknown") && !text.includes("already") ? "warning" : "error";
111
125
  ctx.ui.notify(text, severity);
112
126
  return;
@@ -141,6 +155,33 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
141
155
  ctx.ui.notify("fleet resumed", "info");
142
156
  return;
143
157
  }
158
+ if (cmd === "continue") {
159
+ if (active.running) {
160
+ ctx.ui.notify("fleet already running", "warning");
161
+ return;
162
+ }
163
+ await currentState(active);
164
+ if (active.state.status === "completed") {
165
+ ctx.ui.notify("fleet completed, nothing to continue", "warning");
166
+ return;
167
+ }
168
+ if (active.state.status === "paused") {
169
+ ctx.ui.notify("fleet is paused; use /fleet resume for paused loop fleets", "warning");
170
+ return;
171
+ }
172
+ if (active.state.status === "planned" && Object.values(active.state.nodes).every((n) => n.status === "pending")) {
173
+ ctx.ui.notify("fleet has not started; use /fleet launch", "warning");
174
+ return;
175
+ }
176
+ active.killSwitch.killed = false;
177
+ active.pauseSwitch.paused = false;
178
+ active.state = { ...active.state, status: "running", paused: false };
179
+ await writeState(active.fleetRoot, active.state);
180
+ await writeWorkerPrompts(active);
181
+ void startLoop(active, ctx, false, true);
182
+ ctx.ui.notify("fleet continue requested", "info");
183
+ return;
184
+ }
144
185
  if (cmd === "relaunch") {
145
186
  if (!target) {
146
187
  ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
@@ -175,10 +216,11 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
175
216
  }
176
217
  const canonical = `${resolved.model.provider}/${resolved.model.id}`;
177
218
  active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
178
- await writeFile(join(active.fleetRoot, "fleet.json"), `${JSON.stringify(active.spec, null, 2)}\n`, "utf-8");
219
+ await persistFleetJson(active);
179
220
  }
180
221
  active.state = resetForRelaunch(active.state, active.spec, target);
181
222
  await writeState(active.fleetRoot, active.state);
223
+ await writeWorkerPrompts(active);
182
224
  prepareRelaunch(active, target);
183
225
  void startLoop(active, ctx, false, true);
184
226
  ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
@@ -241,7 +283,7 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
241
283
  if (r.ok) updateWidget(ctx, active);
242
284
  return;
243
285
  }
244
- ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet relaunch <node_id> [model]", "warning");
286
+ ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet models | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet continue | /fleet relaunch <node_id> [model]", "warning");
245
287
  },
246
288
  });
247
289
  }
package/src/contracts.ts CHANGED
@@ -4,53 +4,91 @@ import type { ContractCheck, ContractOutput, ContractResult, Verdict } from "./t
4
4
 
5
5
  export const VERDICT_RE = /^verdict:\s*(lgtm|iterate|escalate)\s*$/mi;
6
6
 
7
+ // Leading metadata lines that a markdown file may place before its first heading.
8
+ const MARKDOWN_METADATA_RE = /^(status|verdict):\s*(approved|needs-revision|escalate|lgtm|iterate)\s*$/i;
9
+
7
10
  function resolvePath(workerDir: string, repoCwd: string, p: string): string {
8
11
  if (isAbsolute(p)) return p;
9
12
  return p.startsWith("output/") ? join(workerDir, p) : join(repoCwd, p);
10
13
  }
11
14
 
15
+ function firstLines(content: string, n = 5): string {
16
+ return content
17
+ .split("\n")
18
+ .slice(0, n)
19
+ .map((l) => l.trimEnd())
20
+ .join(" / ");
21
+ }
22
+
12
23
  async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
13
24
  const full = resolvePath(workerDir, repoCwd, o.path);
14
- const fail = (error: string): ContractCheck => ({ path: o.path, kind: o.kind, required: o.required, ok: false, error });
25
+ const base: Omit<ContractCheck, "ok" | "error"> = { path: o.path, kind: o.kind, required: o.required, actualPath: full };
26
+ const fail = (error: string): ContractCheck => ({ ...base, ok: false, error });
15
27
  let content: string;
16
28
  try {
17
29
  const s = await stat(full);
18
30
  if (o.kind === "file-exists") {
19
31
  return s.size > 0
20
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
32
+ ? { ...base, ok: true }
21
33
  : fail("empty file");
22
34
  }
23
35
  content = await readFile(full, "utf-8");
24
36
  } catch {
25
- return fail("file not found");
37
+ return { path: o.path, kind: o.kind, required: o.required, ok: false, error: "file not found" };
26
38
  }
39
+
27
40
  switch (o.kind) {
28
41
  case "markdown": {
29
- const first = content.split("\n").find((l) => l.trim().length > 0) ?? "";
30
- return first.startsWith("#")
31
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
32
- : fail("no markdown heading");
42
+ const meaningful = content.split("\n").filter((l) => l.trim().length > 0);
43
+ const first10 = meaningful.slice(0, 10);
44
+ const heading = first10.find((l) => l.trim().startsWith("#"));
45
+ if (heading) {
46
+ return { ...base, ok: true };
47
+ }
48
+ // Check whether the only blocker was a leading metadata line like Status: ...
49
+ const metadataFirst = first10[0]?.match(MARKDOWN_METADATA_RE);
50
+ return { ...fail(metadataFirst ? "no markdown heading after leading metadata line" : "no markdown heading"), firstLines: firstLines(content) };
33
51
  }
34
52
  case "verdict": {
35
53
  const m = content.match(VERDICT_RE);
36
- if (!m) return fail("no verdict line");
54
+ if (!m) return { ...base, ok: false, error: "no verdict line", firstLines: firstLines(content) };
37
55
  const body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
38
56
  return body.length > 0
39
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
40
- : fail("verdict line without body");
57
+ ? { ...base, ok: true }
58
+ : { ...base, ok: false, error: "verdict line without body", firstLines: firstLines(content) };
41
59
  }
42
- case "json":
60
+ case "json": {
61
+ let parsed: unknown;
43
62
  try {
44
- JSON.parse(content);
45
- return { path: o.path, kind: o.kind, required: o.required, ok: true };
63
+ parsed = JSON.parse(content);
46
64
  } catch (e) {
47
- return fail(`json parse error: ${(e as Error).message}`);
65
+ return { ...base, ok: false, error: `json parse error: ${(e as Error).message}`, firstLines: firstLines(content) };
48
66
  }
67
+ if (o.schema) {
68
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
69
+ return { ...base, ok: false, error: "json schema requires an object", firstLines: firstLines(content) };
70
+ }
71
+ const obj = parsed as Record<string, unknown>;
72
+ for (const key of o.schema.required_keys ?? []) {
73
+ if (!Object.hasOwn(obj, key)) {
74
+ return { ...base, ok: false, error: `missing required key "${key}"`, firstLines: firstLines(content) };
75
+ }
76
+ }
77
+ for (const key of o.schema.number_keys ?? []) {
78
+ const v = obj[key];
79
+ const ok = typeof v === "number" || (Array.isArray(v) && v.every((x) => typeof x === "number"));
80
+ if (!ok) {
81
+ return { ...base, ok: false, error: `key "${key}" must be a number or number[]`, firstLines: firstLines(content) };
82
+ }
83
+ }
84
+ }
85
+ return { ...base, ok: true };
86
+ }
49
87
  case "yaml": {
50
88
  const bad = content.split("\n").some((l) => l.includes("\t"));
51
89
  return content.trim().length > 0 && !bad
52
- ? { path: o.path, kind: o.kind, required: o.required, ok: true }
53
- : fail("empty or invalid yaml");
90
+ ? { ...base, ok: true }
91
+ : { ...base, ok: false, error: "empty or invalid yaml" };
54
92
  }
55
93
  default:
56
94
  return fail(`unknown kind`);
@@ -76,3 +114,17 @@ export async function verifyOutputs(opts: {
76
114
  const verdict_body = content.slice(content.indexOf(m[0]) + m[0].length).trim();
77
115
  return verdict_body.length > 0 ? { ok, checks, verdict, verdict_body } : { ok, checks };
78
116
  }
117
+
118
+ /** Format a concise, actionable note for the first failed required contract. */
119
+ export function contractFailureNote(checks: ContractCheck[]): string {
120
+ const failed = checks.find((c) => c.required && !c.ok);
121
+ if (!failed) return "contract failed";
122
+ const parts = [
123
+ `${failed.path}: ${failed.error}`,
124
+ `actual: ${failed.actualPath ?? "(not found)"}`,
125
+ ];
126
+ if (failed.firstLines) {
127
+ parts.push(`first lines: ${failed.firstLines}`);
128
+ }
129
+ return parts.join("; ");
130
+ }
package/src/controller.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Api, Model } from "@earendil-works/pi-ai";
5
5
  import { writeWorkerPrompts } from "./fleet-store.js";
6
+ import { recoverLatestFleet } from "./fleet-recovery.js";
6
7
  import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
7
8
  import { insertWorkers } from "./insert.js";
8
9
  import { writeReport } from "./report.js";
@@ -25,6 +26,7 @@ export interface ActiveFleet {
25
26
  costWarned?: boolean;
26
27
  sessions: Map<string, AgentSessionLike>;
27
28
  killedNodes: Set<string>;
29
+ widgetVisible?: boolean;
28
30
  }
29
31
 
30
32
  export interface ActiveFleetCell {
@@ -34,7 +36,7 @@ export interface ActiveFleetCell {
34
36
  export const activeFleet: ActiveFleetCell = { current: undefined };
35
37
 
36
38
  export function updateWidget(ctx: ExtensionContext, fleet: ActiveFleet, spinnerFrame?: number): void {
37
- if (ctx.hasUI) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
39
+ if (ctx.hasUI && fleet.widgetVisible) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
38
40
  }
39
41
 
40
42
  export function startSpinner(ctx: ExtensionContext, fleet: ActiveFleet, intervalMs = 150): () => void {
@@ -60,7 +62,14 @@ export async function currentState(fleet: ActiveFleet): Promise<FleetState> {
60
62
 
61
63
  export async function statusText(fleet: ActiveFleet): Promise<string> {
62
64
  const state = await currentState(fleet);
63
- return renderDag(fleet.spec, state);
65
+ const reportPath = join(fleet.fleetRoot, "report.md");
66
+ const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
67
+ const next = state.status === "planned" ? "next: fleet_launch"
68
+ : state.status === "running" ? "next: fleet_status, fleet_canvas, or fleet_kill <id>|all"
69
+ : failed ? `next: fleet_relaunch ${failed[0]}`
70
+ : state.status === "completed" ? `next: read report ${reportPath}`
71
+ : `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
72
+ return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}`;
64
73
  }
65
74
 
66
75
  export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
@@ -166,12 +175,15 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
166
175
  const prompt = await readFile(join(fleet.fleetRoot, "workers", nodeId, "prompt.md"), "utf-8");
167
176
  const sessionDir = join(fleet.fleetRoot, "workers", nodeId);
168
177
  const effort = worker.effort ?? fleet.spec.config.effort ?? "medium";
178
+ const worktreeCwd = worker?.worktree || worker?.id === "fleet-integrator"
179
+ ? join(fleet.fleetRoot, "worktrees", nodeId)
180
+ : ctx.cwd;
169
181
  try {
170
182
  return await runWorker({
171
183
  nodeId,
172
184
  worker: workerWithResolvedModel(worker, resolvedModel),
173
185
  prompt,
174
- repoCwd: ctx.cwd,
186
+ repoCwd: worktreeCwd,
175
187
  sessionDir,
176
188
  thinkingLevel: effort,
177
189
  sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
@@ -200,9 +212,12 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
200
212
  const state = await runFleet({
201
213
  spec: fleet.spec,
202
214
  fleetRoot: fleet.fleetRoot,
215
+ baseRepo: ctx.cwd,
203
216
  repoCwd: (nodeId) => {
204
217
  const worker = fleet.spec.workers.find((w) => w.id === nodeId);
205
- return worker?.worktree ? join(fleet.fleetRoot, "worktrees", nodeId) : ctx.cwd;
218
+ return worker?.worktree || worker?.id === "fleet-integrator"
219
+ ? join(fleet.fleetRoot, "worktrees", nodeId)
220
+ : ctx.cwd;
206
221
  },
207
222
  spawn,
208
223
  killSwitch: fleet.killSwitch,
@@ -257,9 +272,21 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
257
272
  }
258
273
  }
259
274
 
260
- export async function killFleet(target: string): Promise<string> {
261
- const active = activeFleet.current;
275
+ export async function killFleet(target: string, cwd?: string): Promise<string> {
276
+ const active = activeFleet.current ?? (cwd ? await recoverLatestFleet(cwd) : undefined);
277
+ if (active) activeFleet.current ??= active;
262
278
  if (!active) return "no fleet planned yet";
279
+ if (!active.running) {
280
+ try {
281
+ active.state = await readState(active.fleetRoot);
282
+ } catch {
283
+ // keep in-memory state
284
+ }
285
+ if (active.state.status === "running") {
286
+ const where = target === "all" ? "fleet" : `node "${target}"`;
287
+ return `${where} not killed: fleet state on disk is "running" — it appears to be running in another live session; kill it there (this session holds no live scheduler, so a kill here would silently no-op)`;
288
+ }
289
+ }
263
290
  if (target === "all") {
264
291
  active.killSwitch.killed = true;
265
292
  return "fleet kill requested";
package/src/dag.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FleetSpec, GateKind, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
1
+ import type { ContractOutput, FleetSpec, GateKind, JsonOutputSchema, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
2
2
  import { THINKING_LEVELS } from "./types.js";
3
3
 
4
4
  export class CycleError extends Error {
@@ -49,6 +49,46 @@ export function getDependents(spec: FleetSpec, nodeId: string): string[] {
49
49
  return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
50
50
  }
51
51
 
52
+ export function findRepoOutputOwnershipConflicts(workers: WorkerSpec[]): string[] {
53
+ const claims = new Map<string, string[]>();
54
+ for (const w of workers) {
55
+ for (const o of w.outputs) {
56
+ if (!o.path.startsWith("output/")) {
57
+ const list = claims.get(o.path) ?? [];
58
+ list.push(w.id);
59
+ claims.set(o.path, list);
60
+ }
61
+ }
62
+ }
63
+ const errors: string[] = [];
64
+ const byId = new Map(workers.map((w) => [w.id, w]));
65
+ for (const [path, ids] of claims) {
66
+ if (ids.length < 2) continue;
67
+ for (let i = 0; i < ids.length; i++) {
68
+ for (let j = i + 1; j < ids.length; j++) {
69
+ const a = ids[i], b = ids[j];
70
+ const aBeforeB = byId.get(a)!.depends_on.includes(b);
71
+ const bBeforeA = byId.get(b)!.depends_on.includes(a);
72
+ if (!aBeforeB && !bBeforeA) {
73
+ errors.push(`repo output ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return errors;
79
+ }
80
+
81
+ function hasIntegratorPath(workers: WorkerSpec[]): boolean {
82
+ const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
83
+ if (worktreeIds.size < 2) return true;
84
+ return workers.some((w) => {
85
+ if (w.worktree) return false;
86
+ const deps = new Set(w.depends_on);
87
+ for (const id of worktreeIds) if (!deps.has(id)) return false;
88
+ return true;
89
+ });
90
+ }
91
+
52
92
  export function validateFleetSpec(
53
93
  raw: unknown,
54
94
  ): { ok: true; spec: FleetSpec; layers: string[][] } | { ok: false; errors: string[] } {
@@ -91,6 +131,21 @@ export function validateFleetSpec(
91
131
  errors.push(`worker "${id}": output path must be relative and stay within the repo`);
92
132
  }
93
133
  }
134
+ if (o.schema !== undefined) {
135
+ if (o.kind !== "json") {
136
+ errors.push(`worker "${id}": output schema only allowed with kind "json"`);
137
+ } else if (typeof o.schema !== "object" || o.schema === null || Array.isArray(o.schema)) {
138
+ errors.push(`worker "${id}": output schema must be an object`);
139
+ } else {
140
+ const s = o.schema as Record<string, unknown>;
141
+ for (const name of ["required_keys", "number_keys"] as const) {
142
+ const keys = s[name];
143
+ if (keys !== undefined && (!Array.isArray(keys) || keys.some((k) => typeof k !== "string" || k.length === 0))) {
144
+ errors.push(`worker "${id}": schema.${name} must be an array of non-empty strings`);
145
+ }
146
+ }
147
+ }
148
+ }
94
149
  }
95
150
  const wEffort = typeof w.effort === "string" ? w.effort as ThinkingLevelName : undefined;
96
151
  if (wEffort !== undefined && !THINKING_LEVELS.includes(wEffort)) {
@@ -103,7 +158,18 @@ export function validateFleetSpec(
103
158
  model: typeof w.model === "string" ? w.model : undefined,
104
159
  effort: wEffort,
105
160
  depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
106
- outputs: outputs.map((o) => ({ path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false })),
161
+ outputs: outputs.map((o) => {
162
+ const out: ContractOutput = { path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false };
163
+ const s = o.schema;
164
+ if (o.kind === "json" && typeof s === "object" && s !== null && !Array.isArray(s)) {
165
+ const rec = s as Record<string, unknown>;
166
+ const schema: JsonOutputSchema = {};
167
+ if (Array.isArray(rec.required_keys)) schema.required_keys = rec.required_keys as string[];
168
+ if (Array.isArray(rec.number_keys)) schema.number_keys = rec.number_keys as string[];
169
+ out.schema = schema;
170
+ }
171
+ return out;
172
+ }),
107
173
  iterate: w.iterate !== false,
108
174
  worktree: w.worktree === true,
109
175
  });
@@ -115,6 +181,23 @@ export function validateFleetSpec(
115
181
  }
116
182
  }
117
183
 
184
+ const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
185
+ if (worktreeIds.size >= 2) {
186
+ if (!workers.some((w) => w.id === "fleet-integrator")) {
187
+ workers.push({
188
+ id: "fleet-integrator",
189
+ type: "code-run",
190
+ task: `Merge worktree branches in order: ${[...worktreeIds].join(", ")}. Verify the combined repo is consistent and commit if needed.`,
191
+ depends_on: [...worktreeIds],
192
+ outputs: [],
193
+ });
194
+ }
195
+ if (!hasIntegratorPath(workers)) {
196
+ errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
197
+ }
198
+ }
199
+ errors.push(...findRepoOutputOwnershipConflicts(workers));
200
+
118
201
  let loopConfig: LoopConfig | undefined;
119
202
  if (cfg.loop !== undefined && cfg.loop !== null) {
120
203
  if (typeof cfg.loop !== "object" || Array.isArray(cfg.loop)) {
package/src/edits.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { rename, stat, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ActiveFleet } from "./controller.js";
4
+ import { persistFleetJson } from "./fleet-store.js";
4
5
  import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
5
6
  import { buildWorkerPrompt } from "./prompts.js";
7
+ import type { NodeStatus, ThinkingLevelName } from "./types.js";
6
8
  import { THINKING_LEVELS } from "./types.js";
7
- import type { ThinkingLevelName } from "./types.js";
8
9
 
9
10
  export type NodeEditKey = "model" | "effort" | "task";
10
11
  export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "model" | "effort";
@@ -14,9 +15,7 @@ export interface EditResult {
14
15
  message: string;
15
16
  }
16
17
 
17
- async function persistSpec(fleet: ActiveFleet): Promise<void> {
18
- await writeFile(join(fleet.fleetRoot, "fleet.json"), `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
19
- }
18
+ const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed"]);
20
19
 
21
20
  export async function editNode(
22
21
  fleet: ActiveFleet,
@@ -28,9 +27,9 @@ export async function editNode(
28
27
  const worker = fleet.spec.workers.find((w) => w.id === nodeId);
29
28
  const node = fleet.state.nodes[nodeId];
30
29
  if (!worker || !node) return { ok: false, message: `unknown node "${nodeId}"` };
31
- if (node.status !== "pending" && node.status !== "ready") {
32
- return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending nodes can be edited` };
33
- }
30
+ if (!EDITABLE_NODE_STATUSES.has(node.status)) {
31
+ return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, failed, contract_failed, or killed nodes can be edited` };
32
+ }
34
33
  switch (key) {
35
34
  case "model": {
36
35
  const r = resolveModelReference(registry, value);
@@ -63,7 +62,7 @@ export async function editNode(
63
62
  default:
64
63
  return { ok: false, message: `unknown node edit key "${String(key)}" (keys: model, effort, task)` };
65
64
  }
66
- await persistSpec(fleet);
65
+ await persistFleetJson(fleet);
67
66
  return { ok: true, message: `node "${nodeId}" ${key} updated` };
68
67
  }
69
68
 
@@ -102,6 +101,6 @@ export async function editConfig(
102
101
  default:
103
102
  return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, model, effort)` };
104
103
  }
105
- await persistSpec(fleet);
104
+ await persistFleetJson(fleet);
106
105
  return { ok: true, message: `config.${key} updated` };
107
106
  }
@@ -0,0 +1,73 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ActiveFleet } from "./controller.js";
4
+ import { readState } from "./state.js";
5
+ import type { FleetSpec, FleetState } from "./types.js";
6
+
7
+ export interface FleetRootInfo {
8
+ name: string;
9
+ root: string;
10
+ status: string;
11
+ created_at: string;
12
+ }
13
+
14
+ export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
15
+ const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
16
+ const state = await readState(fleetRoot);
17
+ return {
18
+ spec,
19
+ fleetRoot,
20
+ state,
21
+ killSwitch: { killed: false },
22
+ pauseSwitch: { paused: false },
23
+ running: false,
24
+ sessions: new Map(),
25
+ killedNodes: new Set(),
26
+ };
27
+ }
28
+
29
+ export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
30
+ const base = join(cwd, ".fleet");
31
+ let entries: string[];
32
+ try {
33
+ entries = await readdir(base);
34
+ } catch {
35
+ return [];
36
+ }
37
+ const out: FleetRootInfo[] = [];
38
+ for (const name of entries) {
39
+ const root = join(base, name);
40
+ try {
41
+ const s = await stat(join(root, "fleet.json"));
42
+ if (!s.isFile()) continue;
43
+ const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
44
+ out.push({
45
+ name,
46
+ root,
47
+ status: typeof state.status === "string" ? state.status : "unknown",
48
+ created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
49
+ });
50
+ } catch {
51
+ // not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
52
+ try {
53
+ await stat(join(root, "fleet.json"));
54
+ out.push({ name, root, status: "unknown", created_at: "" });
55
+ } catch {
56
+ // not a fleet root
57
+ }
58
+ }
59
+ }
60
+ out.sort((a, b) => b.created_at.localeCompare(a.created_at));
61
+ return out;
62
+ }
63
+
64
+ export async function recoverLatestFleet(cwd: string): Promise<ActiveFleet | undefined> {
65
+ const roots = await listFleetRoots(cwd);
66
+ const latest = roots.find((r) => r.status !== "unknown") ?? roots[0];
67
+ if (!latest) return undefined;
68
+ try {
69
+ return await readDiskFleet(latest.root);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { buildWorkerPrompt } from "./prompts.js";
4
4
  import { writeState } from "./state.js";
@@ -47,9 +47,18 @@ export async function writePlanFiles(fleetRoot: string, spec: FleetSpec, state:
47
47
  await writeState(fleetRoot, state);
48
48
  }
49
49
 
50
+ export async function persistFleetJson(fleet: { fleetRoot: string; spec: FleetSpec }): Promise<void> {
51
+ const path = join(fleet.fleetRoot, "fleet.json");
52
+ const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
53
+ await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
54
+ await rename(tmp, path);
55
+ }
56
+
50
57
  export async function writeWorkerPrompts(fleet: { spec: FleetSpec; state: FleetState; fleetRoot: string }): Promise<void> {
51
58
  await Promise.all(fleet.spec.workers.map(async (w) => {
59
+ const dir = join(fleet.fleetRoot, "workers", w.id);
60
+ await mkdir(dir, { recursive: true });
52
61
  const prompt = buildWorkerPrompt({ spec: fleet.spec, state: fleet.state, workerId: w.id, fleetRoot: fleet.fleetRoot });
53
- await writeFile(join(fleet.fleetRoot, "workers", w.id, "prompt.md"), prompt, "utf-8");
62
+ await writeFile(join(dir, "prompt.md"), prompt, "utf-8");
54
63
  }));
55
64
  }
package/src/insert.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { mkdir, rename, writeFile } from "node:fs/promises";
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ActiveFleet } from "./controller.js";
4
4
  import { validateFleetSpec } from "./dag.js";
5
+ import { persistFleetJson } from "./fleet-store.js";
5
6
  import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
6
7
  import { buildWorkerPrompt } from "./prompts.js";
7
8
  import { writeState } from "./state.js";
@@ -12,13 +13,6 @@ export interface InsertResult {
12
13
  inserted?: string[];
13
14
  }
14
15
 
15
- async function persistFleetJson(fleet: ActiveFleet): Promise<void> {
16
- const path = join(fleet.fleetRoot, "fleet.json");
17
- const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
18
- await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
19
- await rename(tmp, path);
20
- }
21
-
22
16
  async function insertWorkersSerialized(
23
17
  fleet: ActiveFleet,
24
18
  raw: unknown,