@getpipher/armory-fleet 0.11.0 → 0.12.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/engine/concurrency-lock.ts +20 -15
  3. package/src/engine/spawnSubagent.ts +8 -2
  4. package/src/index.ts +112 -8
  5. package/src/panel/fleet-panel.ts +165 -11
  6. package/src/runtime/async-runner.ts +79 -25
  7. package/src/runtime/reconcile.ts +12 -9
  8. package/src/runtime/resume.ts +21 -15
  9. package/src/runtime/run-journal.ts +2 -2
  10. package/src/scheduling/scheduler.ts +4 -0
  11. package/src/tools/fleet.ts +179 -0
  12. package/src/tools/subagent.ts +8 -2
  13. package/src/workflows/builtin/adversarial-review.js +19 -0
  14. package/src/workflows/builtin/code-review.js +13 -0
  15. package/src/workflows/builtin/codebase-audit.js +16 -0
  16. package/src/workflows/builtin/deep-research.js +12 -0
  17. package/src/workflows/builtin/multi-perspective.js +17 -0
  18. package/src/workflows/helpers/checkpoint.ts +15 -0
  19. package/src/workflows/helpers/completeness-check.ts +18 -0
  20. package/src/workflows/helpers/gate.ts +22 -0
  21. package/src/workflows/helpers/index.ts +8 -0
  22. package/src/workflows/helpers/judge-panel.ts +33 -0
  23. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  24. package/src/workflows/helpers/retry.ts +17 -0
  25. package/src/workflows/helpers/types.ts +19 -0
  26. package/src/workflows/helpers/verify.ts +27 -0
  27. package/src/workflows/journal.ts +76 -0
  28. package/src/workflows/keyword.ts +22 -0
  29. package/src/workflows/panel/workflows-items.ts +150 -0
  30. package/src/workflows/panel/workflows-rows.ts +3 -0
  31. package/src/workflows/panel-host.ts +179 -0
  32. package/src/workflows/registry.ts +68 -0
  33. package/src/workflows/runner.ts +507 -0
  34. package/src/workflows/runtime/adapters.ts +182 -0
  35. package/src/workflows/runtime/controller.ts +493 -0
  36. package/src/workflows/runtime/hydrate.ts +116 -0
  37. package/src/workflows/runtime/pause-gate.ts +41 -0
  38. package/src/workflows/runtime/run-store.ts +31 -0
  39. package/src/workflows/runtime/save.ts +111 -0
  40. package/src/workflows/runtime/types.ts +78 -0
  41. package/src/workflows/source.ts +156 -0
  42. package/src/workflows/vm-realm.ts +106 -0
  43. package/src/worktree/worktree-service.ts +10 -0
@@ -0,0 +1,33 @@
1
+ import type { HelperCtx } from "./types.ts";
2
+
3
+ interface Judgment { score: number; reason?: string }
4
+ interface PanelResult { index: number; attempt: unknown; score: number; judgments: Judgment[] }
5
+
6
+ /** SPEC-6-3 §3.3 — N judges score every attempt; highest average score wins. Closes 6-2.1. */
7
+ export async function judgePanel(
8
+ attempts: unknown[],
9
+ opts: { judges?: number; rubric?: string; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
10
+ ctx: HelperCtx,
11
+ ): Promise<PanelResult | undefined> {
12
+ if (attempts.length === 0) return undefined;
13
+ const judges = opts.judges ?? 3;
14
+ const rubric = opts.rubric ?? "overall quality and correctness";
15
+ const perAttempt: Judgment[][] = attempts.map(() => []);
16
+ for (let j = 0; j < judges; j++) {
17
+ for (let a = 0; a < attempts.length; a++) {
18
+ const prompt = `You are judge ${j + 1} of ${judges}. Score this attempt on a 0-10 scale.\nRubric: ${rubric}\nAttempt ${a}: ${JSON.stringify(attempts[a])}\nRespond as JSON: {"score": number, "reason": string}`;
19
+ const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
20
+ if (res && res.status === "completed") {
21
+ try { const p = JSON.parse(res.finalText) as { score?: number; reason?: string }; perAttempt[a]!.push({ score: typeof p.score === "number" ? p.score : 0, ...(p.reason ? { reason: p.reason } : {}) }); }
22
+ catch { perAttempt[a]!.push({ score: 0 }); }
23
+ } else { perAttempt[a]!.push({ score: 0 }); }
24
+ }
25
+ }
26
+ let best: PanelResult | undefined;
27
+ for (let a = 0; a < attempts.length; a++) {
28
+ const js = perAttempt[a]!;
29
+ const score = js.reduce((s, x) => s + x.score, 0) / (js.length || 1);
30
+ if (!best || score > best.score) best = { index: a, attempt: attempts[a], score, judgments: js };
31
+ }
32
+ return best;
33
+ }
@@ -0,0 +1,21 @@
1
+ import type { HelperCtx } from "./types.ts";
2
+
3
+ /** SPEC-6-3 §3.3 — discovery loop: call round(n) → items; de-dupe by key; stop after consecutiveEmpty empty rounds or maxRounds. */
4
+ export async function loopUntilDry(
5
+ opts: { round: (roundIndex: number) => unknown[] | Promise<unknown[]>; key?: (item: unknown) => string; consecutiveEmpty?: number; maxRounds?: number },
6
+ ctx: HelperCtx,
7
+ ): Promise<unknown[]> {
8
+ const consecutiveEmpty = opts.consecutiveEmpty ?? 2;
9
+ const maxRounds = opts.maxRounds ?? 50;
10
+ const key = opts.key ?? ((item: unknown) => JSON.stringify(item));
11
+ const seen = new Set<string>();
12
+ const acc: unknown[] = [];
13
+ let emptyStreak = 0;
14
+ for (let n = 0; n < maxRounds; n++) {
15
+ const items = await opts.round(n);
16
+ const fresh = (Array.isArray(items) ? items : []).filter((it) => { const k = key(it); if (seen.has(k)) return false; seen.add(k); return true; });
17
+ for (const f of fresh) acc.push(f);
18
+ if (fresh.length === 0) { emptyStreak++; if (emptyStreak >= consecutiveEmpty) break; } else emptyStreak = 0;
19
+ }
20
+ return acc;
21
+ }
@@ -0,0 +1,17 @@
1
+ import type { HelperCtx } from "./types.ts";
2
+
3
+ /** SPEC-6-3 §3.3 — retry a thunk up to `attempts`; `until` decides when to stop. */
4
+ export async function retry(
5
+ thunk: (attempt: number) => unknown | Promise<unknown>,
6
+ opts: { attempts?: number; until?: (result: unknown) => boolean } = {},
7
+ ctx: HelperCtx,
8
+ ): Promise<unknown> {
9
+ const attempts = opts.attempts ?? 3;
10
+ const until = opts.until ?? (() => true);
11
+ let last: unknown;
12
+ for (let n = 0; n < attempts; n++) {
13
+ try { last = await thunk(n); } catch { continue; } // recoverable: try again
14
+ if (until(last)) return last;
15
+ }
16
+ return last;
17
+ }
@@ -0,0 +1,19 @@
1
+ import type { WorkflowJournal } from "../journal.ts";
2
+
3
+ export interface HelperSpawnResult {
4
+ finalText: string;
5
+ runId: string;
6
+ status: "completed" | "failed";
7
+ costTotal?: number;
8
+ tokenTotal?: number;
9
+ }
10
+
11
+ export interface HelperCtx {
12
+ spawn: (prompt: string, opts?: { agent?: string; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number }) => Promise<HelperSpawnResult | null>;
13
+ journal: WorkflowJournal;
14
+ runId: string;
15
+ budget?: { spent: () => number; remaining: () => number };
16
+ onCheckpoint?: (prompt: string, opts: Record<string, unknown>) => Promise<unknown>;
17
+ getModelContextWindow?: (model: string) => number | undefined;
18
+ nextCallIndex: () => number;
19
+ }
@@ -0,0 +1,27 @@
1
+ import type { HelperCtx } from "./types.ts";
2
+
3
+ const REAL_RE = /\b(real|valid|correct|true|confirmed|legit)\b/i;
4
+
5
+ function judgeVote(text: string): { real: boolean; reason?: string } {
6
+ return { real: REAL_RE.test(text), reason: text.slice(0, 200) };
7
+ }
8
+
9
+ /** SPEC-6-3 §3.3 — N reviewers vote; real = realCount/total >= threshold (default 0.5). */
10
+ export async function verify(
11
+ item: unknown,
12
+ opts: { reviewers?: number; threshold?: number; lens?: string | string[]; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
13
+ ctx: HelperCtx,
14
+ ): Promise<{ real: boolean; realCount: number; total: number; votes: Array<{ real: boolean; reason?: string }> }> {
15
+ const reviewers = opts.reviewers ?? 2;
16
+ const threshold = opts.threshold ?? 0.5;
17
+ const lens = opts.lens ? ` Focus lens: ${Array.isArray(opts.lens) ? opts.lens.join(", ") : opts.lens}.` : "";
18
+ const prompt = `You are an independent reviewer. Decide if the following item is REAL/valid.\nItem: ${JSON.stringify(item)}${lens}\nRespond with "real" or "fake" + a one-line reason.`;
19
+ const votes: Array<{ real: boolean; reason?: string }> = [];
20
+ for (let i = 0; i < reviewers; i++) {
21
+ const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
22
+ if (!res || res.status !== "completed") { votes.push({ real: false, reason: "reviewer failed" }); continue; }
23
+ votes.push(judgeVote(res.finalText));
24
+ }
25
+ const realCount = votes.filter((v) => v.real).length;
26
+ return { real: realCount / reviewers >= threshold, realCount, total: reviewers, votes };
27
+ }
@@ -0,0 +1,76 @@
1
+ // SPEC-6-3 — the per-workflow positional-call-index journal.
2
+ // Append-only JSONL at <dir>/<runId>.jsonl. Crash-safe: a partial last line is discarded.
3
+ // Separate from RunLog (per-agent conversations/) and RunJournal (per-lifecycle runs/).
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export interface WorkflowStartedEvent { type: "wf:started"; runId: string; script: string; args?: unknown; phases?: { title: string }[]; mode: "auto" | "checkpointed"; ts: number; }
8
+ export interface AgentCallEvent { type: "agent:call"; callIndex: number; label: string; phase: string; prompt: string; opts: Record<string, unknown>; childRunId?: string; ts: number; }
9
+ export interface AgentResultEvent { type: "agent:result"; callIndex: number; childRunId: string; result: unknown; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number; ts: number; }
10
+ export interface HelperCallEvent { type: "helper:call"; callIndex: number; name: string; args: unknown; ts: number; }
11
+ export interface HelperResultEvent { type: "helper:result"; callIndex: number; name: string; result: unknown; ts: number; }
12
+ export interface CheckpointEvent { type: "checkpoint"; callIndex: number; prompt: string; response: unknown; ts: number; }
13
+ export interface WorkflowCompletedEvent { type: "wf:completed"; runId: string; result: unknown; costTotal?: number; tokenTotal?: number; ts: number; }
14
+ export interface WorkflowAbortedEvent { type: "wf:aborted"; runId: string; reason: string; ts: number; }
15
+
16
+ export interface WorkflowProgressJournalEvent {
17
+ type: "wf:progress";
18
+ kind: "started" | "phase" | "child-started" | "child-completed" | "child-failed" | "helper-started" | "helper-completed" | "log" | "checkpoint" | "checkpoint-resolved" | "completed" | "failed" | "aborted";
19
+ runId: string;
20
+ status: string;
21
+ currentPhase: string;
22
+ phases: Array<{ title: string; agents: number; cached: number; reRun: number }>;
23
+ childRunIds: string[];
24
+ logs: string[];
25
+ tokenTotal: number;
26
+ costTotal: number;
27
+ checkpoint?: { prompt: string; opts: Record<string, unknown> };
28
+ ts: number;
29
+ }
30
+
31
+ export type WorkflowJournalEvent =
32
+ | WorkflowStartedEvent | AgentCallEvent | AgentResultEvent | HelperCallEvent
33
+ | HelperResultEvent | CheckpointEvent | WorkflowCompletedEvent | WorkflowAbortedEvent
34
+ | WorkflowProgressJournalEvent;
35
+
36
+ const WORKFLOW_TERMINAL = new Set<WorkflowJournalEvent["type"]>(["wf:completed", "wf:aborted"]);
37
+
38
+ export class WorkflowJournal {
39
+ constructor(private readonly dir: string) {}
40
+
41
+ private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); }
42
+
43
+ append(runId: string, event: WorkflowJournalEvent): void {
44
+ try {
45
+ mkdirSync(this.dir, { recursive: true });
46
+ appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
47
+ } catch {
48
+ // best-effort: never fail the workflow because the journal couldn't persist.
49
+ }
50
+ }
51
+
52
+ replay(runId: string): WorkflowJournalEvent[] {
53
+ const f = this.file(runId);
54
+ if (!existsSync(f)) return [];
55
+ const events: WorkflowJournalEvent[] = [];
56
+ for (const line of readFileSync(f, "utf8").split("\n")) {
57
+ if (!line) continue;
58
+ try { events.push(JSON.parse(line) as WorkflowJournalEvent); }
59
+ catch { /* partial last line (crash mid-append) — discard */ }
60
+ }
61
+ return events;
62
+ }
63
+
64
+ scanNonTerminal(): string[] {
65
+ if (!existsSync(this.dir)) return [];
66
+ const ids: string[] = [];
67
+ for (const f of readdirSync(this.dir)) {
68
+ if (!f.endsWith(".jsonl")) continue;
69
+ const runId = f.slice(0, -".jsonl".length);
70
+ const events = this.replay(runId);
71
+ const last = events[events.length - 1];
72
+ if (last && !WORKFLOW_TERMINAL.has(last.type)) ids.push(runId);
73
+ }
74
+ return ids;
75
+ }
76
+ }
@@ -0,0 +1,22 @@
1
+ // SPEC-6-3 §9 — bounded keyword authorization for workflow capability.
2
+ // Returns a bounded system hint when the prompt mentions "workflow"/"workflows"
3
+ // as a standalone word (not as part of an identifier or file path).
4
+
5
+ const WORKFLOW_KEYWORD = /(?:^|[^A-Za-z0-9_])workflows?(?=$|[^A-Za-z0-9_])/i
6
+
7
+ export function workflowKeywordHint(prompt: string): string | undefined {
8
+ const match = prompt.match(WORKFLOW_KEYWORD)
9
+ if (!match) return undefined
10
+
11
+ const fullMatch = match[0]!
12
+ // If the match includes a preceding non-word char, check if it's a path separator.
13
+ const firstChar = fullMatch[0]!
14
+ if (firstChar === "/" || firstChar === ".") return undefined
15
+
16
+ // Check the character after "workflow" in the original string.
17
+ const afterIdx = (match.index ?? 0) + fullMatch.length
18
+ const afterChar = prompt[afterIdx]
19
+ if (afterChar === "-" || afterChar === ".") return undefined
20
+
21
+ return "workflow capability authorized — use action:'workflow' with a script or workflowName"
22
+ }
@@ -0,0 +1,150 @@
1
+ // SPEC-6-3 §7/§10 — combined Workflows panel item + action model (pure functions).
2
+ // Definitions first (sorted by source rank, then name), then runs (newest-first).
3
+ import type { SelectItem } from "@earendil-works/pi-tui"
4
+
5
+ import type { WorkflowDef } from "../registry.ts"
6
+ import type { WorkflowRunState } from "../runtime/types.ts"
7
+
8
+ export type WorkflowPanelItem =
9
+ | { kind: "definition"; definition: WorkflowDef }
10
+ | { kind: "run"; run: WorkflowRunState }
11
+
12
+ export type WorkflowPanelAction =
13
+ | "run"
14
+ | "open"
15
+ | "pause"
16
+ | "resume"
17
+ | "stop"
18
+ | "save"
19
+ | "respond"
20
+ | "edit-resume"
21
+ | "view-result"
22
+ | "resume-unchanged"
23
+
24
+ const SOURCE_RANK: Record<WorkflowDef["source"], number> = {
25
+ project: 0,
26
+ global: 1,
27
+ builtin: 2,
28
+ }
29
+
30
+ const RUN_STATUS_GLYPH: Record<WorkflowRunState["status"], string> = {
31
+ queued: "○",
32
+ running: "▶",
33
+ paused: "⏸",
34
+ checkpoint: "⏸",
35
+ completed: "✓",
36
+ failed: "✗",
37
+ aborted: "✗",
38
+ interrupted: "⚠",
39
+ }
40
+
41
+ const DESC_BOUND = 80
42
+ const LOG_BOUND = 120
43
+
44
+ function bound(text: string, max: number): string {
45
+ return text.length > max ? text.slice(0, max - 1) + "…" : text
46
+ }
47
+
48
+ function phaseStrip(phases: { title: string }[], current: string): string {
49
+ return phases.map((p) => (p.title === current ? `${p.title} ▶` : `${p.title} ○`)).join(" ")
50
+ }
51
+
52
+ function definitionLabel(def: WorkflowDef): string {
53
+ const desc = def.description ? ` — ${bound(def.description, DESC_BOUND)}` : ""
54
+ return `◇ ${def.name} [${def.source}]${desc}`
55
+ }
56
+
57
+ function runLabel(state: WorkflowRunState): string {
58
+ const glyph = RUN_STATUS_GLYPH[state.status]
59
+ const strip = state.phases.length > 0 ? ` ${phaseStrip(state.phases, state.currentPhase)}` : ""
60
+ const tokens = state.tokenTotal > 0 ? ` · ${(state.tokenTotal / 1000).toFixed(1)}K tok` : ""
61
+ const cost = state.costTotal > 0 ? ` · $${state.costTotal.toFixed(2)}` : ""
62
+ const lastLog = state.logs.length > 0 ? ` ${bound(state.logs.at(-1) ?? "", LOG_BOUND)}` : ""
63
+ return `${glyph} ${state.runId} [${state.status}]${strip}${tokens}${cost}${lastLog}`
64
+ }
65
+
66
+ export function buildWorkflowPanelItems(input: {
67
+ definitions: WorkflowDef[]
68
+ runs: WorkflowRunState[]
69
+ }): SelectItem[] {
70
+ const sortedDefs = [...input.definitions].sort((a, b) => {
71
+ const rankDiff = SOURCE_RANK[a.source] - SOURCE_RANK[b.source]
72
+ if (rankDiff !== 0) return rankDiff
73
+ return a.name.localeCompare(b.name)
74
+ })
75
+
76
+ const sortedRuns = [...input.runs].sort((a, b) => b.startedAt - a.startedAt)
77
+
78
+ const defItems: SelectItem[] = sortedDefs.map((def) => ({
79
+ value: `definition:${def.name}`,
80
+ label: definitionLabel(def),
81
+ }))
82
+
83
+ const runItems: SelectItem[] = sortedRuns.map((r) => ({
84
+ value: `run:${r.runId}`,
85
+ label: runLabel(r),
86
+ }))
87
+
88
+ return [...defItems, ...runItems]
89
+ }
90
+
91
+ export function actionsForWorkflowItem(item: WorkflowPanelItem): WorkflowPanelAction[] {
92
+ if (item.kind === "definition") {
93
+ return ["run", "open"]
94
+ }
95
+
96
+ const status = item.run.status
97
+ switch (status) {
98
+ case "queued":
99
+ return ["open", "stop"]
100
+ case "running":
101
+ return ["open", "pause", "stop", "save"]
102
+ case "paused":
103
+ return ["open", "resume", "stop", "save"]
104
+ case "checkpoint":
105
+ return ["respond", "stop", "open"]
106
+ case "completed":
107
+ return ["open", "edit-resume", "save", "view-result"]
108
+ case "failed":
109
+ return ["open", "edit-resume", "save", "view-result"]
110
+ case "aborted":
111
+ return ["open", "edit-resume", "save", "view-result"]
112
+ case "interrupted":
113
+ return ["open", "edit-resume", "resume-unchanged", "stop", "save"]
114
+ }
115
+ }
116
+
117
+ export function parseWorkflowPanelKey(
118
+ value: string,
119
+ ): { kind: "definition"; name: string } | { kind: "run"; runId: string } {
120
+ if (value.startsWith("definition:")) {
121
+ return { kind: "definition", name: value.slice("definition:".length) }
122
+ }
123
+ if (value.startsWith("run:")) {
124
+ return { kind: "run", runId: value.slice("run:".length) }
125
+ }
126
+ throw new Error(`invalid workflow panel key: ${value}`)
127
+ }
128
+
129
+ // ── Backward-compat shim (Task 12 will rewire the panel to the new model) ──
130
+
131
+ export interface WorkflowRunRow {
132
+ runId: string
133
+ name: string
134
+ status: "running" | "completed" | "failed" | "aborted" | "paused" | "checkpoint"
135
+ currentPhase: string
136
+ phases: { title: string }[]
137
+ agents: number
138
+ cached: number
139
+ reRun: number
140
+ tokens: number
141
+ cost: number
142
+ }
143
+
144
+ export function buildWorkflowsItems(runs: WorkflowRunRow[]): SelectItem[] {
145
+ return runs.map((r) => ({
146
+ id: r.runId,
147
+ label: `${RUN_STATUS_GLYPH[r.status] ?? "▶"} ${r.name} [${r.status}] ${phaseStrip(r.phases, r.currentPhase)} · ${r.agents} agents (${r.cached} cached, ${r.reRun} re-run) · ${(r.tokens / 1000).toFixed(1)}K tok · $${r.cost.toFixed(2)}`,
148
+ value: r.runId,
149
+ }))
150
+ }
@@ -0,0 +1,3 @@
1
+ // SPEC-6-3 — row rendering helpers (pure). Re-exported for the panel; kept thin.
2
+ export { buildWorkflowsItems, buildWorkflowPanelItems, actionsForWorkflowItem, parseWorkflowPanelKey } from "./workflows-items.ts"
3
+ export type { WorkflowRunRow, WorkflowPanelItem, WorkflowPanelAction } from "./workflows-items.ts"
@@ -0,0 +1,179 @@
1
+ // SPEC-6-3 §12 — panel host loop: consumes WorkflowPanelIntent from the panel
2
+ // and drives editor/input/confirm around it. Never nests ui.editor inside ui.custom.
3
+ import type { Theme } from "@earendil-works/pi-coding-agent"
4
+ import type { Container } from "@earendil-works/pi-tui"
5
+ import type { FleetPanelDeps } from "../panel/fleet-panel.ts"
6
+ import type { WorkflowDef } from "./registry.ts"
7
+ import type { WorkflowRunState } from "./runtime/types.ts"
8
+ import { FleetPanel } from "../panel/fleet-panel.ts"
9
+
10
+ export type WorkflowPanelIntent =
11
+ | { action: "close" }
12
+ | { action: "run"; definitionName: string; prompt: string }
13
+ | { action: "open-definition"; name: string }
14
+ | { action: "open-child"; runId: string; childRunId: string }
15
+ | { action: "edit-resume"; runId: string }
16
+ | { action: "save"; runId: string }
17
+ | { action: "view-result"; runId: string }
18
+ | { action: "respond"; runId: string }
19
+
20
+ export interface WorkflowPanelHostContext {
21
+ custom: (factory: (tui: unknown, theme: Theme, kb: unknown, done: () => void) => Container) => void
22
+ editor: (initial: string) => Promise<string>
23
+ input: (prompt: string) => Promise<string>
24
+ confirm: (prompt: string) => Promise<boolean>
25
+ notify: (msg: string, type?: "info" | "warning" | "error") => void
26
+ sendUserMessage: (text: string) => void
27
+ }
28
+
29
+ const MAX_SOURCE_BYTES = 50_000
30
+ const MAX_SOURCE_LINES = 2000
31
+
32
+ function boundSource(source: string): string {
33
+ const lines = source.split("\n")
34
+ if (lines.length > MAX_SOURCE_LINES) {
35
+ return lines.slice(0, MAX_SOURCE_LINES).join("\n") + "\n… (truncated)"
36
+ }
37
+ if (source.length > MAX_SOURCE_BYTES) {
38
+ return source.slice(0, MAX_SOURCE_BYTES) + "… (truncated)"
39
+ }
40
+ return source
41
+ }
42
+
43
+ function boundResult(result: unknown): string {
44
+ let text: string
45
+ try {
46
+ text = JSON.stringify(result) ?? String(result)
47
+ } catch {
48
+ text = String(result)
49
+ }
50
+ return text.slice(0, MAX_SOURCE_BYTES)
51
+ }
52
+
53
+ export async function openWorkflowPanelLoop(
54
+ deps: FleetPanelDeps,
55
+ host: WorkflowPanelHostContext,
56
+ ): Promise<void> {
57
+ let running = true
58
+
59
+ while (running) {
60
+ const intent = await openPanelOnce(deps, host)
61
+
62
+ if (!intent || intent.action === "close") {
63
+ running = false
64
+ continue
65
+ }
66
+
67
+ switch (intent.action) {
68
+ case "run": {
69
+ if (intent.prompt.trim()) {
70
+ const instruction = `${intent.prompt}\n\nUse the fleet workflow tool (action:'workflow') with the '${intent.definitionName}' workflow to execute this.`
71
+ host.sendUserMessage(instruction)
72
+ running = false
73
+ } else {
74
+ await deps.workflowController.start({
75
+ workflowName: intent.definitionName,
76
+ mode: "checkpointed",
77
+ })
78
+ }
79
+ break
80
+ }
81
+
82
+ case "edit-resume": {
83
+ const run = deps.workflowController.getRun(intent.runId)
84
+ if (!run) {
85
+ host.notify(`run '${intent.runId}' not found`, "error")
86
+ break
87
+ }
88
+ const editedSource = await host.editor(run.script)
89
+ await deps.workflowController.editAndResume(intent.runId, editedSource, "checkpointed")
90
+ break
91
+ }
92
+
93
+ case "save": {
94
+ const run = deps.workflowController.getRun(intent.runId)
95
+ if (!run) {
96
+ host.notify(`run '${intent.runId}' not found`, "error")
97
+ break
98
+ }
99
+ const name = await host.input("Save-as name?")
100
+ let overwrite = false
101
+ const existing = deps.workflowRegistry.get(name)
102
+ const controllerWithCollision = deps.workflowController as unknown as { saveCollision?: boolean }
103
+ if (existing || controllerWithCollision.saveCollision) {
104
+ overwrite = await host.confirm(`Workflow '${name}' already exists. Overwrite?`)
105
+ }
106
+ deps.workflowController.save({ name, source: run.script, overwrite })
107
+ break
108
+ }
109
+
110
+ case "open-definition": {
111
+ const def = deps.workflowRegistry.get(intent.name)
112
+ if (!def) {
113
+ host.notify(`workflow '${intent.name}' not found`, "warning")
114
+ break
115
+ }
116
+ host.notify(`Source: ${def.name}\n${boundSource(def.sourceText)}`, "info")
117
+ break
118
+ }
119
+
120
+ case "open-child": {
121
+ host.notify(`Open child run '${intent.childRunId}' from '${intent.runId}' — use the Runs viewer.`, "info")
122
+ break
123
+ }
124
+
125
+ case "view-result": {
126
+ const run = deps.workflowController.getRun(intent.runId)
127
+ if (!run) {
128
+ host.notify(`run '${intent.runId}' not found`, "error")
129
+ break
130
+ }
131
+ const resultText = run.result !== undefined ? boundResult(run.result) : "(no result)"
132
+ const logText = run.logs.length > 0 ? run.logs.join("\n") : "(no logs)"
133
+ host.notify(`Result: ${resultText}\n\nLogs:\n${logText}`, "info")
134
+ break
135
+ }
136
+
137
+ case "respond": {
138
+ const shouldContinue = await host.confirm("Continue checkpoint?")
139
+ if (shouldContinue) {
140
+ deps.workflowController.respondToCheckpoint(intent.runId, { action: "continue" })
141
+ } else {
142
+ const feedback = await host.input("Revise feedback (blank to abort):")
143
+ if (feedback.trim()) {
144
+ deps.workflowController.respondToCheckpoint(intent.runId, { action: "revise", feedback })
145
+ } else {
146
+ deps.workflowController.respondToCheckpoint(intent.runId, { action: "abort" })
147
+ }
148
+ }
149
+ break
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ function openPanelOnce(
156
+ deps: FleetPanelDeps,
157
+ host: WorkflowPanelHostContext,
158
+ ): Promise<WorkflowPanelIntent | null> {
159
+ return new Promise((resolve) => {
160
+ let resolved = false
161
+ const safeResolve = (intent: WorkflowPanelIntent | null) => {
162
+ if (resolved) return
163
+ resolved = true
164
+ resolve(intent)
165
+ }
166
+ host.custom((_tui, theme, _kb, done) => {
167
+ const panel = new FleetPanel({
168
+ theme,
169
+ deps,
170
+ onDone: (intent?: WorkflowPanelIntent | null) => {
171
+ safeResolve(intent ?? null)
172
+ done()
173
+ },
174
+ onNotify: (m: string, t?: "info" | "warning" | "error") => host.notify(m, t),
175
+ })
176
+ return panel
177
+ })
178
+ })
179
+ }
@@ -0,0 +1,68 @@
1
+ // SPEC-6-3 §3.7 — saved-workflow discovery. Project > global > builtin (later scopes win by name).
2
+ // A workflow .js exports `meta` { name, description, phases? } + a script body. We parse the
3
+ // source via the canonical balanced-brace parser (source.ts) which produces the meta, the
4
+ // stripped body, and a normalized `executable` string the vm realm can compile as CommonJS.
5
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { parseWorkflowSource } from "./source.ts";
8
+
9
+ export type WorkflowSource = "builtin" | "global" | "project";
10
+
11
+ export interface WorkflowDef {
12
+ name: string;
13
+ description: string;
14
+ phases: { title: string }[];
15
+ sourceText: string; // the original file content
16
+ body: string; // script body after the meta declaration is removed
17
+ executable: string; // normalized CommonJS executable (passed to the runner)
18
+ source: WorkflowSource;
19
+ filePath: string;
20
+ }
21
+
22
+ export interface DiscoverOpts { projectDir: string; globalDir: string; builtinDir: string; }
23
+
24
+ export interface DiscoverResult {
25
+ workflows: Map<string, WorkflowDef>;
26
+ errors: string[];
27
+ warnings: string[];
28
+ }
29
+
30
+ function loadDir(dir: string, source: WorkflowSource, into: Map<string, WorkflowDef>, errors: string[]): void {
31
+ if (!existsSync(dir)) return;
32
+ for (const f of readdirSync(dir)) {
33
+ if (!f.endsWith(".js")) continue;
34
+ const filePath = join(dir, f);
35
+ const content = readFileSync(filePath, "utf8");
36
+ try {
37
+ const parsed = parseWorkflowSource(content, { filePath, requireMeta: true });
38
+ if (!parsed.meta) { errors.push(`${filePath}: missing \`export const meta = {…}\``); continue; }
39
+ const { name, description, phases } = parsed.meta;
40
+ into.set(name, { name, description, phases, sourceText: parsed.source, body: parsed.body, executable: parsed.executable, source, filePath });
41
+ } catch (e) {
42
+ errors.push((e as Error).message);
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Discover workflows from three scopes. Later scopes win by name (project > global > builtin). */
48
+ export function discoverWorkflows(opts: DiscoverOpts): DiscoverResult {
49
+ const map = new Map<string, WorkflowDef>();
50
+ const errors: string[] = [];
51
+ loadDir(opts.builtinDir, "builtin", map, errors);
52
+ loadDir(opts.globalDir, "global", map, errors);
53
+ loadDir(opts.projectDir, "project", map, errors); // project shadows (set last wins)
54
+ return { workflows: map, errors, warnings: [] };
55
+ }
56
+
57
+ export class WorkflowRegistry {
58
+ private readonly byName = new Map<string, WorkflowDef>();
59
+ constructor(workflows: Map<string, WorkflowDef>) { for (const [k, v] of workflows) this.byName.set(k, v); }
60
+ get(name: string): WorkflowDef | undefined { return this.byName.get(name); }
61
+ list(): WorkflowDef[] { return [...this.byName.values()]; }
62
+
63
+ /** SPEC-6-3 §6: clear + repopulate the existing map (live reference preserved for all consumers). */
64
+ replace(workflows: WorkflowDef[]): void {
65
+ this.byName.clear();
66
+ for (const w of workflows) this.byName.set(w.name, w);
67
+ }
68
+ }