@signalridge/pi-workflows 1.2.0 → 1.3.1

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 (49) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +142 -17
  3. package/package.json +14 -8
  4. package/skills/workflow-authoring/SKILL.md +30 -0
  5. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  6. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  7. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  8. package/skills/workflow-authoring/examples/defensive-json-parsing.js +63 -0
  9. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  10. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  11. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  12. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  13. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  14. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  15. package/skills/workflow-authoring/examples/tournament.js +81 -0
  16. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  17. package/skills/workflow-authoring/references/capabilities.md +44 -0
  18. package/skills/workflow-authoring/references/capability-details.md +294 -0
  19. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  20. package/skills/workflow-authoring/references/debugging.md +29 -0
  21. package/skills/workflow-authoring/references/focused-recipes.md +12 -0
  22. package/skills/workflow-authoring/references/helpers.md +9 -0
  23. package/skills/workflow-authoring/references/lifecycle.md +37 -0
  24. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  25. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  26. package/skills/workflow-authoring/references/registry-ownership.md +17 -0
  27. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  28. package/skills/workflow-authoring/references/review.md +43 -0
  29. package/skills/workflow-authoring/references/runtime.md +29 -0
  30. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  31. package/skills/workflow-authoring/references/versions.md +13 -0
  32. package/skills/workflow-patterns/SKILL.md +51 -0
  33. package/src/arming.ts +69 -0
  34. package/src/builtins.ts +331 -0
  35. package/src/capabilities.ts +318 -0
  36. package/src/code-review-scope.ts +144 -0
  37. package/src/engine.ts +1158 -2561
  38. package/src/errors.ts +136 -0
  39. package/src/index.ts +750 -103
  40. package/src/journal.ts +594 -1001
  41. package/src/rpc-client.ts +95 -24
  42. package/src/runtime.ts +1708 -0
  43. package/src/saved-workflows.ts +121 -0
  44. package/src/shared-store.ts +132 -0
  45. package/src/ui.ts +77 -40
  46. package/src/workflow-settings.ts +100 -0
  47. package/src/schema.ts +0 -215
  48. package/src/state-machine.ts +0 -79
  49. package/src/synthesis.ts +0 -140
@@ -0,0 +1,121 @@
1
+ /**
2
+ * saved-workflows.ts — persisted user workflows (A7).
3
+ *
4
+ * Layout: `~/.pi/workflows/projects/<basename>-<sha256(cwd)[0:12]>/saved/*.js`
5
+ * with a `settings.json` next to the run directory. Writes are atomic
6
+ * (temp file + rename) with a `.bak` backup kept before replacement. Names are
7
+ * validated against a bounded identifier pattern so a saved name can never
8
+ * escape the saved directory or collide with command registration.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
+ import { basename, dirname, join, resolve } from "node:path";
14
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
+
16
+ /** Names a saved workflow; the same pattern feeds slash-command registration. */
17
+ const SAVED_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
18
+
19
+ export function isValidSavedName(name: string): boolean {
20
+ return SAVED_NAME_PATTERN.test(name);
21
+ }
22
+
23
+ function projectDir(cwd: string): string {
24
+ const encoded = basename(resolve(cwd)) || "root";
25
+ const digest = createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12);
26
+ return join(getAgentDir(), "workflows", "projects", `${encoded}-${digest}`);
27
+ }
28
+
29
+ function savedDir(cwd: string): string {
30
+ return join(projectDir(cwd), "saved");
31
+ }
32
+
33
+ function savedPath(name: string, cwd: string): string {
34
+ return join(savedDir(cwd), `${name}.js`);
35
+ }
36
+
37
+ function userSavedDir(): string {
38
+ return join(getAgentDir(), "workflows", "saved");
39
+ }
40
+
41
+ function userSavedPath(name: string): string {
42
+ return join(userSavedDir(), `${name}.js`);
43
+ }
44
+
45
+ /** Atomic write with backup: temp file + rename, keeping a .bak of the prior content. */
46
+ function atomicWrite(path: string, content: string): void {
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
49
+ if (existsSync(path)) {
50
+ try {
51
+ renameSync(path, `${path}.bak`);
52
+ } catch {
53
+ // Best-effort backup; the rename below still replaces the target.
54
+ }
55
+ }
56
+ writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" });
57
+ renameSync(temporary, path);
58
+ try {
59
+ rmSync(`${path}.bak`, { force: true });
60
+ } catch {
61
+ // best-effort
62
+ }
63
+ }
64
+
65
+ /** Save a workflow script under `name`. Throws on invalid names. */
66
+ export function saveWorkflow(name: string, script: string, cwd: string = process.cwd()): void {
67
+ if (!isValidSavedName(name)) {
68
+ throw new Error(
69
+ `Invalid workflow name "${name}": use letters, digits, and ._- only, at most 64 characters, no leading separator.`,
70
+ );
71
+ }
72
+ atomicWrite(savedPath(name, cwd), script);
73
+ }
74
+
75
+ /** Load a saved workflow script by name; undefined when absent or invalid. */
76
+ export function loadSavedWorkflow(name: string, cwd: string = process.cwd()): string | undefined {
77
+ if (!isValidSavedName(name)) return undefined;
78
+ for (const path of [savedPath(name, cwd), userSavedPath(name)]) {
79
+ if (!existsSync(path)) continue;
80
+ try {
81
+ const content = readFileSync(path, "utf8");
82
+ if (content.length > 0 && content.length <= 200_000) return content;
83
+ } catch {
84
+ // Project errors do not hide a valid user-scope fallback.
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /** List saved workflow names (bounded). */
91
+ export function listSavedWorkflows(cwd: string = process.cwd()): string[] {
92
+ const names = new Set<string>();
93
+ for (const dir of [savedDir(cwd), userSavedDir()]) {
94
+ if (!existsSync(dir)) continue;
95
+ try {
96
+ for (const file of readdirSync(dir)) {
97
+ const name = file.endsWith(".js") ? basename(file, ".js") : "";
98
+ if (isValidSavedName(name)) names.add(name);
99
+ }
100
+ } catch {
101
+ // An unreadable scope is simply unavailable; the other scope still works.
102
+ }
103
+ }
104
+ return [...names].sort();
105
+ }
106
+
107
+ /** Remove a saved workflow; returns false when it did not exist. */
108
+ export function removeSavedWorkflow(name: string, cwd: string = process.cwd()): boolean {
109
+ if (!isValidSavedName(name)) return false;
110
+ let removed = false;
111
+ for (const path of [savedPath(name, cwd), userSavedPath(name)]) {
112
+ if (!existsSync(path)) continue;
113
+ try {
114
+ rmSync(path, { force: true });
115
+ removed = true;
116
+ } catch {
117
+ // Keep trying the other scope; report whether anything was removed.
118
+ }
119
+ }
120
+ return removed;
121
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * shared-store.ts — in-memory key-value store scoped to a single workflow run.
3
+ *
4
+ * One `SharedStore` instance is created at run start and disposed when the run
5
+ * ends. Two MCP-compatible tool definitions (`store_put` / `store_get`) are
6
+ * injected into every agent's tool list so parallel agents can share
7
+ * intermediate state without coordinating through the script itself.
8
+ *
9
+ * Journal integration: the runtime captures `store.commitDelta(deltaKey)`
10
+ * alongside each agent result in the journal. On resume,
11
+ * `store.applyDelta(delta)` rebuilds the store state additively in callSeq
12
+ * order, so parallel-agent writes are replayed correctly without the
13
+ * last-complete-wins ordering bug that a whole-Map restore() would cause.
14
+ *
15
+ * `deltaKey` must be unique across every run that shares this store instance,
16
+ * not just within one run's callSeq. A nested `workflow()` call restarts its
17
+ * own callSeq at 0 while inheriting the parent's store, so a bare callIndex
18
+ * would collide. Callers compose `deltaKey` as `${runId}:${callIndex}`.
19
+ */
20
+
21
+ export class SharedStore {
22
+ private readonly map = new Map<string, unknown>();
23
+ // Per-agent write deltas; keyed by a run-unique `${runId}:${callIndex}` string.
24
+ private readonly agentDeltas = new Map<string, Record<string, unknown>>();
25
+ // Pre-write shadow values for the CURRENT delta-key's in-progress writes, so a
26
+ // failed retry attempt's mutations can be rolled back (see `discardDelta`)
27
+ // instead of leaking into the live store or a later successful attempt's
28
+ // recorded delta. Populated lazily on first write to a given key within the
29
+ // current delta window; cleared whenever the delta is finalized either way.
30
+ private readonly priorValues = new Map<string, Map<string, { existed: boolean; value: unknown }>>();
31
+
32
+ /** Store a value under `key`. Overwrites any existing value. */
33
+ put(key: string, value: unknown): void {
34
+ this.map.set(key, value);
35
+ }
36
+
37
+ /** Store a value and record the write in the per-agent delta for `deltaKey`. */
38
+ trackPut(key: string, value: unknown, deltaKey: string): void {
39
+ let priors = this.priorValues.get(deltaKey);
40
+ if (!priors) {
41
+ priors = new Map();
42
+ this.priorValues.set(deltaKey, priors);
43
+ }
44
+ // Only shadow the value from BEFORE this delta window started writing to
45
+ // this key — a second write within the same attempt must not overwrite the
46
+ // shadow with its own (already-in-window) value.
47
+ if (!priors.has(key)) {
48
+ priors.set(
49
+ key,
50
+ this.map.has(key) ? { existed: true, value: this.map.get(key) } : { existed: false, value: undefined },
51
+ );
52
+ }
53
+ this.map.set(key, value);
54
+ let delta = this.agentDeltas.get(deltaKey);
55
+ if (!delta) {
56
+ delta = {};
57
+ this.agentDeltas.set(deltaKey, delta);
58
+ }
59
+ delta[key] = value;
60
+ }
61
+
62
+ /** Retrieve the value for `key`, or `undefined` when absent. */
63
+ get(key: string): unknown {
64
+ return this.map.get(key);
65
+ }
66
+
67
+ /** Whether `key` is present in the store. */
68
+ has(key: string): boolean {
69
+ return this.map.has(key);
70
+ }
71
+
72
+ /** Return a deep-copied plain-object snapshot of all entries. */
73
+ snapshot(): Record<string, unknown> {
74
+ return structuredClone(Object.fromEntries(this.map));
75
+ }
76
+
77
+ /**
78
+ * Extract and clear the write delta accumulated for `deltaKey`.
79
+ * Called after an agent completes to get the set of keys it wrote.
80
+ */
81
+ commitDelta(deltaKey: string): Record<string, unknown> {
82
+ const delta = this.agentDeltas.get(deltaKey) ?? {};
83
+ this.agentDeltas.delete(deltaKey);
84
+ this.priorValues.delete(deltaKey);
85
+ return delta;
86
+ }
87
+
88
+ /**
89
+ * Undo the writes recorded for `deltaKey` and discard its bookkeeping,
90
+ * without touching any other key. Used when a retry attempt fails: that
91
+ * attempt's writes must not remain visible in the live store nor merge into
92
+ * the delta eventually recorded by a later successful attempt of the SAME
93
+ * call. Per-key guard: a key is only rolled back if the store STILL holds
94
+ * this attempt's own last write to it (`Object.is`); a concurrent sibling's
95
+ * overwrite is left untouched.
96
+ */
97
+ discardDelta(deltaKey: string): void {
98
+ const delta = this.agentDeltas.get(deltaKey);
99
+ if (!delta) return;
100
+ const priors = this.priorValues.get(deltaKey);
101
+ for (const key of Object.keys(delta)) {
102
+ if (!Object.is(this.map.get(key), delta[key])) continue;
103
+ const prior = priors?.get(key);
104
+ if (prior?.existed) this.map.set(key, prior.value);
105
+ else this.map.delete(key);
106
+ }
107
+ this.agentDeltas.delete(deltaKey);
108
+ this.priorValues.delete(deltaKey);
109
+ }
110
+
111
+ /** Apply a write delta additively — sets each key without clearing others. */
112
+ applyDelta(delta: Record<string, unknown>): void {
113
+ for (const [k, v] of Object.entries(delta)) {
114
+ this.map.set(k, v);
115
+ }
116
+ }
117
+
118
+ /** Replace all entries with a snapshot (full reset). */
119
+ restore(snap: Record<string, unknown>): void {
120
+ this.map.clear();
121
+ for (const [k, v] of Object.entries(snap)) {
122
+ this.map.set(k, v);
123
+ }
124
+ }
125
+
126
+ /** Clear all entries (called when the run ends). */
127
+ dispose(): void {
128
+ this.map.clear();
129
+ this.agentDeltas.clear();
130
+ this.priorValues.clear();
131
+ }
132
+ }
package/src/ui.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
4
- import type { WorkflowEngine, WorkflowSummary } from "./engine.js";
4
+ import type { WorkflowEngine } from "./engine.js";
5
+ import type { ScriptRun } from "./journal.js";
5
6
  import { sanitizeDisplayText } from "./safe-text.js";
6
- import { isTerminalWorkflow, type WorkflowStatus } from "./state-machine.js";
7
7
 
8
8
  /** Bounds, in code points, for untrusted strings rendered into the navigator. */
9
9
  const MAX_NAME_CHARS = 200;
@@ -12,6 +12,38 @@ const MAX_RESULT_CHARS = 300;
12
12
  const MAX_RUN_ERROR_CHARS = 500;
13
13
  const MAX_DESCRIPTION_CHARS = 120;
14
14
 
15
+ /**
16
+ * Pure snapshot render layer shared by the navigator, the live widget, and any
17
+ * inline render site. All child-agent output passes through sanitizeDisplayText
18
+ * BEFORE truncation so a live escape introducer is never cut mid-sequence.
19
+ */
20
+ export function snapshotRunLine(run: ScriptRun): string {
21
+ return formatWorkflowRunLabel(run);
22
+ }
23
+
24
+ /** Live-widget lines for all non-terminal runs (A10). */
25
+ export function liveWidgetLines(
26
+ runs: readonly ScriptRun[],
27
+ mode: "compact" | "detailed" = "compact",
28
+ maxAgentsShown = 6,
29
+ ): string[] {
30
+ const active = runs.filter((run) => !isTerminal(run.status));
31
+ if (active.length === 0) return [];
32
+ const boundedMax = Math.max(1, Math.min(32, Math.floor(maxAgentsShown)));
33
+ const lines = ["Workflows"];
34
+ for (const run of active.slice(0, boundedMax)) {
35
+ lines.push(` ${snapshotRunLine(run)}`);
36
+ if (mode === "detailed") {
37
+ for (const [nodeId, status] of Object.entries(run.callStatus).slice(0, boundedMax)) {
38
+ lines.push(` ${nodeId}: ${status}`);
39
+ }
40
+ }
41
+ }
42
+ return lines.slice(0, 1 + boundedMax * (mode === "detailed" ? boundedMax + 1 : 1));
43
+ }
44
+
45
+ export type RunStatus = ScriptRun["status"];
46
+
15
47
  function elapsed(ms: number): string {
16
48
  const seconds = Math.floor(ms / 1_000);
17
49
  if (seconds < 60) return `${seconds}s`;
@@ -19,47 +51,51 @@ function elapsed(ms: number): string {
19
51
  return `${minutes}m ${seconds % 60}s`;
20
52
  }
21
53
 
22
- export function formatWorkflowRunLabel(run: WorkflowSummary): string {
23
- const phases = run.phaseCount > 0 ? ` · phases ${run.completedPhases}/${run.phaseCount}` : "";
24
- const tokens = run.tokenCount === undefined ? "" : ` · ${run.tokenCount} tokens`;
25
- const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
26
- return `${name} · ${run.status}${phases} · ${run.completedTasks}/${run.taskCount} tasks · ${elapsed(run.elapsedMs)}${tokens}`;
54
+ function isTerminal(status: RunStatus): boolean {
55
+ return status === "completed" || status === "failed" || status === "stopped";
56
+ }
57
+
58
+ export function formatWorkflowRunLabel(run: ScriptRun): string {
59
+ const phases = run.meta.phases && run.meta.phases.length > 0 ? ` · phases ${run.meta.phases.length}` : "";
60
+ const calls = Object.keys(run.callResults).length;
61
+ const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
62
+ return `${name} · ${run.status}${phases} · ${calls} calls · ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`;
27
63
  }
28
64
 
29
65
  export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
30
- const run = engine.getRun(runId);
31
- if (!run) return "Workflow run is no longer available.";
32
- const tokenValues = [...Object.values(run.taskResults), ...(run.synthesisResult ? [run.synthesisResult] : [])]
66
+ const state = engine.getState(runId);
67
+ if (!state) return "Workflow run is no longer available.";
68
+ const run = state.run;
69
+ const tokenValues = Object.values(run.callResults)
33
70
  .map((result) => result.tokenCount)
34
71
  .filter((value): value is number => value !== undefined);
35
72
  const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
36
73
  const lines = [
37
- `${sanitizeDisplayText(run.definition.name, MAX_NAME_CHARS)} [${run.status}]`,
74
+ `${sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS)} [${run.status}]`,
38
75
  `run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
39
- `elapsed: ${elapsed(Math.max(0, (isTerminalWorkflow(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
76
+ `elapsed: ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
40
77
  "",
41
78
  ...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
42
79
  ];
43
- for (const phase of run.definition.phases) {
80
+ for (const phase of run.meta.phases ?? []) {
44
81
  lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
45
- for (const task of run.definition.tasks.filter((candidate) => candidate.phase === phase.id)) {
46
- const status = run.taskStatus[task.id] ?? "pending";
47
- const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
48
- const compact = run.compactions[task.id] ? ` compactions=${run.compactions[task.id]}` : "";
49
- lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}${compact}`);
50
- const result = run.taskResults[task.id];
51
- if (result?.error) lines.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
52
- if (result?.text) lines.push(` result: ${sanitizeDisplayText(result.text, MAX_RESULT_CHARS)}`);
53
- }
54
82
  }
55
- const unphased = run.definition.tasks.filter((task) => task.phase === undefined);
56
- if (unphased.length > 0) {
57
- lines.push("Tasks:");
58
- for (const task of unphased) {
59
- const status = run.taskStatus[task.id] ?? "pending";
60
- const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
61
- lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}`);
62
- }
83
+ const calls = Object.entries(run.callStatus)
84
+ .sort(([a], [b]) => Number(a) - Number(b))
85
+ .map(([nodeId, status]) => {
86
+ const result = run.callResults[nodeId];
87
+ const agent = run.agentIds[nodeId] ? ` agent=${sanitizeDisplayText(run.agentIds[nodeId], MAX_ID_CHARS)}` : "";
88
+ const compact = run.compactions[nodeId] ? ` compactions=${run.compactions[nodeId]}` : "";
89
+ const parts = [` ${status.padEnd(11)} call ${sanitizeDisplayText(nodeId, MAX_ID_CHARS)}${agent}${compact}`];
90
+ if (result?.error) parts.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
91
+ if (result?.result !== undefined && result.status === "completed") {
92
+ parts.push(` result: ${sanitizeDisplayText(String(result.result), MAX_RESULT_CHARS)}`);
93
+ }
94
+ return parts.join("\n");
95
+ });
96
+ if (calls.length > 0) {
97
+ lines.push("Calls:");
98
+ lines.push(...calls);
63
99
  }
64
100
  lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
65
101
  if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
@@ -81,15 +117,12 @@ const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
81
117
  const BACK: WorkflowActionItem = { value: "back", label: "Back" };
82
118
 
83
119
  /**
84
- * Menu actions legal for a run in `status`, mirroring what `WorkflowEngine.control` accepts.
85
- * Exhaustive by construction: there is no `default` arm, so adding a `WorkflowStatus` member
86
- * is a compile error here instead of a silently wrong menu.
120
+ * Menu actions legal for a run in `status`. Exhaustive by construction.
87
121
  */
88
- export function workflowActionsFor(status: WorkflowStatus): readonly WorkflowActionItem[] {
122
+ export function workflowActionsFor(status: RunStatus): readonly WorkflowActionItem[] {
89
123
  switch (status) {
90
124
  case "pending":
91
125
  case "pausing":
92
- case "synthesizing":
93
126
  case "stopping":
94
127
  return [REFRESH, STOP, BACK];
95
128
  case "running":
@@ -145,25 +178,29 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
145
178
  return;
146
179
  }
147
180
  while (true) {
148
- const runs = engine.list();
181
+ const states = engine
182
+ .list()
183
+ .map((summary) => engine.getRun(String(summary.runId)))
184
+ .filter((run): run is ScriptRun => run !== undefined)
185
+ .sort((a, b) => b.startedAt - a.startedAt);
149
186
  const selected = await selectList<string>(
150
187
  ctx,
151
188
  "Workflow runs",
152
- runs.map((run) => ({
189
+ states.map((run) => ({
153
190
  value: run.runId,
154
191
  label: formatWorkflowRunLabel(run),
155
192
  description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
156
193
  })),
157
194
  (theme) =>
158
- runs.length > 0
195
+ states.length > 0
159
196
  ? theme.fg("dim", "Select a run to inspect.")
160
197
  : theme.fg("muted", "No workflow runs in this session."),
161
198
  );
162
199
  if (!selected) return;
163
200
  while (true) {
164
- const run = engine.get(selected);
201
+ const run = engine.getRun(selected);
165
202
  if (!run) break;
166
- const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
203
+ const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
167
204
  const action = await selectList<WorkflowAction>(
168
205
  ctx,
169
206
  `${name} · ${run.status}`,
@@ -0,0 +1,100 @@
1
+ /** Small, package-owned workflow presentation settings. */
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export type WorkflowProgressMode = "compact" | "detailed";
8
+
9
+ export interface WorkflowSettings {
10
+ progressMode?: WorkflowProgressMode;
11
+ maxAgentsShown?: number;
12
+ effort?: "off" | "high" | "ultra";
13
+ keywordTriggerWord?: string;
14
+ keywordTriggerEnabled?: boolean;
15
+ }
16
+
17
+ const DEFAULT_SETTINGS: Required<
18
+ Pick<WorkflowSettings, "progressMode" | "maxAgentsShown" | "effort" | "keywordTriggerEnabled">
19
+ > = {
20
+ progressMode: "compact",
21
+ maxAgentsShown: 6,
22
+ effort: "off",
23
+ keywordTriggerEnabled: true,
24
+ };
25
+
26
+ function projectDirectory(cwd: string): string {
27
+ const absolute = resolve(cwd);
28
+ const digest = createHash("sha256").update(absolute).digest("hex").slice(0, 12);
29
+ return join(getAgentDir(), "workflows", "projects", `${basename(absolute) || "root"}-${digest}`);
30
+ }
31
+
32
+ function readSettings(path: string): WorkflowSettings {
33
+ if (!existsSync(path)) return {};
34
+ try {
35
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
36
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
37
+ const value = parsed as Record<string, unknown>;
38
+ return {
39
+ ...(value.progressMode === "compact" || value.progressMode === "detailed"
40
+ ? { progressMode: value.progressMode }
41
+ : {}),
42
+ ...(typeof value.maxAgentsShown === "number" &&
43
+ Number.isInteger(value.maxAgentsShown) &&
44
+ value.maxAgentsShown >= 1 &&
45
+ value.maxAgentsShown <= 32
46
+ ? { maxAgentsShown: value.maxAgentsShown }
47
+ : {}),
48
+ ...(value.effort === "off" || value.effort === "high" || value.effort === "ultra"
49
+ ? { effort: value.effort }
50
+ : {}),
51
+ ...(typeof value.keywordTriggerWord === "string" &&
52
+ /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u.test(value.keywordTriggerWord)
53
+ ? { keywordTriggerWord: value.keywordTriggerWord.toLowerCase() }
54
+ : {}),
55
+ ...(typeof value.keywordTriggerEnabled === "boolean"
56
+ ? { keywordTriggerEnabled: value.keywordTriggerEnabled }
57
+ : {}),
58
+ };
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
64
+ export function loadWorkflowSettings(
65
+ cwd: string = process.cwd(),
66
+ ): WorkflowSettings & Required<Pick<WorkflowSettings, "progressMode" | "maxAgentsShown" | "effort">> {
67
+ const globalPath = join(getAgentDir(), "workflows", "settings.json");
68
+ const projectPath = join(projectDirectory(cwd), "settings.json");
69
+ return { ...DEFAULT_SETTINGS, ...readSettings(globalPath), ...readSettings(projectPath) };
70
+ }
71
+
72
+ export function saveWorkflowSettings(settings: WorkflowSettings, cwd: string = process.cwd()): void {
73
+ const safe: WorkflowSettings = {
74
+ ...(settings.progressMode === "compact" || settings.progressMode === "detailed"
75
+ ? { progressMode: settings.progressMode }
76
+ : {}),
77
+ ...(typeof settings.maxAgentsShown === "number" &&
78
+ Number.isInteger(settings.maxAgentsShown) &&
79
+ settings.maxAgentsShown >= 1 &&
80
+ settings.maxAgentsShown <= 32
81
+ ? { maxAgentsShown: settings.maxAgentsShown }
82
+ : {}),
83
+ ...(settings.effort === "off" || settings.effort === "high" || settings.effort === "ultra"
84
+ ? { effort: settings.effort }
85
+ : {}),
86
+ ...(typeof settings.keywordTriggerWord === "string" &&
87
+ /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u.test(settings.keywordTriggerWord)
88
+ ? { keywordTriggerWord: settings.keywordTriggerWord.toLowerCase() }
89
+ : {}),
90
+ ...(typeof settings.keywordTriggerEnabled === "boolean"
91
+ ? { keywordTriggerEnabled: settings.keywordTriggerEnabled }
92
+ : {}),
93
+ };
94
+ const directory = projectDirectory(cwd);
95
+ mkdirSync(directory, { recursive: true });
96
+ const path = join(directory, "settings.json");
97
+ const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
98
+ writeFileSync(temporary, `${JSON.stringify(safe, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
99
+ renameSync(temporary, path);
100
+ }