pi-taskflow 0.0.11 → 0.0.13
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 +274 -6
- package/extensions/agents/analyst.md +30 -0
- package/extensions/agents/critic.md +31 -0
- package/extensions/agents/doc-writer.md +43 -0
- package/extensions/agents/executor-code.md +36 -0
- package/extensions/agents/executor-fast.md +26 -0
- package/extensions/agents/executor-ui.md +35 -0
- package/extensions/agents/executor.md +29 -0
- package/extensions/agents/final-arbiter.md +29 -0
- package/extensions/agents/plan-arbiter.md +35 -0
- package/extensions/agents/planner.md +30 -0
- package/extensions/agents/recover.md +28 -0
- package/extensions/agents/reviewer.md +37 -0
- package/extensions/agents/risk-reviewer.md +37 -0
- package/extensions/agents/scout.md +51 -0
- package/extensions/agents/security-reviewer.md +39 -0
- package/extensions/agents/test-engineer.md +31 -0
- package/extensions/agents/verifier.md +29 -0
- package/extensions/agents/visual-explorer.md +32 -0
- package/extensions/agents.ts +33 -2
- package/extensions/cache.ts +263 -0
- package/extensions/index.ts +201 -10
- package/extensions/init.ts +607 -0
- package/extensions/render.ts +39 -0
- package/extensions/runtime.ts +342 -17
- package/extensions/schema.ts +166 -1
- package/extensions/store.ts +16 -2
- package/package.json +4 -3
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-run memoization: fingerprint resolver + persistent phase-result cache.
|
|
3
|
+
*
|
|
4
|
+
* See docs/rfc-cross-run-memoization.md. The cache lets a phase reuse the result
|
|
5
|
+
* of an identical-input phase from ANY prior run (scope: "cross-run"), for $0.00.
|
|
6
|
+
* Freshness is guarded by:
|
|
7
|
+
* - the existing content-addressed inputHash (declared inputs)
|
|
8
|
+
* - optional `fingerprint` entries folded into the key (git/glob/file/env)
|
|
9
|
+
* - optional TTL
|
|
10
|
+
* - default `run-only` scope (this module is only consulted for cross-run)
|
|
11
|
+
*
|
|
12
|
+
* Zero runtime dependencies: Node built-ins only (fs.globSync requires Node >=22,
|
|
13
|
+
* which the project already targets).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import * as crypto from "node:crypto";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { cacheDir, withLock, writeFileAtomic } from "./store.ts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Fingerprint resolution
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Per-file byte cap when content-hashing (mirrors store/context limits). */
|
|
27
|
+
const FINGERPRINT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
28
|
+
/** Cap on glob match count folded into a single fingerprint (defensive). */
|
|
29
|
+
const FINGERPRINT_MAX_GLOB_MATCHES = 5000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a single fingerprint entry to a deterministic string. Never throws:
|
|
33
|
+
* missing files / non-git repos / unreadable paths resolve to a stable sentinel
|
|
34
|
+
* so the key stays deterministic (and a later appearance of the resource simply
|
|
35
|
+
* changes the key → cache miss, which is the safe direction).
|
|
36
|
+
*/
|
|
37
|
+
function resolveOne(entry: string, cwd: string): string {
|
|
38
|
+
try {
|
|
39
|
+
if (entry === "git:HEAD" || entry.startsWith("git:")) {
|
|
40
|
+
const ref = entry.slice("git:".length) || "HEAD";
|
|
41
|
+
// Reject refs that could be interpreted as git options (e.g. "--exec=...").
|
|
42
|
+
// The ref comes from a taskflow definition; refuse flag-like values so a
|
|
43
|
+
// crafted definition can't smuggle arguments into git.
|
|
44
|
+
if (ref.startsWith("-")) return `git:${ref}=<invalid-ref>`;
|
|
45
|
+
try {
|
|
46
|
+
const sha = execFileSync("git", ["rev-parse", ref], {
|
|
47
|
+
cwd,
|
|
48
|
+
encoding: "utf-8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
}).trim();
|
|
51
|
+
return `git:${ref}=${sha}`;
|
|
52
|
+
} catch {
|
|
53
|
+
return `git:${ref}=<no-git>`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (entry.startsWith("glob:") || entry.startsWith("glob!:")) {
|
|
58
|
+
const contentMode = entry.startsWith("glob!:");
|
|
59
|
+
const pattern = entry.slice(contentMode ? "glob!:".length : "glob:".length);
|
|
60
|
+
let matches: string[];
|
|
61
|
+
try {
|
|
62
|
+
// fs.globSync (Node >=22) — cwd-relative, returns posix-ish paths.
|
|
63
|
+
matches = (fs.globSync(pattern, { cwd }) as string[]).slice().sort();
|
|
64
|
+
} catch {
|
|
65
|
+
return `${entry}=<glob-error>`;
|
|
66
|
+
}
|
|
67
|
+
if (matches.length > FINGERPRINT_MAX_GLOB_MATCHES) {
|
|
68
|
+
matches = matches.slice(0, FINGERPRINT_MAX_GLOB_MATCHES);
|
|
69
|
+
}
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
for (const rel of matches) {
|
|
72
|
+
const abs = path.resolve(cwd, rel);
|
|
73
|
+
try {
|
|
74
|
+
if (contentMode) {
|
|
75
|
+
const st = fs.statSync(abs);
|
|
76
|
+
if (st.isFile() && st.size <= FINGERPRINT_MAX_FILE_BYTES) {
|
|
77
|
+
const buf = fs.readFileSync(abs);
|
|
78
|
+
parts.push(`${rel}:${crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16)}`);
|
|
79
|
+
} else {
|
|
80
|
+
parts.push(`${rel}:<skip>`);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const st = fs.statSync(abs);
|
|
84
|
+
parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
parts.push(`${rel}:<stat-error>`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const digest = crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
91
|
+
return `${entry}=${digest}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (entry.startsWith("file:")) {
|
|
95
|
+
const rel = entry.slice("file:".length);
|
|
96
|
+
const abs = path.resolve(cwd, rel);
|
|
97
|
+
try {
|
|
98
|
+
const st = fs.statSync(abs);
|
|
99
|
+
if (!st.isFile() || st.size > FINGERPRINT_MAX_FILE_BYTES) return `file:${rel}=<skip>`;
|
|
100
|
+
const buf = fs.readFileSync(abs);
|
|
101
|
+
return `file:${rel}=${crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16)}`;
|
|
102
|
+
} catch {
|
|
103
|
+
return `file:${rel}=<missing>`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entry.startsWith("env:")) {
|
|
108
|
+
const name = entry.slice("env:".length);
|
|
109
|
+
return `env:${name}=${process.env[name] ?? ""}`;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Fall through to sentinel below.
|
|
113
|
+
}
|
|
114
|
+
// Unknown prefixes are rejected at validation time; defensively encode.
|
|
115
|
+
return `${entry}=<unknown>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a phase's `fingerprint` list into a single deterministic string to be
|
|
120
|
+
* folded into the cache key. Returns "" when there are no entries (so the key is
|
|
121
|
+
* unchanged for phases that declare no fingerprint).
|
|
122
|
+
*/
|
|
123
|
+
export function resolveFingerprint(entries: string[] | undefined, cwd: string): string {
|
|
124
|
+
if (!entries || entries.length === 0) return "";
|
|
125
|
+
// Preserve author order (it's part of the declared key) but resolve each.
|
|
126
|
+
const resolved = entries.map((e) => resolveOne(e, cwd));
|
|
127
|
+
return crypto.createHash("sha256").update(resolved.join("\u0000")).digest("hex").slice(0, 16);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Cross-run cache store
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export interface CacheEntry {
|
|
135
|
+
/** The full cache key (== phase inputHash incl. fingerprint). */
|
|
136
|
+
key: string;
|
|
137
|
+
createdAt: number;
|
|
138
|
+
/** Trimmed phase result surface that downstream phases consume. */
|
|
139
|
+
output?: string;
|
|
140
|
+
json?: unknown;
|
|
141
|
+
model?: string;
|
|
142
|
+
/** Provenance for audit / cleanup. */
|
|
143
|
+
flowName?: string;
|
|
144
|
+
phaseId?: string;
|
|
145
|
+
runId?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Keep at most this many cache entries; LRU-ish eviction by createdAt. */
|
|
149
|
+
const DEFAULT_MAX_ENTRIES = 1000;
|
|
150
|
+
/** Drop entries older than this regardless of TTL (hard backstop). */
|
|
151
|
+
const DEFAULT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
|
|
152
|
+
|
|
153
|
+
/** A cache key is a 16-hex inputHash; constrain to that to prevent traversal. */
|
|
154
|
+
function isValidKey(key: string): boolean {
|
|
155
|
+
return /^[0-9a-f]{8,64}$/.test(key);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function entryPath(dir: string, key: string): string {
|
|
159
|
+
return path.join(dir, `${key}.json`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The cross-run cache, scoped to a working directory. Cheap to construct; all IO
|
|
164
|
+
* is lazy and failure-tolerant (a broken cache must never break a run).
|
|
165
|
+
*/
|
|
166
|
+
export class CacheStore {
|
|
167
|
+
private dir: string;
|
|
168
|
+
|
|
169
|
+
constructor(cwd: string) {
|
|
170
|
+
this.dir = cacheDir(cwd);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Look up a fresh entry. Returns null on miss, malformed key, or TTL expiry. */
|
|
174
|
+
get(key: string, ttlMs?: number): CacheEntry | null {
|
|
175
|
+
if (!isValidKey(key)) return null;
|
|
176
|
+
let entry: CacheEntry;
|
|
177
|
+
try {
|
|
178
|
+
const raw = fs.readFileSync(entryPath(this.dir, key), "utf-8");
|
|
179
|
+
entry = JSON.parse(raw) as CacheEntry;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (typeof entry?.createdAt !== "number") return null;
|
|
184
|
+
const age = Date.now() - entry.createdAt;
|
|
185
|
+
if (age > DEFAULT_MAX_AGE_MS) return null;
|
|
186
|
+
if (ttlMs !== undefined && age > ttlMs) return null;
|
|
187
|
+
return entry;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Store an entry (best-effort; never throws into the run). */
|
|
191
|
+
put(entry: CacheEntry): void {
|
|
192
|
+
if (!isValidKey(entry.key)) return;
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
195
|
+
const lock = path.join(this.dir, `${entry.key}.json.lock`);
|
|
196
|
+
withLock(lock, () => {
|
|
197
|
+
writeFileAtomic(entryPath(this.dir, entry.key), JSON.stringify(entry, null, 2));
|
|
198
|
+
});
|
|
199
|
+
this.cleanup();
|
|
200
|
+
} catch {
|
|
201
|
+
/* cache write failures are non-fatal */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Remove all cache entries. Returns the number removed. */
|
|
206
|
+
clear(): number {
|
|
207
|
+
let n = 0;
|
|
208
|
+
try {
|
|
209
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
210
|
+
if (f.endsWith(".json")) {
|
|
211
|
+
try {
|
|
212
|
+
fs.unlinkSync(path.join(this.dir, f));
|
|
213
|
+
n++;
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
/* no dir → nothing to clear */
|
|
221
|
+
}
|
|
222
|
+
return n;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Opportunistic eviction: drop expired/oversized entries. Best-effort. */
|
|
226
|
+
private cleanup(): void {
|
|
227
|
+
let files: string[];
|
|
228
|
+
try {
|
|
229
|
+
files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".json"));
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const live: Array<{ file: string; createdAt: number }> = [];
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
const abs = path.join(this.dir, f);
|
|
237
|
+
try {
|
|
238
|
+
const e = JSON.parse(fs.readFileSync(abs, "utf-8")) as CacheEntry;
|
|
239
|
+
if (typeof e?.createdAt !== "number" || now - e.createdAt > DEFAULT_MAX_AGE_MS) {
|
|
240
|
+
fs.unlinkSync(abs);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
live.push({ file: abs, createdAt: e.createdAt });
|
|
244
|
+
} catch {
|
|
245
|
+
try {
|
|
246
|
+
fs.unlinkSync(abs);
|
|
247
|
+
} catch {
|
|
248
|
+
/* ignore */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (live.length > DEFAULT_MAX_ENTRIES) {
|
|
253
|
+
live.sort((a, b) => a.createdAt - b.createdAt); // oldest first
|
|
254
|
+
for (const victim of live.slice(0, live.length - DEFAULT_MAX_ENTRIES)) {
|
|
255
|
+
try {
|
|
256
|
+
fs.unlinkSync(victim.file);
|
|
257
|
+
} catch {
|
|
258
|
+
/* ignore */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -12,8 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
14
14
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
15
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import {
|
|
18
|
+
RECOMMENDED_DEFAULTS,
|
|
19
|
+
readSettings,
|
|
20
|
+
writeSettings,
|
|
21
|
+
formatRolesReport,
|
|
22
|
+
formatDiffReport,
|
|
23
|
+
formatFlowResult,
|
|
24
|
+
runInteractiveInit,
|
|
25
|
+
} from "./init.ts";
|
|
17
26
|
import { Type } from "typebox";
|
|
18
27
|
import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
|
|
19
28
|
import { renderRunResult, summarizeRun } from "./render.ts";
|
|
@@ -30,6 +39,7 @@ import {
|
|
|
30
39
|
saveFlow,
|
|
31
40
|
saveRun,
|
|
32
41
|
} from "./store.ts";
|
|
42
|
+
import { CacheStore } from "./cache.ts";
|
|
33
43
|
|
|
34
44
|
interface TaskflowDetails {
|
|
35
45
|
state?: RunState;
|
|
@@ -50,8 +60,8 @@ const ShorthandStep = Type.Object(
|
|
|
50
60
|
);
|
|
51
61
|
|
|
52
62
|
const TaskflowParams = Type.Object({
|
|
53
|
-
action: StringEnum(["run", "save", "resume", "list", "agents"] as const, {
|
|
54
|
-
description: "What to do: run a flow, save a definition, resume a paused run, list saved flows,
|
|
63
|
+
action: StringEnum(["run", "save", "resume", "list", "agents", "init", "cache-clear"] as const, {
|
|
64
|
+
description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, or clear the cross-run memoization cache",
|
|
55
65
|
default: "run",
|
|
56
66
|
}),
|
|
57
67
|
name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
|
|
@@ -84,6 +94,19 @@ const TaskflowParams = Type.Object({
|
|
|
84
94
|
scope: Type.Optional(
|
|
85
95
|
StringEnum(["user", "project"] as const, { description: "Where to save (action=save)", default: "project" }),
|
|
86
96
|
),
|
|
97
|
+
mode: Type.Optional(
|
|
98
|
+
StringEnum(["show", "apply-defaults", "interactive"] as const, {
|
|
99
|
+
description:
|
|
100
|
+
"Init action mode. 'show' is read-only (default); 'apply-defaults' requires force:true; 'interactive' requires a UI session.",
|
|
101
|
+
default: "show",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
force: Type.Optional(
|
|
105
|
+
Type.Boolean({
|
|
106
|
+
description:
|
|
107
|
+
"Destructive: overwrites modelRoles in settings.json. Required for mode='apply-defaults'.",
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
87
110
|
});
|
|
88
111
|
|
|
89
112
|
function makeRunState(def: Taskflow, args: Record<string, unknown>, cwd: string): RunState {
|
|
@@ -167,7 +190,20 @@ async function runFlow(
|
|
|
167
190
|
// the heartbeat timer is cleared by the finally block below.
|
|
168
191
|
const settings = readSubagentSettings();
|
|
169
192
|
const scope: AgentScope = def.agentScope ?? "user";
|
|
170
|
-
const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides);
|
|
193
|
+
const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles);
|
|
194
|
+
|
|
195
|
+
// Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
|
|
196
|
+
const unresolvedRoles = agents
|
|
197
|
+
.filter(a => a.model && /^\{\{\w+\}\}$/.test(a.model))
|
|
198
|
+
.map(a => a.model!.match(/^\{\{(\w+)\}\}$/)![1]);
|
|
199
|
+
if (unresolvedRoles.length > 0) {
|
|
200
|
+
const unique = [...new Set(unresolvedRoles)];
|
|
201
|
+
console.warn(
|
|
202
|
+
`[taskflow] Hint: ${unique.length} model role(s) not configured: ${unique.join(", ")}. ` +
|
|
203
|
+
`Agents will use the default model (slower / less optimal). ` +
|
|
204
|
+
`Run /tf init to auto-generate modelRoles config.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
171
207
|
|
|
172
208
|
// Pre-flight: warn if any phase references an agent not in the registry
|
|
173
209
|
const agentNames = new Set(agents.map(a => a.name));
|
|
@@ -216,7 +252,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
216
252
|
}
|
|
217
253
|
};
|
|
218
254
|
|
|
219
|
-
pi.on("session_start", async (_e, ctx) =>
|
|
255
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
256
|
+
registerSavedFlowCommands(ctx);
|
|
257
|
+
|
|
258
|
+
// Hint: prompt to configure model roles if not set
|
|
259
|
+
try {
|
|
260
|
+
const settings = readSubagentSettings();
|
|
261
|
+
if (!settings.modelRoles) {
|
|
262
|
+
console.warn(
|
|
263
|
+
`[taskflow] Model roles not configured — agents will use the default model. ` +
|
|
264
|
+
`Run /tf init to generate a recommended modelRoles config.`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
});
|
|
220
269
|
|
|
221
270
|
// ---- The LLM-callable tool ----
|
|
222
271
|
pi.registerTool({
|
|
@@ -229,7 +278,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
278
|
"For simple non-DAG delegations (like the subagent tool) skip the DSL: pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` to run in parallel, or `chain:[{task,agent?}]` to run sequentially (reference the prior step with {previous.output}).",
|
|
230
279
|
"Use action=save to persist a definition as a reusable /tf:<name> command. action=resume continues a paused run. action=list shows saved flows. Use action=agents to list available agents — do NOT invent agent names; either use an agent from that list or omit the 'agent' field to auto-select the default agent.",
|
|
231
280
|
"DSL: {name, args?, concurrency?, budget?:{maxUSD,maxTokens}, phases:[{id, type, agent, task, dependsOn?, join?:'all'|'any', when?, retry?:{max,backoffMs,factor}, over?(map), as?(map), branches?(parallel), from?(reduce), use?(flow), with?(flow), output?:'json', final?}]}.",
|
|
232
|
-
"Phase types: agent (one subagent), parallel (static branches), map (dynamic fan-out over an array), gate (VERDICT: PASS/BLOCK quality gate), reduce (aggregate from N phases), approval (human-in-the-loop pause), flow (run a saved sub-flow). join:'any' is an OR-join; when is a conditional guard; retry adds backoff; budget caps run cost.",
|
|
281
|
+
"Phase types: agent (one subagent), parallel (static branches), map (dynamic fan-out over an array), gate (VERDICT: PASS/BLOCK quality gate), reduce (aggregate from N phases), approval (human-in-the-loop pause), flow (run a saved sub-flow), loop (re-run a task until 'until' is truthy / converged / maxIterations; body reads {loop.iteration} and {loop.lastOutput}), tournament (spawn N variants of 'task' — or distinct 'branches' — then a judge picks the best / aggregates; mode:'best'|'aggregate'). join:'any' is an OR-join; when is a conditional guard; retry adds backoff; budget caps run cost.",
|
|
233
282
|
"Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
|
|
234
283
|
].join(" "),
|
|
235
284
|
parameters: TaskflowParams,
|
|
@@ -243,10 +292,88 @@ export default function (pi: ExtensionAPI) {
|
|
|
243
292
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
244
293
|
const action = params.action ?? "run";
|
|
245
294
|
|
|
246
|
-
//
|
|
295
|
+
// init — configure model roles
|
|
296
|
+
if (action === "init") {
|
|
297
|
+
let settings: Record<string, unknown>;
|
|
298
|
+
try {
|
|
299
|
+
settings = readSettings();
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return errorResult(
|
|
302
|
+
action,
|
|
303
|
+
`Failed to read settings.json: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
304
|
+
`Fix the file or remove it.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const current = (settings.modelRoles ?? {}) as Record<string, string>;
|
|
308
|
+
const mode = params.mode;
|
|
309
|
+
|
|
310
|
+
// v0.0.13 deprecation bridge: mode omitted → old behavior
|
|
311
|
+
if (mode === undefined) {
|
|
312
|
+
if (Object.keys(current).length === 0) {
|
|
313
|
+
// v0.0.12 compat: auto-write recommended defaults when modelRoles is empty
|
|
314
|
+
console.warn(
|
|
315
|
+
"[taskflow] action=init with no mode is deprecated and will require explicit mode in v0.0.14. " +
|
|
316
|
+
"Use mode='apply-defaults' with force=true.",
|
|
317
|
+
);
|
|
318
|
+
writeSettings({ ...settings, modelRoles: { ...RECOMMENDED_DEFAULTS } });
|
|
319
|
+
const text = formatDiffReport({}, RECOMMENDED_DEFAULTS);
|
|
320
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
321
|
+
}
|
|
322
|
+
// mode omitted + modelRoles exist → show
|
|
323
|
+
const text = formatRolesReport(current);
|
|
324
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// mode === "show" (read-only, never overwrites)
|
|
328
|
+
if (mode === "show") {
|
|
329
|
+
const text = formatRolesReport(current);
|
|
330
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// mode === "apply-defaults" requires explicit force=true
|
|
334
|
+
if (mode === "apply-defaults") {
|
|
335
|
+
if (!params.force)
|
|
336
|
+
return errorResult(action, "mode=apply-defaults requires force=true to overwrite.");
|
|
337
|
+
const merged: Record<string, string> = { ...RECOMMENDED_DEFAULTS };
|
|
338
|
+
for (const key of Object.keys(current)) {
|
|
339
|
+
if (!(key in merged)) merged[key] = current[key]; // stale-preserved
|
|
340
|
+
}
|
|
341
|
+
writeSettings({ ...settings, modelRoles: merged });
|
|
342
|
+
const text = formatDiffReport(current, merged);
|
|
343
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// mode === "interactive" — requires a UI session
|
|
347
|
+
if (mode === "interactive") {
|
|
348
|
+
if (!ctx.hasUI)
|
|
349
|
+
return errorResult(action, "mode=interactive requires an interactive session.");
|
|
350
|
+
const enabledModels = (settings.enabledModels as string[] | undefined) ?? [];
|
|
351
|
+
const modelList =
|
|
352
|
+
enabledModels.length > 0
|
|
353
|
+
? enabledModels
|
|
354
|
+
.map((id) => ctx.modelRegistry.find(id.split("/")[0], id.split("/").slice(1).join("/")))
|
|
355
|
+
.filter((m): m is NonNullable<typeof m> => m !== undefined)
|
|
356
|
+
: ctx.modelRegistry.getAvailable();
|
|
357
|
+
const result = await runInteractiveInit({
|
|
358
|
+
hasUI: ctx.hasUI,
|
|
359
|
+
signal: signal ?? new AbortController().signal,
|
|
360
|
+
ui: ctx.ui as ExtensionUIContext,
|
|
361
|
+
modelRegistry: ctx.modelRegistry,
|
|
362
|
+
modelList,
|
|
363
|
+
currentRoles: current,
|
|
364
|
+
});
|
|
365
|
+
const text = formatFlowResult(result);
|
|
366
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return errorResult(action, `Unknown init mode: ${String(mode)}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// agents — list available agents the LLM can use in phase definitions
|
|
247
373
|
if (action === "agents") {
|
|
248
374
|
const scope = params.scope ?? "both";
|
|
249
|
-
const
|
|
375
|
+
const settings2 = readSubagentSettings();
|
|
376
|
+
const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles);
|
|
250
377
|
const text = agents.length
|
|
251
378
|
? agents
|
|
252
379
|
.map(
|
|
@@ -267,6 +394,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
394
|
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
268
395
|
}
|
|
269
396
|
|
|
397
|
+
if (action === "cache-clear") {
|
|
398
|
+
const removed = new CacheStore(ctx.cwd).clear();
|
|
399
|
+
return {
|
|
400
|
+
content: [{ type: "text", text: `Cleared ${removed} cross-run cache entr${removed === 1 ? "y" : "ies"}.` }],
|
|
401
|
+
details: { action } satisfies TaskflowDetails,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
270
405
|
// resume
|
|
271
406
|
if (action === "resume") {
|
|
272
407
|
if (!params.runId)
|
|
@@ -386,9 +521,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
386
521
|
|
|
387
522
|
// ---- The /tf user command ----
|
|
388
523
|
pi.registerCommand("tf", {
|
|
389
|
-
description: "Taskflow: list | run <name> | show <name> | runs",
|
|
524
|
+
description: "Taskflow: list | run <name> | show <name> | runs | init",
|
|
390
525
|
getArgumentCompletions: (prefix) => {
|
|
391
|
-
const subs = ["list", "run", "show", "runs", "resume"];
|
|
526
|
+
const subs = ["list", "run", "show", "runs", "resume", "init"];
|
|
392
527
|
const items = subs.map((s) => ({ value: s, label: s }));
|
|
393
528
|
const filtered = items.filter((i) => i.value.startsWith(prefix));
|
|
394
529
|
return filtered.length > 0 ? filtered : null;
|
|
@@ -480,6 +615,62 @@ export default function (pi: ExtensionAPI) {
|
|
|
480
615
|
return;
|
|
481
616
|
}
|
|
482
617
|
|
|
618
|
+
if (sub === "init") {
|
|
619
|
+
let settings: Record<string, unknown>;
|
|
620
|
+
try {
|
|
621
|
+
settings = readSettings();
|
|
622
|
+
} catch (e) {
|
|
623
|
+
ctx.ui.notify(
|
|
624
|
+
`Failed to read settings.json: ${e instanceof Error ? e.message : String(e)}`,
|
|
625
|
+
"error",
|
|
626
|
+
);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const currentRoles = (settings.modelRoles ?? {}) as Record<string, string>;
|
|
630
|
+
|
|
631
|
+
if (!ctx.hasUI) {
|
|
632
|
+
if (Object.keys(currentRoles).length > 0) {
|
|
633
|
+
ctx.ui.notify(
|
|
634
|
+
formatRolesReport(currentRoles),
|
|
635
|
+
"info",
|
|
636
|
+
);
|
|
637
|
+
} else {
|
|
638
|
+
ctx.ui.notify(
|
|
639
|
+
"No modelRoles configured. Run /tf init in an interactive session to select models.",
|
|
640
|
+
"warning",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const enabledModels = (settings.enabledModels as string[] | undefined) ?? [];
|
|
647
|
+
const modelList =
|
|
648
|
+
enabledModels.length > 0
|
|
649
|
+
? enabledModels
|
|
650
|
+
.map((id) => ctx.modelRegistry.find(id.split("/")[0], id.split("/").slice(1).join("/")))
|
|
651
|
+
.filter((m): m is NonNullable<typeof m> => m !== undefined)
|
|
652
|
+
: ctx.modelRegistry.getAvailable();
|
|
653
|
+
const result = await runInteractiveInit({
|
|
654
|
+
hasUI: ctx.hasUI,
|
|
655
|
+
signal: ctx.signal ?? new AbortController().signal,
|
|
656
|
+
ui: ctx.ui,
|
|
657
|
+
modelRegistry: ctx.modelRegistry,
|
|
658
|
+
modelList,
|
|
659
|
+
currentRoles,
|
|
660
|
+
});
|
|
661
|
+
ctx.ui.notify(
|
|
662
|
+
result.kind === "saved"
|
|
663
|
+
? `Saved model roles to ${result.savedPath}:\n${Object.entries(result.chosen)
|
|
664
|
+
.map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
|
|
665
|
+
.join("\n")}`
|
|
666
|
+
: result.kind === "no-change"
|
|
667
|
+
? "No changes made."
|
|
668
|
+
: "Init cancelled.",
|
|
669
|
+
result.kind === "saved" ? "info" : "info",
|
|
670
|
+
);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
483
674
|
ctx.ui.notify(`Unknown subcommand: ${sub}`, "warning");
|
|
484
675
|
},
|
|
485
676
|
});
|