pi-loopflows 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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/extensions/index.ts +449 -0
- package/loopflows/launch-control.loopflow.json +65 -0
- package/package.json +56 -0
- package/skills/loopflows/SKILL.md +40 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-06-22
|
|
4
|
+
|
|
5
|
+
- Initial release.
|
|
6
|
+
- Add `loopflow_run` tool.
|
|
7
|
+
- Add `/loopflow` and `/loopflow-list` commands.
|
|
8
|
+
- Add JSON loopflow discovery from bundled, user, and project locations.
|
|
9
|
+
- Add step, loop, gate, max-iteration, artifact, and adapter primitives.
|
|
10
|
+
- Bundle `launch-control` loopflow preset.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nikita Nosov
|
|
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,174 @@
|
|
|
1
|
+
# pi-loopflows
|
|
2
|
+
|
|
3
|
+
Deterministic loop workflows for Pi subagents.
|
|
4
|
+
|
|
5
|
+
A **loopflow** describes agent work as a process instead of a single prompt: steps, gates, feedback loops, stop conditions, and saved evidence. It lets you connect Pi subagents like building blocks: gather context, plan, build, review, loop back for fixes, and audit the result.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Normal chains are linear. Real work is not. A reviewer may request changes, a builder may need another pass, or a gate may block because evidence is missing. `pi-loopflows` adds that missing control flow while keeping the agents focused on their roles.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-loopflows
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or from GitHub:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install https://github.com/nik1t7n/pi-loopflows
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Reload Pi after installing:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
/reload
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## What it adds
|
|
30
|
+
|
|
31
|
+
### Tool
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
loopflow_run({
|
|
35
|
+
workflow: "launch-control",
|
|
36
|
+
task: "Implement this approved backend plan",
|
|
37
|
+
maxIterations: 3
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Commands
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
/loopflow-list
|
|
45
|
+
/loopflow launch-control -- Implement this approved backend plan
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Bundled loopflow
|
|
49
|
+
|
|
50
|
+
`launch-control`:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
context-builder
|
|
54
|
+
→ planner
|
|
55
|
+
→ loop max 3:
|
|
56
|
+
worker
|
|
57
|
+
reviewer gate
|
|
58
|
+
approved -> continue
|
|
59
|
+
changes_requested -> repeat
|
|
60
|
+
blocked -> stop
|
|
61
|
+
→ final audit
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Loopflow files
|
|
65
|
+
|
|
66
|
+
Loopflows are JSON files named `*.loopflow.json`.
|
|
67
|
+
|
|
68
|
+
Discovery locations:
|
|
69
|
+
|
|
70
|
+
- bundled package `loopflows/`
|
|
71
|
+
- user: `~/.pi/agent/loopflows/`
|
|
72
|
+
- project: `.pi/loopflows/`
|
|
73
|
+
|
|
74
|
+
Project loopflows can override or add workflows for a repo.
|
|
75
|
+
|
|
76
|
+
## Minimal shape
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"name": "build-review",
|
|
81
|
+
"description": "Build, review, and fix until approved.",
|
|
82
|
+
"steps": [
|
|
83
|
+
{
|
|
84
|
+
"id": "plan",
|
|
85
|
+
"agent": "planner",
|
|
86
|
+
"task": "Plan this task: {task}"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"loop": {
|
|
90
|
+
"id": "build-review",
|
|
91
|
+
"maxIterations": 3,
|
|
92
|
+
"gateStep": "review",
|
|
93
|
+
"passStatuses": ["approved"],
|
|
94
|
+
"retryStatuses": ["changes_requested"],
|
|
95
|
+
"stopStatuses": ["blocked"],
|
|
96
|
+
"body": [
|
|
97
|
+
{
|
|
98
|
+
"id": "build",
|
|
99
|
+
"agent": "worker",
|
|
100
|
+
"task": "Build from plan: {outputs.plan}"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "review",
|
|
104
|
+
"agent": "reviewer",
|
|
105
|
+
"gate": { "type": "json-status" },
|
|
106
|
+
"task": "Review and return JSON with status approved, changes_requested, or blocked."
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Template variables
|
|
116
|
+
|
|
117
|
+
- `{task}` — original user task
|
|
118
|
+
- `{previous}` — previous step output
|
|
119
|
+
- `{outputs.stepId}` — output from a named step
|
|
120
|
+
- `{outputs.stepId.status}` — parsed gate status
|
|
121
|
+
- `{outputs.stepId.json}` — parsed gate JSON
|
|
122
|
+
- `{loop.iteration}` — current loop iteration
|
|
123
|
+
- `{artifactsDir}` — current run artifact directory
|
|
124
|
+
- `{params.name}` — runtime params passed to `loopflow_run`
|
|
125
|
+
|
|
126
|
+
## Artifacts
|
|
127
|
+
|
|
128
|
+
Every run writes evidence to:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
<cwd>/.pi/loopflows/runs/<timestamp>-<workflow>/
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Typical files:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
task.md
|
|
138
|
+
workflow.json
|
|
139
|
+
context.md
|
|
140
|
+
block-plan.md
|
|
141
|
+
build-1.md
|
|
142
|
+
review-1.json
|
|
143
|
+
final-audit.json
|
|
144
|
+
summary.md
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Backend design
|
|
148
|
+
|
|
149
|
+
The engine uses an executor adapter boundary:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
runAgent(agent, task, options) -> StepResult
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Current backend: Pi subprocess agents compatible with `pi-subagents` agent definitions.
|
|
156
|
+
|
|
157
|
+
Future backends can support Codex CLI, OpenCode, ACP-based workers, remote workers, or other agent runtimes without changing loopflow definitions.
|
|
158
|
+
|
|
159
|
+
## Requirements
|
|
160
|
+
|
|
161
|
+
- Pi coding agent
|
|
162
|
+
- `pi-subagents` installed for the bundled agent roles:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pi install npm:pi-subagents
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Status
|
|
169
|
+
|
|
170
|
+
Early but usable. The core model is intentionally small: steps, loops, gates, artifacts, and adapters.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
type BackendName = "pi-subprocess";
|
|
11
|
+
type GateStatus = "approved" | "changes_requested" | "blocked" | "complete" | "incomplete" | string;
|
|
12
|
+
|
|
13
|
+
type StepDef = {
|
|
14
|
+
id: string;
|
|
15
|
+
agent: string;
|
|
16
|
+
task: string;
|
|
17
|
+
gate?: GateDef;
|
|
18
|
+
output?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
tools?: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type LoopDef = {
|
|
24
|
+
id: string;
|
|
25
|
+
maxIterations: number;
|
|
26
|
+
body: StepDef[];
|
|
27
|
+
gateStep: string;
|
|
28
|
+
passStatuses?: string[];
|
|
29
|
+
retryStatuses?: string[];
|
|
30
|
+
stopStatuses?: string[];
|
|
31
|
+
onExhausted?: "stop" | "continue";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type WorkflowNode = StepDef | { loop: LoopDef };
|
|
35
|
+
|
|
36
|
+
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const PACKAGE_ROOT = path.resolve(EXTENSION_DIR, "..");
|
|
38
|
+
|
|
39
|
+
type WorkflowDef = {
|
|
40
|
+
name: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
version?: string;
|
|
43
|
+
backend?: BackendName;
|
|
44
|
+
defaults?: {
|
|
45
|
+
maxIterations?: number;
|
|
46
|
+
agentScope?: "user" | "project" | "both";
|
|
47
|
+
};
|
|
48
|
+
steps: WorkflowNode[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type AgentDef = {
|
|
52
|
+
name: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
systemPrompt: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
tools?: string[];
|
|
57
|
+
source: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type StepResult = {
|
|
61
|
+
id: string;
|
|
62
|
+
agent: string;
|
|
63
|
+
iteration?: number;
|
|
64
|
+
output: string;
|
|
65
|
+
json?: any;
|
|
66
|
+
status?: GateStatus;
|
|
67
|
+
artifactPath?: string;
|
|
68
|
+
exitCode: number;
|
|
69
|
+
stderr?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type RunContext = {
|
|
73
|
+
cwd: string;
|
|
74
|
+
task: string;
|
|
75
|
+
artifactsDir: string;
|
|
76
|
+
outputs: Record<string, StepResult>;
|
|
77
|
+
sequence: StepResult[];
|
|
78
|
+
params: Record<string, any>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type GateDef = {
|
|
82
|
+
type?: "json-status";
|
|
83
|
+
passStatuses?: string[];
|
|
84
|
+
retryStatuses?: string[];
|
|
85
|
+
stopStatuses?: string[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function safeName(value: string): string {
|
|
89
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "item";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureDir(dir: string) {
|
|
93
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJsonFile<T>(file: string): T | null {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseFrontmatter(md: string): { data: Record<string, any>; body: string } {
|
|
105
|
+
if (!md.startsWith("---\n")) return { data: {}, body: md };
|
|
106
|
+
const end = md.indexOf("\n---", 4);
|
|
107
|
+
if (end < 0) return { data: {}, body: md };
|
|
108
|
+
const raw = md.slice(4, end).trim();
|
|
109
|
+
const body = md.slice(end + 4).replace(/^\n/, "");
|
|
110
|
+
const data: Record<string, any> = {};
|
|
111
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
112
|
+
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
113
|
+
if (!m) continue;
|
|
114
|
+
const key = m[1];
|
|
115
|
+
let value: any = m[2].trim();
|
|
116
|
+
if (value === "") value = undefined;
|
|
117
|
+
if (typeof value === "string" && value.includes(",")) {
|
|
118
|
+
value = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
data[key] = value;
|
|
121
|
+
}
|
|
122
|
+
return { data, body };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function agentDirs(cwd: string, scope: "user" | "project" | "both"): string[] {
|
|
126
|
+
const dirs: string[] = [];
|
|
127
|
+
const add = (p: string) => { if (fs.existsSync(p)) dirs.push(p); };
|
|
128
|
+
const pkg = path.join(getAgentDir(), "npm/node_modules/pi-subagents/agents");
|
|
129
|
+
add(pkg);
|
|
130
|
+
if (scope === "user" || scope === "both") add(path.join(getAgentDir(), "agents"));
|
|
131
|
+
if (scope === "project" || scope === "both") {
|
|
132
|
+
add(path.join(cwd, ".pi/agents"));
|
|
133
|
+
add(path.join(cwd, ".agents"));
|
|
134
|
+
}
|
|
135
|
+
return dirs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function walkMd(dir: string): string[] {
|
|
139
|
+
const out: string[] = [];
|
|
140
|
+
if (!fs.existsSync(dir)) return out;
|
|
141
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
142
|
+
const p = path.join(dir, ent.name);
|
|
143
|
+
if (ent.isDirectory()) out.push(...walkMd(p));
|
|
144
|
+
else if (ent.isFile() && ent.name.endsWith(".md")) out.push(p);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function discoverAgents(cwd: string, scope: "user" | "project" | "both"): Map<string, AgentDef> {
|
|
150
|
+
const map = new Map<string, AgentDef>();
|
|
151
|
+
// Load lower priority first; later dirs override.
|
|
152
|
+
for (const dir of agentDirs(cwd, scope)) {
|
|
153
|
+
for (const file of walkMd(dir)) {
|
|
154
|
+
const md = fs.readFileSync(file, "utf8");
|
|
155
|
+
const { data, body } = parseFrontmatter(md);
|
|
156
|
+
const name = typeof data.name === "string" ? data.name : path.basename(file, ".md");
|
|
157
|
+
const pkg = typeof data.package === "string" ? data.package : undefined;
|
|
158
|
+
const runtime = pkg ? `${pkg}.${name}` : name;
|
|
159
|
+
const tools = Array.isArray(data.tools) ? data.tools : typeof data.tools === "string" ? data.tools.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
160
|
+
map.set(runtime, {
|
|
161
|
+
name: runtime,
|
|
162
|
+
description: data.description,
|
|
163
|
+
systemPrompt: body,
|
|
164
|
+
model: typeof data.model === "string" ? data.model : undefined,
|
|
165
|
+
tools,
|
|
166
|
+
source: file,
|
|
167
|
+
});
|
|
168
|
+
if (!pkg) map.set(name, map.get(runtime)!);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return map;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function workflowDirs(cwd: string): string[] {
|
|
175
|
+
return [
|
|
176
|
+
path.join(PACKAGE_ROOT, "loopflows"),
|
|
177
|
+
path.join(getAgentDir(), "loopflows"),
|
|
178
|
+
path.join(cwd, ".pi/loopflows"),
|
|
179
|
+
].filter((p) => fs.existsSync(p));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function discoverWorkflows(cwd: string): Map<string, { file: string; workflow: WorkflowDef }> {
|
|
183
|
+
const map = new Map<string, { file: string; workflow: WorkflowDef }>();
|
|
184
|
+
for (const dir of workflowDirs(cwd)) {
|
|
185
|
+
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".loopflow.json"))) {
|
|
186
|
+
const full = path.join(dir, file);
|
|
187
|
+
const wf = readJsonFile<WorkflowDef>(full);
|
|
188
|
+
if (wf?.name && Array.isArray(wf.steps)) map.set(wf.name, { file: full, workflow: wf });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return map;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderTemplate(template: string, ctx: RunContext, iteration?: number): string {
|
|
195
|
+
return template.replace(/\{([^}]+)\}/g, (_m, keyRaw) => {
|
|
196
|
+
const key = String(keyRaw).trim();
|
|
197
|
+
if (key === "task") return ctx.task;
|
|
198
|
+
if (key === "artifactsDir") return ctx.artifactsDir;
|
|
199
|
+
if (key === "previous") return ctx.sequence.at(-1)?.output ?? "";
|
|
200
|
+
if (key === "loop.iteration") return String(iteration ?? "");
|
|
201
|
+
if (key.startsWith("params.")) return String(ctx.params[key.slice(7)] ?? "");
|
|
202
|
+
if (key.startsWith("outputs.")) {
|
|
203
|
+
const rest = key.slice(8);
|
|
204
|
+
const [id, prop] = rest.split(".");
|
|
205
|
+
const res = ctx.outputs[id];
|
|
206
|
+
if (!res) return "";
|
|
207
|
+
if (!prop || prop === "output") return res.output;
|
|
208
|
+
if (prop === "status") return String(res.status ?? "");
|
|
209
|
+
if (prop === "json") return JSON.stringify(res.json ?? null, null, 2);
|
|
210
|
+
return String((res as any)[prop] ?? res.json?.[prop] ?? "");
|
|
211
|
+
}
|
|
212
|
+
return "";
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractJson(text: string): any | undefined {
|
|
217
|
+
const trimmed = text.trim();
|
|
218
|
+
try { return JSON.parse(trimmed); } catch {}
|
|
219
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
220
|
+
if (fenced) {
|
|
221
|
+
try { return JSON.parse(fenced[1].trim()); } catch {}
|
|
222
|
+
}
|
|
223
|
+
const start = trimmed.indexOf("{");
|
|
224
|
+
const end = trimmed.lastIndexOf("}");
|
|
225
|
+
if (start >= 0 && end > start) {
|
|
226
|
+
try { return JSON.parse(trimmed.slice(start, end + 1)); } catch {}
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function finalAssistantTextFromJsonLines(stdout: string): string {
|
|
232
|
+
let final = "";
|
|
233
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
234
|
+
if (!line.trim()) continue;
|
|
235
|
+
let ev: any;
|
|
236
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
237
|
+
const msg = ev.message;
|
|
238
|
+
if (ev.type === "message_end" && msg?.role === "assistant") {
|
|
239
|
+
for (const part of msg.content ?? []) {
|
|
240
|
+
if (part.type === "text") final = part.text;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return final;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
248
|
+
const currentScript = process.argv[1];
|
|
249
|
+
if (currentScript && fs.existsSync(currentScript) && !currentScript.startsWith("/$bunfs/root/")) {
|
|
250
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
251
|
+
}
|
|
252
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
253
|
+
if (!/^(node|bun)(\.exe)?$/.test(execName)) return { command: process.execPath, args };
|
|
254
|
+
return { command: "pi", args };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function writePrompt(agentName: string, prompt: string) {
|
|
258
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-loopflows-"));
|
|
259
|
+
const file = path.join(dir, `prompt-${safeName(agentName)}.md`);
|
|
260
|
+
await withFileMutationQueue(file, async () => fs.promises.writeFile(file, prompt, { mode: 0o600 }));
|
|
261
|
+
return { dir, file };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface ExecutorAdapter {
|
|
265
|
+
runAgent(agent: string, task: string, options: { cwd: string; signal?: AbortSignal; model?: string; tools?: string[]; scope: "user" | "project" | "both" }): Promise<{ output: string; exitCode: number; stderr: string }>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
class PiSubprocessAdapter implements ExecutorAdapter {
|
|
269
|
+
async runAgent(agentName: string, task: string, options: { cwd: string; signal?: AbortSignal; model?: string; tools?: string[]; scope: "user" | "project" | "both" }) {
|
|
270
|
+
const agents = discoverAgents(options.cwd, options.scope);
|
|
271
|
+
const agent = agents.get(agentName);
|
|
272
|
+
if (!agent) {
|
|
273
|
+
return { output: "", exitCode: 1, stderr: `Unknown agent ${agentName}. Available: ${[...agents.keys()].sort().join(", ")}` };
|
|
274
|
+
}
|
|
275
|
+
const args = ["--mode", "json", "-p", "--no-session"];
|
|
276
|
+
const model = options.model ?? agent.model;
|
|
277
|
+
const tools = options.tools ?? agent.tools;
|
|
278
|
+
if (model) args.push("--model", model);
|
|
279
|
+
if (tools?.length) args.push("--tools", tools.join(","));
|
|
280
|
+
const tmp = agent.systemPrompt.trim() ? await writePrompt(agentName, agent.systemPrompt) : undefined;
|
|
281
|
+
if (tmp) args.push("--append-system-prompt", tmp.file);
|
|
282
|
+
args.push(task);
|
|
283
|
+
|
|
284
|
+
const invocation = getPiInvocation(args);
|
|
285
|
+
let stdout = "";
|
|
286
|
+
let stderr = "";
|
|
287
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
288
|
+
const proc = spawn(invocation.command, invocation.args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
289
|
+
proc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
290
|
+
proc.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
291
|
+
proc.on("close", (code) => resolve(code ?? 0));
|
|
292
|
+
proc.on("error", (err) => { stderr += String(err?.message ?? err); resolve(1); });
|
|
293
|
+
if (options.signal) {
|
|
294
|
+
const kill = () => { proc.kill("SIGTERM"); setTimeout(() => proc.kill("SIGKILL"), 3000); };
|
|
295
|
+
if (options.signal.aborted) kill();
|
|
296
|
+
else options.signal.addEventListener("abort", kill, { once: true });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
if (tmp) {
|
|
300
|
+
fs.rmSync(tmp.dir, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
return { output: finalAssistantTextFromJsonLines(stdout) || stdout.trim(), exitCode, stderr };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function saveArtifact(ctx: RunContext, name: string, content: string): Promise<string> {
|
|
307
|
+
const file = path.join(ctx.artifactsDir, name);
|
|
308
|
+
await ensureDir(path.dirname(file));
|
|
309
|
+
await fs.promises.writeFile(file, content, "utf8");
|
|
310
|
+
return file;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function runStep(def: StepDef, ctx: RunContext, adapter: ExecutorAdapter, scope: "user" | "project" | "both", signal: AbortSignal | undefined, iteration?: number): Promise<StepResult> {
|
|
314
|
+
const task = renderTemplate(def.task, ctx, iteration);
|
|
315
|
+
const run = await adapter.runAgent(def.agent, task, { cwd: ctx.cwd, signal, model: def.model, tools: def.tools, scope });
|
|
316
|
+
const json = def.gate ? extractJson(run.output) : undefined;
|
|
317
|
+
const status = json?.status;
|
|
318
|
+
const artifactName = def.output ? renderTemplate(def.output, ctx, iteration) : `${safeName(def.id)}${iteration ? `-${iteration}` : ""}.${def.gate ? "json" : "md"}`;
|
|
319
|
+
const artifactPath = await saveArtifact(ctx, artifactName, def.gate ? JSON.stringify(json ?? { parse_error: true, raw: run.output }, null, 2) : run.output);
|
|
320
|
+
const result: StepResult = { id: def.id, agent: def.agent, iteration, output: run.output, json, status, artifactPath, exitCode: run.exitCode, stderr: run.stderr };
|
|
321
|
+
ctx.outputs[def.id] = result;
|
|
322
|
+
ctx.outputs[iteration ? `${def.id}_${iteration}` : def.id] = result;
|
|
323
|
+
ctx.sequence.push(result);
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function statusIn(status: GateStatus | undefined, values: string[] | undefined, fallback: string[]) {
|
|
328
|
+
return !!status && (values ?? fallback).includes(status);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function runLoop(loop: LoopDef, ctx: RunContext, adapter: ExecutorAdapter, scope: "user" | "project" | "both", signal: AbortSignal | undefined): Promise<StepResult> {
|
|
332
|
+
const max = Math.max(1, loop.maxIterations);
|
|
333
|
+
let lastGate: StepResult | undefined;
|
|
334
|
+
for (let i = 1; i <= max; i++) {
|
|
335
|
+
await saveArtifact(ctx, `${safeName(loop.id)}/iteration-${i}.txt`, `Starting iteration ${i}/${max}\n`);
|
|
336
|
+
for (const step of loop.body) {
|
|
337
|
+
const stepWithGate = step.id === loop.gateStep && !step.gate ? { ...step, gate: { type: "json-status" as const } } : step;
|
|
338
|
+
const result = await runStep(stepWithGate, ctx, adapter, scope, signal, i);
|
|
339
|
+
if (step.id === loop.gateStep) lastGate = result;
|
|
340
|
+
if (result.exitCode !== 0) return result;
|
|
341
|
+
}
|
|
342
|
+
const status = lastGate?.status;
|
|
343
|
+
if (statusIn(status, loop.passStatuses, ["approved", "complete"])) return lastGate!;
|
|
344
|
+
if (statusIn(status, loop.stopStatuses, ["blocked"])) return lastGate!;
|
|
345
|
+
if (!statusIn(status, loop.retryStatuses, ["changes_requested", "incomplete"])) return lastGate!;
|
|
346
|
+
}
|
|
347
|
+
if (lastGate) {
|
|
348
|
+
lastGate.status = `exhausted:${lastGate.status ?? "unknown"}`;
|
|
349
|
+
await saveArtifact(ctx, `${safeName(loop.id)}/exhausted.json`, JSON.stringify(lastGate.json ?? { status: lastGate.status }, null, 2));
|
|
350
|
+
}
|
|
351
|
+
return lastGate ?? { id: loop.id, agent: "loop", output: "Loop had no gate result", exitCode: 1 };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runWorkflow(workflow: WorkflowDef, task: string, opts: { cwd: string; signal?: AbortSignal; params?: Record<string, any>; maxIterations?: number }) {
|
|
355
|
+
const adapter = new PiSubprocessAdapter();
|
|
356
|
+
const runId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${safeName(workflow.name)}`;
|
|
357
|
+
const artifactsDir = path.join(opts.cwd, ".pi/loopflows/runs", runId);
|
|
358
|
+
await ensureDir(artifactsDir);
|
|
359
|
+
const ctx: RunContext = { cwd: opts.cwd, task, artifactsDir, outputs: {}, sequence: [], params: opts.params ?? {} };
|
|
360
|
+
const scope = workflow.defaults?.agentScope ?? "both";
|
|
361
|
+
|
|
362
|
+
await saveArtifact(ctx, "workflow.json", JSON.stringify(workflow, null, 2));
|
|
363
|
+
await saveArtifact(ctx, "task.md", task);
|
|
364
|
+
|
|
365
|
+
for (const node of workflow.steps) {
|
|
366
|
+
if ("loop" in node) {
|
|
367
|
+
const loop = { ...node.loop };
|
|
368
|
+
if (opts.maxIterations) loop.maxIterations = opts.maxIterations;
|
|
369
|
+
const result = await runLoop(loop, ctx, adapter, scope, opts.signal);
|
|
370
|
+
if (result.exitCode !== 0 || String(result.status ?? "").startsWith("exhausted") || statusIn(result.status, loop.stopStatuses, ["blocked"])) break;
|
|
371
|
+
} else {
|
|
372
|
+
const result = await runStep(node, ctx, adapter, scope, opts.signal);
|
|
373
|
+
if (result.exitCode !== 0) break;
|
|
374
|
+
if (node.gate && statusIn(result.status, node.gate.stopStatuses, ["blocked", "incomplete"])) break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const summary = [
|
|
379
|
+
`# Loopflow run: ${workflow.name}`,
|
|
380
|
+
``,
|
|
381
|
+
`Task: ${task}`,
|
|
382
|
+
`Artifacts: ${artifactsDir}`,
|
|
383
|
+
``,
|
|
384
|
+
`## Steps`,
|
|
385
|
+
...ctx.sequence.map((r, idx) => `${idx + 1}. ${r.id}${r.iteration ? `#${r.iteration}` : ""} (${r.agent}) - exit ${r.exitCode}${r.status ? ` - status ${r.status}` : ""}${r.artifactPath ? ` - ${path.relative(opts.cwd, r.artifactPath)}` : ""}`),
|
|
386
|
+
].join("\n");
|
|
387
|
+
const summaryPath = await saveArtifact(ctx, "summary.md", summary);
|
|
388
|
+
return { artifactsDir, summaryPath, results: ctx.sequence, summary };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const RunParams = Type.Object({
|
|
392
|
+
workflow: Type.String({ description: "Loopflow name, e.g. launch-control" }),
|
|
393
|
+
task: Type.String({ description: "Task/spec/plan for the loopflow" }),
|
|
394
|
+
maxIterations: Type.Optional(Type.Number({ description: "Override max iterations for loops" })),
|
|
395
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
export default function (pi: ExtensionAPI) {
|
|
399
|
+
pi.registerTool({
|
|
400
|
+
name: "loopflow_run",
|
|
401
|
+
label: "Loopflow Run",
|
|
402
|
+
description: "Run a deterministic loopflow: subagent steps, gates, loops, artifacts, and max-iteration feedback cycles.",
|
|
403
|
+
parameters: RunParams,
|
|
404
|
+
promptSnippet: "Run deterministic subagent loop workflows with gates and feedback loops.",
|
|
405
|
+
promptGuidelines: ["Use loopflow_run when the user asks to run a loopflow, launch-control workflow, or builder/reviewer feedback loop."],
|
|
406
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
407
|
+
const workflows = discoverWorkflows(ctx.cwd);
|
|
408
|
+
const found = workflows.get(params.workflow);
|
|
409
|
+
if (!found) {
|
|
410
|
+
return { content: [{ type: "text", text: `Unknown loopflow ${params.workflow}. Available: ${[...workflows.keys()].join(", ") || "none"}` }], details: {}, isError: true };
|
|
411
|
+
}
|
|
412
|
+
onUpdate?.({ content: [{ type: "text", text: `Running loopflow ${params.workflow}...` }], details: {} });
|
|
413
|
+
const result = await runWorkflow(found.workflow, params.task, { cwd: ctx.cwd, signal, params: params.params, maxIterations: params.maxIterations });
|
|
414
|
+
return { content: [{ type: "text", text: result.summary }], details: result };
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
pi.registerCommand("loopflow-list", {
|
|
419
|
+
description: "List available loopflows",
|
|
420
|
+
handler: async (_args, ctx) => {
|
|
421
|
+
const workflows = discoverWorkflows(ctx.cwd);
|
|
422
|
+
const lines = [...workflows.values()].map(({ file, workflow }) => `- ${workflow.name}: ${workflow.description ?? ""}\n ${file}`);
|
|
423
|
+
ctx.ui.notify(lines.length ? lines.join("\n") : "No loopflows found", "info");
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
pi.registerCommand("loopflow", {
|
|
428
|
+
description: "Run a loopflow: /loopflow <name> -- <task>",
|
|
429
|
+
handler: async (args, ctx) => {
|
|
430
|
+
const [namePart, ...rest] = args.split(/\s+--\s+/);
|
|
431
|
+
const name = namePart.trim().split(/\s+/)[0];
|
|
432
|
+
const task = rest.join(" -- ").trim() || namePart.trim().replace(/^\S+\s*/, "");
|
|
433
|
+
if (!name || !task) {
|
|
434
|
+
ctx.ui.notify("Usage: /loopflow <name> -- <task>", "error");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await ctx.waitForIdle();
|
|
438
|
+
const workflows = discoverWorkflows(ctx.cwd);
|
|
439
|
+
const found = workflows.get(name);
|
|
440
|
+
if (!found) {
|
|
441
|
+
ctx.ui.notify(`Unknown loopflow ${name}. Available: ${[...workflows.keys()].join(", ") || "none"}`, "error");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
ctx.ui.notify(`Running loopflow ${name}`, "info");
|
|
445
|
+
const result = await runWorkflow(found.workflow, task, { cwd: ctx.cwd });
|
|
446
|
+
pi.sendMessage({ customType: "loopflow-result", content: result.summary, display: true, details: result }, { triggerTurn: false });
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "launch-control",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Plan-as-contract implementation loopflow: context, block plan, builder/reviewer feedback loop, and final audit with real validation evidence.",
|
|
5
|
+
"backend": "pi-subprocess",
|
|
6
|
+
"defaults": {
|
|
7
|
+
"agentScope": "both"
|
|
8
|
+
},
|
|
9
|
+
"steps": [
|
|
10
|
+
{
|
|
11
|
+
"id": "context",
|
|
12
|
+
"agent": "context-builder",
|
|
13
|
+
"output": "context.md",
|
|
14
|
+
"task": "Build concise launch-control execution context for this task:\n\n{task}\n\nRead any provided plan/spec path, inspect the repository enough to identify relevant files, acceptance criteria, validation commands, risks, missing inputs, and stop conditions. Do not edit files. Return compact handoff context with: scope, likely files, validation contract candidates, explicit unknowns, and stop conditions."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "plan",
|
|
18
|
+
"agent": "planner",
|
|
19
|
+
"output": "block-plan.md",
|
|
20
|
+
"task": "Create the smallest safe launch-control implementation block from this context:\n\n{outputs.context}\n\nOriginal task:\n{task}\n\nReturn a concrete block plan with: intended slice, acceptance criteria, files likely involved, real validation commands/evidence required, non-goals, stop conditions, and what the builder must report. Keep the plan lean and executable. Do not edit files."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"loop": {
|
|
24
|
+
"id": "build-review",
|
|
25
|
+
"maxIterations": 3,
|
|
26
|
+
"gateStep": "review",
|
|
27
|
+
"passStatuses": ["approved"],
|
|
28
|
+
"retryStatuses": ["changes_requested"],
|
|
29
|
+
"stopStatuses": ["blocked"],
|
|
30
|
+
"onExhausted": "stop",
|
|
31
|
+
"body": [
|
|
32
|
+
{
|
|
33
|
+
"id": "build",
|
|
34
|
+
"agent": "worker",
|
|
35
|
+
"output": "build-{loop.iteration}.md",
|
|
36
|
+
"task": "Implement or fix launch-control block iteration {loop.iteration}.\n\nOriginal task:\n{task}\n\nBlock plan:\n{outputs.plan}\n\nPrevious review, if any:\n{outputs.review.output}\n\nRules:\n- One writer only.\n- Use the real project stack and real integrations.\n- No mocks, demos, fallbacks, fake validation, or hidden shortcuts unless the user explicitly asked.\n- If this is iteration 1, implement exactly the block plan. If this is a later iteration, apply only required reviewer fixes.\n- If the plan is stale, acceptance criteria change, data/API model changes, or user decision is required, stop and report it instead of inventing scope.\n- Run the validation contract where possible.\n\nReturn changed files, commands run with exit codes, validation evidence, blockers, and any plan mismatch."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "review",
|
|
40
|
+
"agent": "reviewer",
|
|
41
|
+
"output": "review-{loop.iteration}.json",
|
|
42
|
+
"gate": {
|
|
43
|
+
"type": "json-status",
|
|
44
|
+
"passStatuses": ["approved"],
|
|
45
|
+
"retryStatuses": ["changes_requested"],
|
|
46
|
+
"stopStatuses": ["blocked"]
|
|
47
|
+
},
|
|
48
|
+
"task": "Review launch-control build iteration {loop.iteration}. Do not edit project/source files.\n\nOriginal task:\n{task}\n\nBlock plan:\n{outputs.plan}\n\nBuilder report:\n{outputs.build.output}\n\nCheck: plan freshness, scope drift, correctness, real validation evidence, test coverage, hidden mocks/fallbacks/demos, over-large slice, and unlogged plan changes.\n\nReturn ONLY valid JSON matching this shape:\n{\n \"status\": \"approved\" | \"changes_requested\" | \"blocked\",\n \"summary\": \"short verdict\",\n \"findings\": [{\"severity\": \"blocker|required|optional\", \"file\": \"path or null\", \"issue\": \"specific issue\", \"required_fix\": \"specific fix or null\"}],\n \"validation_gaps\": [\"missing evidence\"],\n \"plan_changed\": false,\n \"requires_user_decision\": false\n}\n\nUse status=approved only when the diff and evidence satisfy the block plan. Use changes_requested for fixable required issues. Use blocked for missing credentials, stale plan, required user/product decision, or exhausted/unsafe scope."
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "final-audit",
|
|
55
|
+
"agent": "reviewer",
|
|
56
|
+
"output": "final-audit.json",
|
|
57
|
+
"gate": {
|
|
58
|
+
"type": "json-status",
|
|
59
|
+
"passStatuses": ["complete", "approved"],
|
|
60
|
+
"stopStatuses": ["incomplete", "blocked"]
|
|
61
|
+
},
|
|
62
|
+
"task": "Run final launch-control completion audit. Do not edit files.\n\nOriginal task:\n{task}\n\nPlan:\n{outputs.plan}\n\nLast build:\n{outputs.build.output}\n\nLast review status: {outputs.review.status}\nLast review JSON:\n{outputs.review.json}\n\nVerify every explicit acceptance criterion, artifact, command, invariant, and stop condition against current repo state and available evidence.\n\nReturn ONLY valid JSON:\n{\n \"status\": \"complete\" | \"incomplete\" | \"blocked\",\n \"summary\": \"short audit result\",\n \"verified_items\": [\"item\"],\n \"remaining_blockers\": [\"blocker\"],\n \"validation_gaps\": [\"gap\"]\n}"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-loopflows",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic loop workflows for Pi subagents: steps, gates, feedback loops, and artifacts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Nikita Nosov <20nik.nosov21@gmail.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"agent-workflows",
|
|
13
|
+
"subagents",
|
|
14
|
+
"loopflows",
|
|
15
|
+
"ai-agents"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/nik1t7n/pi-loopflows#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/nik1t7n/pi-loopflows.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/nik1t7n/pi-loopflows/issues"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"extensions",
|
|
27
|
+
"loopflows",
|
|
28
|
+
"skills",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./extensions"
|
|
36
|
+
],
|
|
37
|
+
"skills": [
|
|
38
|
+
"./skills"
|
|
39
|
+
],
|
|
40
|
+
"image": "https://raw.githubusercontent.com/nik1t7n/pi-loopflows/main/assets/pi-loopflows.png"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"typebox": "*"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "latest",
|
|
48
|
+
"@types/node": "^26.0.0",
|
|
49
|
+
"typebox": "latest",
|
|
50
|
+
"typescript": "latest"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"pack:check": "npm pack --dry-run"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: loopflows
|
|
3
|
+
description: Use when the user wants deterministic multi-agent workflows, feedback loops between builder/reviewer agents, launch-control style implementation gates, or repeatable Pi subagent processes with max iterations, gates, stop conditions, and artifacts.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Loopflows
|
|
7
|
+
|
|
8
|
+
Use `loopflow_run` for deterministic agent workflows that need real control flow instead of a linear chain.
|
|
9
|
+
|
|
10
|
+
Prefer loopflows when work needs:
|
|
11
|
+
|
|
12
|
+
- a builder/reviewer feedback loop;
|
|
13
|
+
- explicit pass/retry/stop gates;
|
|
14
|
+
- max iteration limits;
|
|
15
|
+
- saved evidence artifacts;
|
|
16
|
+
- launch-control style plan → build → review → fix → audit flow.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
List workflows:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
/loopflow-list
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run a workflow:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
/loopflow launch-control -- <task or plan>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or call the tool directly:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
loopflow_run({ workflow: "launch-control", task: "...", maxIterations: 3 })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Rule of thumb
|
|
39
|
+
|
|
40
|
+
Use `pi-subagents` chains for simple linear handoffs. Use `pi-loopflows` when a gate can send work backward for fixes or stop the run.
|