@nicknisi/pi-workflows 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/engine.d.ts +102 -0
- package/dist/engine.js +167 -0
- package/dist/examples.test.d.ts +9 -0
- package/dist/examples.test.js +35 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +532 -0
- package/dist/workflow.test.d.ts +10 -0
- package/dist/workflow.test.js +299 -0
- package/engine.ts +295 -0
- package/index.ts +618 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Nisi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @nicknisi/pi-workflows
|
|
2
|
+
|
|
3
|
+
The model-facing front door to the first-party workflow engine. One `workflow` tool runs JavaScript workflow scripts that orchestrate subagents over the in-process runtime, replacing the third-party `@quintinshaw/pi-dynamic-workflows` extension. A `/wf` command is the thin human-facing wrapper.
|
|
4
|
+
|
|
5
|
+
## The platform story
|
|
6
|
+
|
|
7
|
+
Four pieces compose the workflow platform: `@nicknisi/pi-shared`'s **subagent runtime** (hermetic in-process child sessions), `@nicknisi/pi-codemode`'s **VM** approach (compile a model-written script in `node:vm` with injected bindings), `@nicknisi/pi-shared`'s **`workflow.ts` engine** (declarative multi-stage DAGs with needs/foreach/gates/retries), and **this tool** as the model-facing front door that ties them together with the script contract the old third-party engine used. The third-party `@quintinshaw/pi-dynamic-workflows` engine is being evicted — its script contract lives on unchanged here, its built-in pattern library / model tiers / agent-type registry / trigger-word arming do not.
|
|
8
|
+
|
|
9
|
+
## What it adds
|
|
10
|
+
|
|
11
|
+
- **`workflow` tool** (model-facing) — actions: `run` (inline JS `script` OR `name` of a saved workflow file), `list`, `status <runId>`, `stop <runId>`.
|
|
12
|
+
- **`/wf` command** (human-facing) — `/wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId>`.
|
|
13
|
+
|
|
14
|
+
## The script contract
|
|
15
|
+
|
|
16
|
+
A workflow script is a JavaScript **statement body** (no imports) with a leading `export const meta = { name, description }` declaration and a trailing `return value`. The body is wrapped in an async function so a top-level `return` compiles; `export const meta =` is rewritten so `node:vm` compiles it (a stranded `export` fails loudly) and `meta.name`/`meta.description` surface in the result.
|
|
17
|
+
|
|
18
|
+
Injected globals — the exact names the old third-party tool's scripts use, so existing scripts run unchanged:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
export const meta = { name: 'research', description: 'parallel research fan-out' };
|
|
22
|
+
|
|
23
|
+
const questions = ['How does the auth refresh flow work?', 'Where are sessions persisted?'];
|
|
24
|
+
const results = await parallel(questions.map((q) => () => agent(q, { label: 'researcher' })));
|
|
25
|
+
return { answered: results.length, results };
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Global | Behavior |
|
|
29
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
30
|
+
| `agent(prompt, opts)` | Spawns a hermetic in-process child via the subagent runtime (namespace `workflows`). Throws `${kind}: ${error}` on failure — wrap with a `safeAgent` that returns `{ ok, value, error }` so a failure inside `parallel()` reports which stage died instead of collapsing the wave to `null`. Returns `res.data ?? res.text ?? null`. |
|
|
31
|
+
| `parallel(thunks)` | `Promise.all` over zero-arg thunks — pass `() => agent(...)`, not `agent(...)`. |
|
|
32
|
+
| `pipeline(items, ...stages)` | Folds items through stages: each stage maps over the previous stage's outputs in parallel, producing the next array. |
|
|
33
|
+
| `phase(name)` | Logging marker only — NOT a budget boundary. Appends `── name` to the result logs. |
|
|
34
|
+
| `log(...args)` | Captured into the result logs. |
|
|
35
|
+
| `args` | The `args` JSON value passed to `run`. |
|
|
36
|
+
| `budget` | `{ total, spent, remaining }` over the run's token usage. `total` defaults to `Infinity`; `spent` accumulates across `agent()` calls. Read-only. |
|
|
37
|
+
| `cwd` | The session working directory. |
|
|
38
|
+
|
|
39
|
+
`agent()` opts: `model` (`'provider/id'`), `tools` (allowlist — default read-only `['read','grep','find','ls']`; pass `['read','bash','edit','write']` for builders), `label` (child agent label), `systemPrompt`, `schema` (validated; parsed JSON lands in `result.data`), `effort` (thinking level), `timeoutMs`, `maxTurns`, `worktree` (run the child in an isolated git worktree; on settle the change set is captured to a `.patch` and `agent()` returns `{ value, patchPath, runId }` instead of the bare value — opt-in, so non-worktree calls are unchanged), `agentType` (accepted but ignored — no agent-type registry; resolve `systemPrompt` in the script itself).
|
|
40
|
+
|
|
41
|
+
The script executes **in the host process with full Node access** — `process`, `require`, and `fs` are all reachable, the same trust boundary as the `bash` tool. Keep the returned value small: summaries, counts, key findings — never raw file dumps.
|
|
42
|
+
|
|
43
|
+
## Saved workflows
|
|
44
|
+
|
|
45
|
+
Plain files. The registry is `ls` — no database, no manifest, no config keys.
|
|
46
|
+
|
|
47
|
+
- `~/.pi/agent/workflows/*.js` — global.
|
|
48
|
+
- `.pi/workflows/*.js` — project-local, **trusted projects only** (the same trust gate as codemode's `/cx`). Untrusted projects see only global workflows.
|
|
49
|
+
|
|
50
|
+
Names are bare file stems (`research`, not `research.js`, never a path — `..` and `/` are rejected to prevent escaping the workflows dirs). Global shadows a same-named project workflow. Files are read on demand, so `/reload` needs no workflow-specific wiring.
|
|
51
|
+
|
|
52
|
+
## Runs are visible
|
|
53
|
+
|
|
54
|
+
Every `agent()` call spawns through `@nicknisi/pi-shared`'s subagent runtime with `artifactsDir` set to `~/.pi/agent/subagent-runs/` and namespace `workflows`, so child spawns appear in the `fleet` tool / `/fleet` command from `@nicknisi/pi-subagents`. `status <runId>` and `stop <runId>` here read from / cancel via the same runtime's run records — no parallel store. `stop` cancels in-flight spawns through a live `runId → AbortController` registry (mirroring subagents' cascading-cancellation); a run belonging to a different host process is reported as not cancellable from here.
|
|
55
|
+
|
|
56
|
+
## Migration from `@quintinshaw/pi-dynamic-workflows`
|
|
57
|
+
|
|
58
|
+
| Old concept | New home |
|
|
59
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
60
|
+
| Built-in named patterns (e.g. `research`, `review`) | Example `.js` files you drop in `~/.pi/agent/workflows/`. No built-in library — the registry is `ls`. |
|
|
61
|
+
| Model tiers (`fast` / `balanced` / `deep`) | Explicit `model:` strings passed to `agent(prompt, { model: 'anthropic/claude-haiku-4-5' })`. No tier registry. |
|
|
62
|
+
| `agentType` → tool/systemPrompt resolution | Resolve `systemPrompt` in the script itself: `agent(prompt, { systemPrompt: 'You are a reviewer…', tools: ['read','grep','bash'] })`. `agentType` is accepted but ignored (logged). |
|
|
63
|
+
| Trigger-word arming (the tool activates on keywords) | The model calls the `workflow` tool when intent warrants — no arming, no keyword matching. |
|
|
64
|
+
| Phases with per-phase budgets | `phase(name)` is a logging marker only. Budget is a single run-level `{ total, spent, remaining }`; per-stage budgets are the `workflow.ts` engine's `tokenBudget` (use `runWorkflow` from codemode for that). |
|
|
65
|
+
| The third-party engine's script globals | Unchanged: `args`, `agent`, `parallel`, `pipeline`, `phase`, `log`, `budget`, `cwd`. Existing scripts run as-is. |
|
|
66
|
+
|
|
67
|
+
## Recipes
|
|
68
|
+
|
|
69
|
+
The `examples/` directory ships standalone, copy-and-adapt workflow scripts — the registry is `ls`, so these are code you read and copy, never APIs you import (no index re-exports them). Drop any of them into `~/.pi/agent/workflows/` and run via `/wf run <name>`.
|
|
70
|
+
|
|
71
|
+
- **`lanes.js`** — N parallel agents editing FILE-DISJOINT lanes of one repo under a hard-rules preamble (each lane owns a fixed file set; no git, no installs; the parent integrates centrally). Use it when a task splits into independent edits that don't overlap on files. Adapt by setting `VERIFY` to your typecheck command and filling the `LANES` array with `{ name, files, brief }` per lane.
|
|
72
|
+
- **`gates.js`** — three judge/verify prompt builders returning prompt strings: adversarial refutation (defeats confirmation bias), deep-research coverage (defeats silent source omission), and a 3-way code-review verdict (defeats verdict collapse). Use it when you need a reliable gate inside your own workflow. Adapt by copying the builder whose failure mode you need and calling it from an `agent()` with a JSON schema. Prompt patterns distilled from `@quintinshaw/pi-dynamic-workflows`.
|
|
73
|
+
- **`bake-off.js`** — race N models on the SAME task in isolated worktrees (`worktree: true`), then an advisory judge reads each contender's `.patch` and picks a winner. Use it on hard build tasks where a single GLM-5.2-class builder produces decent-but-flawed code; the 2x token cost buys a measurably better hit rate. Adapt by setting `CONTENDERS` to the models to race and passing `task` in `args`; the workflow returns the winner's `patchPath` to apply via `/patches`.
|
|
74
|
+
|
|
75
|
+
## Dependencies
|
|
76
|
+
|
|
77
|
+
- `@nicknisi/pi-shared` (`workspace:*`) — the subagent runtime and `runWorkflow` engine.
|
|
78
|
+
- `typebox` — the tool's parameter schema.
|
|
79
|
+
- `@earendil-works/pi-coding-agent` (peer) — the extension API, `getAgentDir`, `CONFIG_DIR_NAME`.
|
|
80
|
+
|
|
81
|
+
## Caveats
|
|
82
|
+
|
|
83
|
+
- The script runs in the host process with full Node access — the same trust boundary as the `bash` and `codemode` tools. Your model, your session.
|
|
84
|
+
- Project-local workflows (`.pi/workflows/`) load only in trusted projects; untrusted projects are limited to global workflows so a cloned repo cannot silently inject orchestration scripts.
|
|
85
|
+
- `agent()` cannot spawn children of its own (the ecosystem recursion guard refuses nested orchestration). For dependent multi-stage work where stages spawn, use `@nicknisi/pi-codemode`'s `runWorkflow` instead.
|
|
86
|
+
- `stop` cancels only runs spawned by this host process; persisted runs from other hosts show in `status` but are not cancellable here.
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow script engine — pi-free and testable.
|
|
3
|
+
*
|
|
4
|
+
* A workflow script is a JavaScript statement body with injected globals
|
|
5
|
+
* (args, agent, parallel, pipeline, phase, log, budget, cwd) and a leading
|
|
6
|
+
* `export const meta = { name, description }` declaration. It returns a value
|
|
7
|
+
* by evaluating a trailing expression or a top-level `return` (the body is
|
|
8
|
+
* wrapped in an async function so a bare `return` compiles).
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately imports nothing from pi or @nicknisi/pi-shared so
|
|
11
|
+
* the test suite can exercise it without an install step: the spawn function
|
|
12
|
+
* is injected. index.ts wires it to the shared subagent runtime.
|
|
13
|
+
*
|
|
14
|
+
* The compile model mirrors ~/Developer/ideation/workflows/engine-host.mjs:
|
|
15
|
+
* `export const meta =` is rewritten to an outer-binding assignment so the vm
|
|
16
|
+
* compiles (a stranded `export` fails loudly) and the tool can surface
|
|
17
|
+
* meta.name/description.
|
|
18
|
+
*/
|
|
19
|
+
export interface EngineSpawnUsage {
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
totalTokens: number;
|
|
23
|
+
cost?: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
export interface EngineSpawnOk {
|
|
26
|
+
ok: true;
|
|
27
|
+
text: string;
|
|
28
|
+
data?: unknown;
|
|
29
|
+
usage: EngineSpawnUsage;
|
|
30
|
+
/** Run id of the child spawn, when the spawn fn attaches it. */
|
|
31
|
+
runId?: string;
|
|
32
|
+
/** Path to the worktree `.patch` file, for `worktree: true` runs that changed files. */
|
|
33
|
+
patchPath?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface EngineSpawnFail {
|
|
36
|
+
ok: false;
|
|
37
|
+
kind: string;
|
|
38
|
+
error: string;
|
|
39
|
+
text: string;
|
|
40
|
+
usage: EngineSpawnUsage;
|
|
41
|
+
}
|
|
42
|
+
export type EngineSpawnResult = EngineSpawnOk | EngineSpawnFail;
|
|
43
|
+
export interface EngineSpawnOptions {
|
|
44
|
+
prompt: string;
|
|
45
|
+
agent?: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
tools?: string[];
|
|
48
|
+
systemPrompt?: string;
|
|
49
|
+
thinkingLevel?: string;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
maxTurns?: number;
|
|
52
|
+
outputSchema?: unknown;
|
|
53
|
+
/** Run the child in an isolated git worktree; its change set is captured to a `.patch`. */
|
|
54
|
+
worktree?: boolean;
|
|
55
|
+
}
|
|
56
|
+
export type EngineSpawnFn = (opts: EngineSpawnOptions) => Promise<EngineSpawnResult>;
|
|
57
|
+
export interface EngineBudget {
|
|
58
|
+
total: number;
|
|
59
|
+
spent: number;
|
|
60
|
+
remaining: number;
|
|
61
|
+
}
|
|
62
|
+
export interface ScriptMeta {
|
|
63
|
+
name?: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
[k: string]: unknown;
|
|
66
|
+
}
|
|
67
|
+
export interface RunScriptOptions {
|
|
68
|
+
script: string;
|
|
69
|
+
args?: unknown;
|
|
70
|
+
spawn: EngineSpawnFn;
|
|
71
|
+
cwd: string;
|
|
72
|
+
budgetTotal?: number;
|
|
73
|
+
onLog?: (line: string) => void;
|
|
74
|
+
}
|
|
75
|
+
export interface RunScriptResult {
|
|
76
|
+
value: unknown;
|
|
77
|
+
meta: ScriptMeta | undefined;
|
|
78
|
+
logs: string[];
|
|
79
|
+
usage: EngineSpawnUsage;
|
|
80
|
+
durationMs: number;
|
|
81
|
+
}
|
|
82
|
+
export declare function runScript(opts: RunScriptOptions): Promise<RunScriptResult>;
|
|
83
|
+
/** The compiled async body; invoking it runs the script with injected globals. */
|
|
84
|
+
export type CompiledFn = (args: unknown, agent: unknown, parallel: unknown, pipeline: unknown, phase: unknown, log: unknown, budget: unknown, cwd: string) => Promise<unknown>;
|
|
85
|
+
export interface CompiledScript {
|
|
86
|
+
/** The script's `meta` export, read via a stub dry-run (no real spawn). */
|
|
87
|
+
meta: ScriptMeta | undefined;
|
|
88
|
+
/** Invoke to run the script; spawn-backed globals must be supplied. */
|
|
89
|
+
fn: CompiledFn;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Compile a workflow script and read its `meta` export WITHOUT a real spawn.
|
|
93
|
+
*
|
|
94
|
+
* `export const meta =` is rewritten to an outer-binding assignment so the vm
|
|
95
|
+
* compiles (a stranded `export` fails loudly) and `meta` is captured into a
|
|
96
|
+
* holder. The body is wrapped in an async function so a top-level `return`
|
|
97
|
+
* compiles. `meta` is populated by a stub dry-run — `agent` returns `null`,
|
|
98
|
+
* `parallel`/`pipeline` are `Promise.all`-style folds over those stubs — so the
|
|
99
|
+
* script's `meta` declaration (which well-formed scripts place first) is read
|
|
100
|
+
* without spawning any child sessions. A syntax error throws here.
|
|
101
|
+
*/
|
|
102
|
+
export declare function compileScript(script: string): CompiledScript;
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow script engine — pi-free and testable.
|
|
3
|
+
*
|
|
4
|
+
* A workflow script is a JavaScript statement body with injected globals
|
|
5
|
+
* (args, agent, parallel, pipeline, phase, log, budget, cwd) and a leading
|
|
6
|
+
* `export const meta = { name, description }` declaration. It returns a value
|
|
7
|
+
* by evaluating a trailing expression or a top-level `return` (the body is
|
|
8
|
+
* wrapped in an async function so a bare `return` compiles).
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately imports nothing from pi or @nicknisi/pi-shared so
|
|
11
|
+
* the test suite can exercise it without an install step: the spawn function
|
|
12
|
+
* is injected. index.ts wires it to the shared subagent runtime.
|
|
13
|
+
*
|
|
14
|
+
* The compile model mirrors ~/Developer/ideation/workflows/engine-host.mjs:
|
|
15
|
+
* `export const meta =` is rewritten to an outer-binding assignment so the vm
|
|
16
|
+
* compiles (a stranded `export` fails loudly) and the tool can surface
|
|
17
|
+
* meta.name/description.
|
|
18
|
+
*/
|
|
19
|
+
import vm from 'node:vm';
|
|
20
|
+
// ── Internals ──────────────────────────────────────────────────────────────
|
|
21
|
+
const STRIP_META = /export\s+const\s+meta\s*=/;
|
|
22
|
+
const DEFAULT_TOOLS = ['read', 'grep', 'find', 'ls'];
|
|
23
|
+
const MAX_LOG_ENTRIES = 200;
|
|
24
|
+
const MAX_LOG_CHARS = 2000;
|
|
25
|
+
function truncate(line, max) {
|
|
26
|
+
return line.length <= max ? line : `${line.slice(0, max)}…[truncated at ${max} chars]`;
|
|
27
|
+
}
|
|
28
|
+
function safeStringify(v) {
|
|
29
|
+
if (typeof v === 'string')
|
|
30
|
+
return v;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(v);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return String(v);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function zeroUsage() {
|
|
39
|
+
return { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
40
|
+
}
|
|
41
|
+
function addUsage(a, b) {
|
|
42
|
+
a.inputTokens += b.inputTokens;
|
|
43
|
+
a.outputTokens += b.outputTokens;
|
|
44
|
+
a.totalTokens += b.totalTokens;
|
|
45
|
+
if (b.cost !== undefined)
|
|
46
|
+
a.cost = (a.cost ?? 0) + b.cost;
|
|
47
|
+
}
|
|
48
|
+
// ── Engine ─────────────────────────────────────────────────────────────────
|
|
49
|
+
export async function runScript(opts) {
|
|
50
|
+
const { script, spawn, cwd } = opts;
|
|
51
|
+
const args = opts.args;
|
|
52
|
+
const budgetTotal = opts.budgetTotal ?? Infinity;
|
|
53
|
+
const logs = [];
|
|
54
|
+
const usage = zeroUsage();
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
const log = (...parts) => {
|
|
57
|
+
if (logs.length >= MAX_LOG_ENTRIES)
|
|
58
|
+
return;
|
|
59
|
+
const line = parts.map(safeStringify).join(' ');
|
|
60
|
+
const t = truncate(line, MAX_LOG_CHARS);
|
|
61
|
+
logs.push(t);
|
|
62
|
+
opts.onLog?.(t);
|
|
63
|
+
};
|
|
64
|
+
const phase = (name) => {
|
|
65
|
+
const line = `── ${name}`;
|
|
66
|
+
logs.push(line);
|
|
67
|
+
opts.onLog?.(line);
|
|
68
|
+
};
|
|
69
|
+
const budget = {
|
|
70
|
+
get total() {
|
|
71
|
+
return budgetTotal;
|
|
72
|
+
},
|
|
73
|
+
get spent() {
|
|
74
|
+
return usage.totalTokens;
|
|
75
|
+
},
|
|
76
|
+
get remaining() {
|
|
77
|
+
return budgetTotal - usage.totalTokens;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
// agent(prompt, opts) backed by the injected spawn. A failure kind becomes
|
|
81
|
+
// a throw so a script's safeAgent wrapper converts it into a typed stage
|
|
82
|
+
// failure (matching ~/Developer/ideation/workflows/engine-host.mjs).
|
|
83
|
+
const agent = async (prompt, agentOpts = {}) => {
|
|
84
|
+
if (typeof agentOpts.agentType === 'string') {
|
|
85
|
+
log(`(agentType '${agentOpts.agentType}' accepted but ignored — no agent-type registry)`);
|
|
86
|
+
}
|
|
87
|
+
const spawnOpts = {
|
|
88
|
+
prompt,
|
|
89
|
+
...(typeof agentOpts.label === 'string' ? { agent: agentOpts.label } : {}),
|
|
90
|
+
...(typeof agentOpts.model === 'string' ? { model: agentOpts.model } : {}),
|
|
91
|
+
...(Array.isArray(agentOpts.tools) ? { tools: agentOpts.tools } : { tools: DEFAULT_TOOLS }),
|
|
92
|
+
...(typeof agentOpts.systemPrompt === 'string' ? { systemPrompt: agentOpts.systemPrompt } : {}),
|
|
93
|
+
...(typeof agentOpts.effort === 'string' ? { thinkingLevel: agentOpts.effort } : {}),
|
|
94
|
+
...(typeof agentOpts.timeoutMs === 'number' ? { timeoutMs: agentOpts.timeoutMs } : {}),
|
|
95
|
+
...(typeof agentOpts.maxTurns === 'number' ? { maxTurns: agentOpts.maxTurns } : {}),
|
|
96
|
+
...(agentOpts.schema !== undefined ? { outputSchema: agentOpts.schema } : {}),
|
|
97
|
+
...(agentOpts.worktree === true ? { worktree: true } : {}),
|
|
98
|
+
};
|
|
99
|
+
const res = await spawn(spawnOpts);
|
|
100
|
+
addUsage(usage, res.usage);
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
throw new Error(`${res.kind}: ${res.error}`);
|
|
103
|
+
// A worktree run that changed files produces a `.patch`; surface it
|
|
104
|
+
// alongside the value so the script can hand the path to a judge or
|
|
105
|
+
// return it for the `/patches` apply flow. Opt-in: only when patchPath is
|
|
106
|
+
// present, so non-worktree scripts see the unchanged `data ?? text` return.
|
|
107
|
+
if (res.patchPath)
|
|
108
|
+
return { value: res.data ?? res.text ?? null, patchPath: res.patchPath, runId: res.runId };
|
|
109
|
+
return res.data ?? res.text ?? null;
|
|
110
|
+
};
|
|
111
|
+
const parallel = (thunks) => Promise.all(thunks.map((t) => t()));
|
|
112
|
+
// pipeline(items, ...stages): each stage maps over the previous stage's
|
|
113
|
+
// outputs in parallel, producing the next array. A fold over Promise.all.
|
|
114
|
+
const pipeline = async (items, ...stages) => {
|
|
115
|
+
let values = [...items];
|
|
116
|
+
for (const stage of stages) {
|
|
117
|
+
values = await parallel(values.map((v, i) => () => stage(v, i)));
|
|
118
|
+
}
|
|
119
|
+
return values;
|
|
120
|
+
};
|
|
121
|
+
// Compile + run. compileScript is extracted so callers (tests, future
|
|
122
|
+
// tooling) can compile + read `meta` without a real spawn.
|
|
123
|
+
const compiled = compileScript(script);
|
|
124
|
+
const value = await compiled.fn(args, agent, parallel, pipeline, phase, log, budget, cwd);
|
|
125
|
+
return { value, meta: compiled.meta, logs, usage, durationMs: Date.now() - startedAt };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Compile a workflow script and read its `meta` export WITHOUT a real spawn.
|
|
129
|
+
*
|
|
130
|
+
* `export const meta =` is rewritten to an outer-binding assignment so the vm
|
|
131
|
+
* compiles (a stranded `export` fails loudly) and `meta` is captured into a
|
|
132
|
+
* holder. The body is wrapped in an async function so a top-level `return`
|
|
133
|
+
* compiles. `meta` is populated by a stub dry-run — `agent` returns `null`,
|
|
134
|
+
* `parallel`/`pipeline` are `Promise.all`-style folds over those stubs — so the
|
|
135
|
+
* script's `meta` declaration (which well-formed scripts place first) is read
|
|
136
|
+
* without spawning any child sessions. A syntax error throws here.
|
|
137
|
+
*/
|
|
138
|
+
export function compileScript(script) {
|
|
139
|
+
const metaHolder = { value: undefined };
|
|
140
|
+
const stripped = script.replace(STRIP_META, 'const meta = metaHolder.value =');
|
|
141
|
+
const wrapped = `(async function(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder){\n${stripped}\n})`;
|
|
142
|
+
const raw = new vm.Script(wrapped, { filename: 'workflow.js' }).runInThisContext();
|
|
143
|
+
// Bind metaHolder so callers invoke an 8-arg fn; the holder rides the call
|
|
144
|
+
// (runInThisContext cannot see a closure variable, so it must be a parameter).
|
|
145
|
+
const fn = (args, agent, parallel, pipeline, phase, log, budget, cwd) => raw(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder);
|
|
146
|
+
// Stub dry-run to read `meta`. Well-formed scripts declare `meta` first, so
|
|
147
|
+
// it is assigned synchronously before the first `await`; we await the whole
|
|
148
|
+
// stubbed body anyway so a script that computes meta from `args` works too.
|
|
149
|
+
// Any throw is swallowed — we only care that it compiled + set meta.
|
|
150
|
+
const stubAgent = async () => null;
|
|
151
|
+
const stubParallel = (thunks) => Promise.all(thunks.map((t) => t()));
|
|
152
|
+
const stubPipeline = async (items, ...stages) => {
|
|
153
|
+
let values = [...items];
|
|
154
|
+
for (const stage of stages)
|
|
155
|
+
values = await stubParallel(values.map((v) => () => stage(v)));
|
|
156
|
+
return values;
|
|
157
|
+
};
|
|
158
|
+
const stubBudget = { total: Infinity, spent: 0, remaining: Infinity };
|
|
159
|
+
void fn(undefined, stubAgent, stubParallel, stubPipeline, () => { }, () => { }, stubBudget, '/tmp').catch(() => { });
|
|
160
|
+
// `meta` is a getter so a caller that re-runs `fn` with real globals sees
|
|
161
|
+
// the post-run meta (the stub dry-run may have thrown on args-derived meta;
|
|
162
|
+
// the real run sets it). For static meta the stub already populated it.
|
|
163
|
+
return Object.defineProperty({ fn }, 'meta', {
|
|
164
|
+
get: () => metaHolder.value,
|
|
165
|
+
enumerable: true,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for the example workflow files: each example file COMPILES under
|
|
3
|
+
* the package's script compiler (`compileScript` — a stub dry-run, no real
|
|
4
|
+
* spawn) and its `meta` export has a non-empty `name` + `description`.
|
|
5
|
+
*
|
|
6
|
+
* No real spawns: compileScript reads `meta` via a stub dry-run where
|
|
7
|
+
* `agent`/`parallel`/`pipeline` are no-ops (see engine.ts).
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for the example workflow files: each example file COMPILES under
|
|
3
|
+
* the package's script compiler (`compileScript` — a stub dry-run, no real
|
|
4
|
+
* spawn) and its `meta` export has a non-empty `name` + `description`.
|
|
5
|
+
*
|
|
6
|
+
* No real spawns: compileScript reads `meta` via a stub dry-run where
|
|
7
|
+
* `agent`/`parallel`/`pipeline` are no-ops (see engine.ts).
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { compileScript } from './engine.js';
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const examplesDir = path.join(here, 'examples');
|
|
16
|
+
const examples = fs
|
|
17
|
+
.readdirSync(examplesDir)
|
|
18
|
+
.filter((f) => f.endsWith('.js'))
|
|
19
|
+
.map((f) => path.join(examplesDir, f));
|
|
20
|
+
describe('examples smoke: compile + meta', () => {
|
|
21
|
+
it('discovers exactly the three example files', () => {
|
|
22
|
+
expect(examples.map((e) => path.basename(e)).sort()).toEqual(['bake-off.js', 'gates.js', 'lanes.js']);
|
|
23
|
+
});
|
|
24
|
+
for (const file of examples) {
|
|
25
|
+
const name = path.basename(file);
|
|
26
|
+
it(`${name} compiles and exports meta.name + meta.description`, () => {
|
|
27
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
28
|
+
const compiled = compileScript(src);
|
|
29
|
+
expect(typeof compiled.meta?.name).toBe('string');
|
|
30
|
+
expect((compiled.meta?.name ?? '').length).toBeGreaterThan(0);
|
|
31
|
+
expect(typeof compiled.meta?.description).toBe('string');
|
|
32
|
+
expect((compiled.meta?.description ?? '').length).toBeGreaterThan(0);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nicknisi/pi-workflows — the model-facing front door to the first-party
|
|
3
|
+
* workflow engine.
|
|
4
|
+
*
|
|
5
|
+
* One `workflow` tool with actions: run (inline JS script OR a saved workflow
|
|
6
|
+
* file name), list, status (runId), stop (runId). `run` compiles the script in
|
|
7
|
+
* a node:vm context exactly like codemode compiles its snippets — `export const
|
|
8
|
+
* meta =` is rewritten so the vm compiles, the body is wrapped in an async
|
|
9
|
+
* function so a top-level `return` works, and meta.name/description surface in
|
|
10
|
+
* the result. Injected globals: agent, parallel, pipeline, phase, log, args,
|
|
11
|
+
* budget, cwd — the contract the old third-party tool's scripts were written
|
|
12
|
+
* against, so existing scripts run unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Saved workflows are plain files: ~/.pi/agent/workflows/*.js (global) and
|
|
15
|
+
* .pi/workflows/*.js (project, trusted-only). The registry is `ls` — no
|
|
16
|
+
* database, no manifest.
|
|
17
|
+
*
|
|
18
|
+
* Runs are visible: agent() spawns through @nicknisi/pi-shared's subagent
|
|
19
|
+
* runtime (namespace 'workflows'), so child spawns appear in the fleet radar
|
|
20
|
+
* from @nicknisi/pi-subagents. status/stop read from / cancel via the same
|
|
21
|
+
* runtime's run records — no parallel store.
|
|
22
|
+
*
|
|
23
|
+
* The platform story: subagents runtime + codemode VM + shared/workflow.ts
|
|
24
|
+
* engine + this tool = the workflow platform; the third-party
|
|
25
|
+
* @quintinshaw/pi-dynamic-workflows engine is being evicted.
|
|
26
|
+
*/
|
|
27
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
28
|
+
export declare function findWorkflowFile(name: string, cwd: string, trusted: boolean): string | undefined;
|
|
29
|
+
export interface SavedWorkflow {
|
|
30
|
+
name: string;
|
|
31
|
+
scope: 'global' | 'project';
|
|
32
|
+
description?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function listWorkflows(cwd: string, trusted: boolean): SavedWorkflow[];
|
|
35
|
+
export default function workflows(pi: ExtensionAPI): void;
|
|
36
|
+
export type { ExtensionContext };
|