@davidorex/pi-workflows 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,23 +1,39 @@
1
1
  # pi-workflows
2
2
 
3
- Workflow orchestration extension for [Pi](https://github.com/badlogic/pi-mono). Define multi-step workflows in YAML, execute them as DAGs with typed data flow between agents, and resume from checkpoints on failure.
3
+ Schema-driven workflow orchestration for [Pi](https://github.com/badlogic/pi-mono).
4
+
5
+ Data flows through workflows as typed JSON, not strings. Each agent step declares an `output.schema` — the agent's output is validated against a JSON Schema before it's accepted into the pipeline. The expression engine passes typed fields between steps (`${{ steps.investigate.output.findings }}`), not raw text. Templates compose typed data into agent prompts, so agents receive structured context. The entire pipeline is schema-governed: **schema defines shape → agent produces to shape → validator enforces shape → next step consumes typed fields.**
6
+
7
+ The schemas in `schemas/` (investigation-findings, decomposition-specs, execution-results, etc.) are the typed contracts between workflow steps. They're not metadata — they're the enforcement boundary.
4
8
 
5
9
  ## Install
6
10
 
7
11
  ```bash
8
- pi install pi-workflows
12
+ pi install npm:@davidorex/pi-project # peer dependency
13
+ pi install npm:@davidorex/pi-workflows
14
+ ```
15
+
16
+ Or install both at once: `pi install npm:@davidorex/pi-project-workflows`
17
+
18
+ ## Getting Started
19
+
20
+ ```
21
+ /workflow init
9
22
  ```
10
23
 
11
- Requires `pi-project` as a peer dependency (installed separately).
24
+ Creates `.workflows/` for run state. Workflow YAML specs are discovered automatically from the package's bundled workflows — no setup needed to start running them.
12
25
 
13
26
  ## What It Does
14
27
 
15
28
  pi-workflows replaces ad-hoc agent chaining with composable, typed workflow orchestration. Workflows are YAML specs. Steps run as subprocesses (`pi --mode json`) with their own context windows. The main conversation is the control plane; workflows are subordinate.
16
29
 
30
+ Workflows consume project blocks as typed input via `readBlock()` — structured data, not raw files. When workflow agents write back to project blocks, pi-project's schema validation enforces the shape at write time. The two extensions form a typed loop: project state → workflow input → agent output → validated project state.
31
+
17
32
  **Tool registered:**
18
33
  - `workflow` — run a named workflow with typed input
19
34
 
20
35
  **Commands registered:**
36
+ - `/workflow init` — scaffold `.workflows/` directory for run state
21
37
  - `/workflow list` — discover and select a workflow to run
22
38
  - `/workflow run <name> [--input '<json>']` — execute a workflow
23
39
  - `/workflow resume <name>` — resume an incomplete run from checkpoint
@@ -28,7 +44,7 @@ pi-workflows replaces ad-hoc agent chaining with composable, typed workflow orch
28
44
 
29
45
  ## Workflow Spec Format
30
46
 
31
- Workflows are `.workflow.yaml` files discovered from `.pi/workflows/` (project), `~/.pi/agent/workflows/` (user), or the package's `workflows/` directory (builtin).
47
+ Workflows are `.workflow.yaml` files discovered from `.workflows/` (project), `~/.pi/agent/workflows/` (user), or the package's `workflows/` directory (builtin).
32
48
 
33
49
  ```yaml
34
50
  name: my-workflow
@@ -143,7 +159,7 @@ declaredSchemaRefs(spec): string[]
143
159
  - DAG planner infers parallelism from `${{ steps.X }}` references — no manual dependency declaration
144
160
  - Agent specs are `.agent.yaml` only. Compiled to prompts via Nunjucks at dispatch time.
145
161
  - State persisted atomically after each step (tmp + rename). State write failure is fatal.
146
- - Three-tier resource search: project `.pi/` > user `~/.pi/agent/` > package builtin
162
+ - Three-tier resource search: project (`.workflows/`, `.pi/agents/`, `.pi/templates/`) > user `~/.pi/agent/` > package builtin
147
163
  - `completion` field controls what message is sent back to the main conversation after a workflow finishes
148
164
 
149
165
  ## For LLMs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidorex/pi-workflows",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Workflow orchestration extension for Pi",
5
5
  "license": "MIT",
6
6
  "author": "David Ryan",
@@ -18,7 +18,7 @@ describe("findIncompleteRun", () => {
18
18
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
19
19
  t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
20
20
 
21
- const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs", "test-20260314-120000-abcd");
21
+ const runDir = path.join(tmpDir, ".workflows", "runs", "test", "runs", "test-20260314-120000-abcd");
22
22
  fs.mkdirSync(runDir, { recursive: true });
23
23
  writeState(runDir, { input: {}, steps: {}, status: "completed" });
24
24
 
@@ -30,7 +30,7 @@ describe("findIncompleteRun", () => {
30
30
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
31
31
  t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
32
32
 
33
- const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs", "test-20260314-120000-abcd");
33
+ const runDir = path.join(tmpDir, ".workflows", "runs", "test", "runs", "test-20260314-120000-abcd");
34
34
  fs.mkdirSync(runDir, { recursive: true });
35
35
 
36
36
  const state: ExecutionState = {
@@ -57,7 +57,7 @@ describe("findIncompleteRun", () => {
57
57
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
58
58
  t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
59
59
 
60
- const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs", "test-20260314-120000-abcd");
60
+ const runDir = path.join(tmpDir, ".workflows", "runs", "test", "runs", "test-20260314-120000-abcd");
61
61
  fs.mkdirSync(runDir, { recursive: true });
62
62
 
63
63
  const state: ExecutionState = {
@@ -88,7 +88,7 @@ describe("findIncompleteRun", () => {
88
88
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
89
89
  t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
90
90
 
91
- const runsBase = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs");
91
+ const runsBase = path.join(tmpDir, ".workflows", "runs", "test", "runs");
92
92
 
93
93
  // Older run (completed)
94
94
  const oldDir = path.join(runsBase, "test-20260314-100000-aaaa");
package/src/checkpoint.ts CHANGED
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import type { ExecutionState, WorkflowSpec } from "./types.ts";
10
10
  import { readState } from "./state.ts";
11
+ import { WORKFLOWS_DIR } from "./workflows-dir.ts";
11
12
 
12
13
  export interface IncompleteRun {
13
14
  runId: string;
@@ -21,14 +22,14 @@ export interface IncompleteRun {
21
22
  /**
22
23
  * Find the most recent incomplete run for a workflow.
23
24
  *
24
- * Scans .pi/workflow-runs/<name>/runs/ for state.json files
25
+ * Scans .workflows/runs/<name>/runs/ for state.json files
25
26
  * with status "running" or "failed". Returns the most recent one
26
27
  * (by directory name, which encodes timestamp).
27
28
  *
28
29
  * Returns null if no incomplete runs exist.
29
30
  */
30
31
  export function findIncompleteRun(cwd: string, workflowName: string): IncompleteRun | null {
31
- const runsDir = path.join(cwd, ".pi", "workflow-runs", workflowName, "runs");
32
+ const runsDir = path.join(cwd, WORKFLOWS_DIR, "runs", workflowName, "runs");
32
33
  if (!fs.existsSync(runsDir)) return null;
33
34
 
34
35
  const entries = fs.readdirSync(runsDir)
@@ -6,13 +6,15 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import os from "node:os";
8
8
 
9
- // Skip integration tests if pi is not available
9
+ // Skip integration tests unless RUN_INTEGRATION=1 and pi is available
10
10
  let hasPi = false;
11
- try {
12
- const { execSync } = await import("node:child_process");
13
- execSync("pi --version", { stdio: "ignore" });
14
- hasPi = true;
15
- } catch {}
11
+ if (process.env.RUN_INTEGRATION === "1") {
12
+ try {
13
+ const { execSync } = await import("node:child_process");
14
+ execSync("pi --version", { stdio: "ignore" });
15
+ hasPi = true;
16
+ } catch {}
17
+ }
16
18
 
17
19
  // ── Unit tests: extractText ──
18
20
 
@@ -199,7 +199,7 @@ describe("graduated failure retry", () => {
199
199
  beforeEach(() => {
200
200
  tmpDir = makeTmpDir();
201
201
  // Create minimal run dir structure
202
- const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs");
202
+ const runDir = path.join(tmpDir, ".workflows", "runs", "test", "runs");
203
203
  fs.mkdirSync(runDir, { recursive: true });
204
204
  });
205
205
 
package/src/index.ts CHANGED
@@ -7,7 +7,9 @@ import { executeWorkflow, requestPause } from "./workflow-executor.ts";
7
7
  import { findIncompleteRun, validateResumeCompatibility, formatIncompleteRun } from "./checkpoint.ts";
8
8
  import { createAgentLoader } from "./agent-spec.ts";
9
9
  import type { WorkflowResult } from "./types.ts";
10
+ import { WORKFLOWS_DIR } from "./workflows-dir.ts";
10
11
  import fs from "node:fs";
12
+ import path from "node:path";
11
13
 
12
14
  import { Key } from "@mariozechner/pi-tui";
13
15
  import { Type } from "@sinclair/typebox";
@@ -126,7 +128,7 @@ function formatToolResult(result: WorkflowResult): string {
126
128
  async function handleList(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
127
129
  const workflows = discoverWorkflows(ctx.cwd);
128
130
  if (workflows.length === 0) {
129
- ctx.ui.notify("No workflows found in .pi/workflows/ or ~/.pi/agent/workflows/", "info");
131
+ ctx.ui.notify("No workflows found in .workflows/ or ~/.pi/agent/workflows/", "info");
130
132
  return;
131
133
  }
132
134
 
@@ -333,6 +335,30 @@ async function handleResume(rawArgs: string, ctx: ExtensionCommandContext, pi: E
333
335
  }
334
336
  }
335
337
 
338
+ /**
339
+ * /workflow init — scaffold .workflows/ directory for user workflow specs
340
+ * and run state. Idempotent.
341
+ */
342
+ function handleWorkflowInit(ctx: ExtensionCommandContext): void {
343
+ const workflowsDir = path.join(ctx.cwd, WORKFLOWS_DIR);
344
+ const runsDir = path.join(workflowsDir, "runs");
345
+
346
+ const created: string[] = [];
347
+
348
+ for (const dir of [workflowsDir, runsDir]) {
349
+ if (!fs.existsSync(dir)) {
350
+ fs.mkdirSync(dir, { recursive: true });
351
+ created.push(path.relative(ctx.cwd, dir) + "/");
352
+ }
353
+ }
354
+
355
+ if (created.length > 0) {
356
+ ctx.ui.notify(`Workflows initialized: created ${created.join(", ")}`, "info");
357
+ } else {
358
+ ctx.ui.notify("Workflows already initialized — nothing to do.", "info");
359
+ }
360
+ }
361
+
336
362
  // ── Extension factory ───────────────────────────────────────────────────────
337
363
 
338
364
  const extension = (pi: ExtensionAPI) => {
@@ -341,7 +367,7 @@ const extension = (pi: ExtensionAPI) => {
341
367
  pi.registerTool({
342
368
  name: "workflow",
343
369
  label: "Workflow",
344
- description: "Run a named workflow with typed input. Discovers workflows from .pi/workflows/ and ~/.pi/agent/workflows/.",
370
+ description: "Run a named workflow with typed input. Discovers workflows from .workflows/ and ~/.pi/agent/workflows/.",
345
371
  promptSnippet: "Run a multi-step workflow with typed data flow between agents",
346
372
  parameters: Type.Object({
347
373
  workflow: Type.String({ description: "Name of the workflow to run" }),
@@ -403,7 +429,7 @@ const extension = (pi: ExtensionAPI) => {
403
429
  pi.registerCommand("workflow", {
404
430
  description: "List and run workflows",
405
431
  getArgumentCompletions: (prefix: string) => {
406
- const subcommands = ["run", "list", "resume"];
432
+ const subcommands = ["init", "run", "list", "resume"];
407
433
  return subcommands
408
434
  .filter((s) => s.startsWith(prefix))
409
435
  .map((s) => ({ value: s, label: s }));
@@ -415,14 +441,16 @@ const extension = (pi: ExtensionAPI) => {
415
441
  const subcommand = spaceIdx === -1 ? trimmed || "list" : trimmed.slice(0, spaceIdx);
416
442
  const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
417
443
 
418
- if (subcommand === "list") {
444
+ if (subcommand === "init") {
445
+ handleWorkflowInit(ctx);
446
+ } else if (subcommand === "list") {
419
447
  await handleList(ctx, pi);
420
448
  } else if (subcommand === "run") {
421
449
  await handleRun(rest, ctx, pi);
422
450
  } else if (subcommand === "resume") {
423
451
  await handleResume(rest, ctx, pi);
424
452
  } else {
425
- ctx.ui.notify(`Unknown subcommand: ${subcommand}. Use: list, run, resume`, "warning");
453
+ ctx.ui.notify(`Unknown subcommand: ${subcommand}. Use: init, list, run, resume`, "warning");
426
454
  }
427
455
  },
428
456
  });
package/src/loop.test.ts CHANGED
@@ -17,13 +17,15 @@ function defaultOptions(tmpDir?: string) {
17
17
  };
18
18
  }
19
19
 
20
- // Skip if pi is not available
20
+ // Skip integration tests unless RUN_INTEGRATION=1 and pi is available
21
21
  let hasPi = false;
22
- try {
23
- const { execSync } = await import("node:child_process");
24
- execSync("pi --version", { stdio: "ignore" });
25
- hasPi = true;
26
- } catch {}
22
+ if (process.env.RUN_INTEGRATION === "1") {
23
+ try {
24
+ const { execSync } = await import("node:child_process");
25
+ execSync("pi --version", { stdio: "ignore" });
26
+ hasPi = true;
27
+ } catch {}
28
+ }
27
29
 
28
30
  describe("loop steps", () => {
29
31
  it("breaks on gate pass", async (t: any) => {
package/src/state.ts CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import type { ExecutionState, WorkflowResult, WorkflowSpec, StepResult, StepUsage } from "./types.ts";
5
5
  import { formatDuration, formatCost } from "./format.ts";
6
+ import { WORKFLOWS_DIR } from "./workflows-dir.ts";
6
7
 
7
8
  /**
8
9
  * Generate a unique run ID.
@@ -24,11 +25,11 @@ export function generateRunId(workflowName: string): string {
24
25
  /**
25
26
  * Initialize the run directory structure.
26
27
  * Creates:
27
- * .pi/workflow-runs/<workflowName>/runs/<runId>/
28
- * .pi/workflow-runs/<workflowName>/runs/<runId>/sessions/
29
- * .pi/workflow-runs/<workflowName>/runs/<runId>/outputs/
28
+ * .workflows/runs/<workflowName>/runs/<runId>/
29
+ * .workflows/runs/<workflowName>/runs/<runId>/sessions/
30
+ * .workflows/runs/<workflowName>/runs/<runId>/outputs/
30
31
  *
31
- * Each workflow owns a directory under .pi/workflow-runs/<name>/.
32
+ * Each workflow owns a directory under .workflows/runs/<name>/.
32
33
  * Run state goes in runs/<runId>/; artifacts live at the workflow level.
33
34
  *
34
35
  * @param cwd - project root
@@ -37,7 +38,7 @@ export function generateRunId(workflowName: string): string {
37
38
  * @returns absolute path to the run directory
38
39
  */
39
40
  export function initRunDir(cwd: string, workflowName: string, runId: string): string {
40
- const runDir = path.join(cwd, ".pi", "workflow-runs", workflowName, "runs", runId);
41
+ const runDir = path.join(cwd, WORKFLOWS_DIR, "runs", workflowName, "runs", runId);
41
42
  fs.mkdirSync(path.join(runDir, "sessions"), { recursive: true });
42
43
  fs.mkdirSync(path.join(runDir, "outputs"), { recursive: true });
43
44
  return runDir;
@@ -49,10 +50,10 @@ export function initRunDir(cwd: string, workflowName: string, runId: string): st
49
50
  *
50
51
  * @param cwd - project root
51
52
  * @param workflowName - name of the workflow
52
- * @returns absolute path to .pi/workflow-runs/<workflowName>/
53
+ * @returns absolute path to .workflows/runs/<workflowName>/
53
54
  */
54
55
  export function getWorkflowDir(cwd: string, workflowName: string): string {
55
- return path.join(cwd, ".pi", "workflow-runs", workflowName);
56
+ return path.join(cwd, WORKFLOWS_DIR, "runs", workflowName);
56
57
  }
57
58
 
58
59
  /**
package/src/types.ts CHANGED
@@ -159,7 +159,7 @@ export interface WorkflowResult {
159
159
  output?: unknown; // final workflow output (last step's output, or explicit)
160
160
  totalUsage: StepUsage;
161
161
  totalDurationMs: number;
162
- runDir: string; // absolute path to .pi/workflow-runs/<name>/runs/<run-id>/
162
+ runDir: string; // absolute path to .workflows/runs/<name>/runs/<run-id>/
163
163
  artifacts?: Record<string, string>; // name → absolute path of written artifact files
164
164
  }
165
165
 
@@ -11,7 +11,7 @@ const NO_BUILTINS = path.join(os.tmpdir(), "wf-no-builtins-nonexistent");
11
11
  describe("discoverWorkflows", () => {
12
12
  it("discovers workflows from project directory", () => {
13
13
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
14
- const wfDir = path.join(tmpDir, ".pi", "workflows");
14
+ const wfDir = path.join(tmpDir, ".workflows");
15
15
  fs.mkdirSync(wfDir, { recursive: true });
16
16
  fs.writeFileSync(
17
17
  path.join(wfDir, "test.workflow.yaml"),
@@ -35,7 +35,7 @@ describe("discoverWorkflows", () => {
35
35
 
36
36
  it("skips invalid specs with warning (no throw)", () => {
37
37
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
38
- const wfDir = path.join(tmpDir, ".pi", "workflows");
38
+ const wfDir = path.join(tmpDir, ".workflows");
39
39
  fs.mkdirSync(wfDir, { recursive: true });
40
40
  fs.writeFileSync(path.join(wfDir, "bad.workflow.yaml"), "not: valid: workflow");
41
41
  fs.writeFileSync(
@@ -52,7 +52,7 @@ describe("discoverWorkflows", () => {
52
52
 
53
53
  it("project specs shadow user specs with same name", () => {
54
54
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
55
- const projectDir = path.join(tmpDir, ".pi", "workflows");
55
+ const projectDir = path.join(tmpDir, ".workflows");
56
56
  fs.mkdirSync(projectDir, { recursive: true });
57
57
  fs.writeFileSync(
58
58
  path.join(projectDir, "shared.workflow.yaml"),
@@ -83,7 +83,7 @@ describe("discoverWorkflows", () => {
83
83
  describe("findWorkflow", () => {
84
84
  it("finds a workflow by name", () => {
85
85
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
86
- const wfDir = path.join(tmpDir, ".pi", "workflows");
86
+ const wfDir = path.join(tmpDir, ".workflows");
87
87
  fs.mkdirSync(wfDir, { recursive: true });
88
88
  fs.writeFileSync(
89
89
  path.join(wfDir, "bugfix.workflow.yaml"),
@@ -3,12 +3,13 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import type { WorkflowSpec } from "./types.ts";
5
5
  import { parseWorkflowSpec } from "./workflow-spec.ts";
6
+ import { WORKFLOWS_DIR } from "./workflows-dir.ts";
6
7
 
7
8
  /**
8
9
  * Discover all workflow specs from project, user, and builtin directories.
9
10
  *
10
11
  * Scans (highest priority first):
11
- * 1. .pi/workflows/ (project-level, source: "project")
12
+ * 1. .workflows/ (project-level, source: "project")
12
13
  * 2. ~/.pi/agent/workflows/ (user-level, source: "user")
13
14
  * 3. <package>/workflows/ (builtin workflows, source: "user")
14
15
  *
@@ -20,7 +21,7 @@ import { parseWorkflowSpec } from "./workflow-spec.ts";
20
21
  * skipped with a warning (logged to stderr), not thrown.
21
22
  */
22
23
  export function discoverWorkflows(cwd: string, builtinDir?: string): WorkflowSpec[] {
23
- const projectDir = path.join(cwd, ".pi", "workflows");
24
+ const projectDir = path.join(cwd, WORKFLOWS_DIR);
24
25
  const userDir = path.join(os.homedir(), ".pi", "agent", "workflows");
25
26
  const demoDir = builtinDir ?? path.resolve(import.meta.dirname, "..", "workflows");
26
27
 
@@ -7,13 +7,15 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import os from "node:os";
9
9
 
10
- // Skip if pi is not available
10
+ // Skip integration tests unless RUN_INTEGRATION=1 and pi is available
11
11
  let hasPi = false;
12
- try {
13
- const { execSync } = await import("node:child_process");
14
- execSync("pi --version", { stdio: "ignore" });
15
- hasPi = true;
16
- } catch {}
12
+ if (process.env.RUN_INTEGRATION === "1") {
13
+ try {
14
+ const { execSync } = await import("node:child_process");
15
+ execSync("pi --version", { stdio: "ignore" });
16
+ hasPi = true;
17
+ } catch {}
18
+ }
17
19
 
18
20
  describe("executeWorkflow", { skip: !hasPi ? "pi not available" : undefined }, () => {
19
21
  it("runs a single-step workflow", async () => {
@@ -529,8 +531,8 @@ describe("artifacts", () => {
529
531
 
530
532
  assert.strictEqual(result.status, "completed");
531
533
  assert.ok(result.artifacts);
532
- // Artifact path should be under .pi/workflow-runs/<workflow-name>/
533
- const workflowDir = path.join(tmpDir, ".pi", "workflow-runs", "test-artifact-rel");
534
+ // Artifact path should be under .workflows/runs/<workflow-name>/
535
+ const workflowDir = path.join(tmpDir, ".workflows", "runs", "test-artifact-rel");
534
536
  const expectedPath = path.resolve(workflowDir, "latest.json");
535
537
  assert.strictEqual(result.artifacts!.report, expectedPath);
536
538
  assert.ok(fs.existsSync(expectedPath));
@@ -0,0 +1,2 @@
1
+ /** Workflow directory name — project-level workflow specs and run state */
2
+ export const WORKFLOWS_DIR = ".workflows";