pi-taskflow 0.0.14 → 0.0.16

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.
@@ -10,6 +10,59 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
 
11
11
  export type AgentScope = "user" | "project" | "both";
12
12
 
13
+ export interface TaskflowSettings {
14
+ /** Whether taskflow's package-local built-in agents are available to flows. */
15
+ builtInAgents: boolean;
16
+ /** Whether package-local built-ins are copied into the current project's .pi/agents/. */
17
+ syncBuiltinAgentsToProject: boolean;
18
+ /** Maximum completed/failed runs to keep. 0 disables cleanup. */
19
+ maxKeptRuns: number;
20
+ /** Maximum age (days) for completed/failed runs. 0 disables age cleanup. */
21
+ maxRunAgeDays: number;
22
+ }
23
+
24
+ import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS } from "./store.ts";
25
+
26
+ export const DEFAULT_TASKFLOW_SETTINGS: TaskflowSettings = {
27
+ builtInAgents: true,
28
+ syncBuiltinAgentsToProject: false,
29
+ maxKeptRuns: DEFAULT_KEPT_RUNS,
30
+ maxRunAgeDays: DEFAULT_RUN_AGE_DAYS,
31
+ };
32
+
33
+ export function normalizeTaskflowSettings(raw: unknown): TaskflowSettings {
34
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
35
+ return { ...DEFAULT_TASKFLOW_SETTINGS };
36
+ }
37
+ const rec = raw as Record<string, unknown>;
38
+ return {
39
+ builtInAgents:
40
+ typeof rec.builtInAgents === "boolean"
41
+ ? rec.builtInAgents
42
+ : DEFAULT_TASKFLOW_SETTINGS.builtInAgents,
43
+ syncBuiltinAgentsToProject:
44
+ typeof rec.syncBuiltinAgentsToProject === "boolean"
45
+ ? rec.syncBuiltinAgentsToProject
46
+ : DEFAULT_TASKFLOW_SETTINGS.syncBuiltinAgentsToProject,
47
+ maxKeptRuns:
48
+ typeof rec.maxKeptRuns === "number" && rec.maxKeptRuns >= 0 && Number.isInteger(rec.maxKeptRuns)
49
+ ? rec.maxKeptRuns
50
+ : DEFAULT_TASKFLOW_SETTINGS.maxKeptRuns,
51
+ maxRunAgeDays:
52
+ typeof rec.maxRunAgeDays === "number" && rec.maxRunAgeDays >= 0 && Number.isInteger(rec.maxRunAgeDays)
53
+ ? rec.maxRunAgeDays
54
+ : DEFAULT_TASKFLOW_SETTINGS.maxRunAgeDays,
55
+ };
56
+ }
57
+
58
+ export function shouldLoadBuiltinAgents(settings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS): boolean {
59
+ return settings.builtInAgents;
60
+ }
61
+
62
+ export function shouldSyncBuiltinAgentsToProject(settings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS): boolean {
63
+ return settings.builtInAgents && settings.syncBuiltinAgentsToProject;
64
+ }
65
+
13
66
  export interface AgentOverride {
14
67
  model?: string;
15
68
  thinking?: string;
@@ -122,12 +175,14 @@ export function discoverAgents(
122
175
  scope: AgentScope,
123
176
  overrides?: Record<string, AgentOverride>,
124
177
  modelRoles?: Record<string, string>,
178
+ taskflowSettings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS,
125
179
  ): AgentDiscoveryResult {
126
- // Built-in agents ship with the package (extensions/agents/*.md)
127
- // PI_TASKFLOW_BUILTIN_AGENTS_DIR allows tests to override or disable (empty = skip)
180
+ // Built-in agents ship with the package (extensions/agents/*.md).
181
+ // PI_TASKFLOW_BUILTIN_AGENTS_DIR is kept as a test hook only; user-facing
182
+ // enable/disable lives in settings.json under `taskflow.builtInAgents`.
128
183
  const builtInDirEnv = process.env.PI_TASKFLOW_BUILTIN_AGENTS_DIR;
129
184
  const builtInDir = builtInDirEnv ? builtInDirEnv : builtInDirEnv === undefined ? path.resolve(import.meta.dirname, "agents") : "";
130
- const builtInAgents = builtInDir ? loadAgentsFromDir(builtInDir, "built-in") : [];
185
+ const builtInAgents = shouldLoadBuiltinAgents(taskflowSettings) && builtInDir ? loadAgentsFromDir(builtInDir, "built-in") : [];
131
186
 
132
187
  const userDir = path.join(getAgentDir(), "agents");
133
188
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
@@ -165,10 +220,15 @@ export function discoverAgents(
165
220
  }
166
221
 
167
222
  // Resolve {{role}} model references (e.g. {{fast}} → openrouter/deepseek/v4-flash)
223
+ // Clone before mutating, consistent with the overrides block above.
168
224
  if (modelRoles) {
169
- for (const agent of agentMap.values()) {
225
+ for (const [name, agent] of agentMap.entries()) {
170
226
  const resolved = resolveModelRole(agent.model, modelRoles);
171
- if (resolved !== agent.model) agent.model = resolved;
227
+ if (resolved !== agent.model) {
228
+ const mutated: AgentConfig = { ...agent };
229
+ mutated.model = resolved;
230
+ agentMap.set(name, mutated);
231
+ }
172
232
  }
173
233
  }
174
234
 
@@ -179,6 +239,7 @@ export interface SubagentSettings {
179
239
  agentOverrides?: Record<string, AgentOverride>;
180
240
  globalThinking?: string;
181
241
  modelRoles?: Record<string, string>;
242
+ taskflow: TaskflowSettings;
182
243
  }
183
244
 
184
245
  /**
@@ -197,14 +258,62 @@ export function resolveModelRole(model: string | undefined, roles?: Record<strin
197
258
  export function readSubagentSettings(): SubagentSettings {
198
259
  try {
199
260
  const settingsPath = path.join(getAgentDir(), "settings.json");
200
- if (!fs.existsSync(settingsPath)) return {};
261
+ if (!fs.existsSync(settingsPath)) return { taskflow: { ...DEFAULT_TASKFLOW_SETTINGS } };
201
262
  const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
202
263
  return {
203
264
  agentOverrides: raw.subagents?.agentOverrides,
204
265
  globalThinking: raw.subagents?.globalThinking ?? raw.defaultThinkingLevel,
205
266
  modelRoles: raw.modelRoles,
267
+ taskflow: normalizeTaskflowSettings(raw.taskflow),
206
268
  };
207
269
  } catch {
208
- return {};
270
+ return { taskflow: { ...DEFAULT_TASKFLOW_SETTINGS } };
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Copy the 18 built-in agents from extensions/agents/*.md into the project's
276
+ * .pi/agents/ directory so Pi's native subagent tool (and any other extension)
277
+ * can discover them. taskflow's own discoverAgents() already reads from this
278
+ * directory with lower priority than built-in, so the copy is a no-op for
279
+ * taskflow phases — it only matters for Pi's native agent discovery.
280
+ *
281
+ * Idempotent: only copies agents whose built-in source is newer than the
282
+ * project copy (or that don't exist yet).
283
+ */
284
+ export function syncBuiltinAgentsToProject(cwd: string): void {
285
+ const builtInDir = path.resolve(import.meta.dirname, "agents");
286
+ if (!fs.existsSync(builtInDir)) return;
287
+
288
+ const projectAgentsDir = path.join(cwd, ".pi", "agents");
289
+ fs.mkdirSync(projectAgentsDir, { recursive: true });
290
+
291
+ let entries: fs.Dirent[];
292
+ try {
293
+ entries = fs.readdirSync(builtInDir, { withFileTypes: true });
294
+ } catch {
295
+ return;
296
+ }
297
+
298
+ for (const entry of entries) {
299
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
300
+ const src = path.join(builtInDir, entry.name);
301
+ const dst = path.join(projectAgentsDir, entry.name);
302
+
303
+ let srcMtime = 0;
304
+ try { srcMtime = fs.statSync(src).mtimeMs; } catch { continue; }
305
+
306
+ let dstMtime = 0;
307
+ try { dstMtime = fs.statSync(dst).mtimeMs; } catch { /* dst doesn't exist yet */ }
308
+
309
+ // Only copy when the source is newer (or the destination is missing).
310
+ if (srcMtime <= dstMtime) continue;
311
+
312
+ try {
313
+ const content = fs.readFileSync(src, "utf-8");
314
+ fs.writeFileSync(dst, content, "utf-8");
315
+ } catch {
316
+ // Best-effort: a locked file must not block the sync.
317
+ }
209
318
  }
210
319
  }
@@ -24,7 +24,7 @@ import {
24
24
  runInteractiveInit,
25
25
  } from "./init.ts";
26
26
  import { Type } from "typebox";
27
- import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
27
+ import { type AgentScope, discoverAgents, readSubagentSettings, shouldSyncBuiltinAgentsToProject, syncBuiltinAgentsToProject } from "./agents.ts";
28
28
  import { renderRunResult, summarizeRun } from "./render.ts";
29
29
  import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
30
30
  import { executeTaskflow, type ApprovalDecision, type ApprovalRequest, type RuntimeResult } from "./runtime.ts";
@@ -38,6 +38,8 @@ import {
38
38
  type RunState,
39
39
  saveFlow,
40
40
  saveRun,
41
+ DEFAULT_KEPT_RUNS,
42
+ DEFAULT_RUN_AGE_DAYS,
41
43
  } from "./store.ts";
42
44
  import { CacheStore } from "./cache.ts";
43
45
 
@@ -142,11 +144,12 @@ async function runFlow(
142
144
 
143
145
  // Throttled persistence: avoid disk writes on every sub-item event.
144
146
  let lastPersist = 0;
147
+ const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS };
145
148
  const persistThrottled = (s: RunState) => {
146
149
  const now = Date.now();
147
150
  if (now - lastPersist >= 1000) {
148
151
  lastPersist = now;
149
- saveRun(s);
152
+ saveRun(s, cleanupConfig);
150
153
  }
151
154
  };
152
155
 
@@ -189,8 +192,10 @@ async function runFlow(
189
192
  // discoverAgents or readSubagentSettings (F-001) is caught and
190
193
  // the heartbeat timer is cleared by the finally block below.
191
194
  const settings = readSubagentSettings();
195
+ cleanupConfig.maxKeep = settings.taskflow.maxKeptRuns;
196
+ cleanupConfig.maxAgeDays = settings.taskflow.maxRunAgeDays;
192
197
  const scope: AgentScope = def.agentScope ?? "user";
193
- const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles);
198
+ const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles, settings.taskflow);
194
199
 
195
200
  // Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
196
201
  const unresolvedRoles = agents
@@ -225,7 +230,7 @@ async function runFlow(
225
230
  return result;
226
231
  } finally {
227
232
  if (heartbeat) clearInterval(heartbeat);
228
- saveRun(state); // force-persist terminal state
233
+ saveRun(state, cleanupConfig); // force-persist terminal state
229
234
  emit(state); // final render reflecting terminal state
230
235
  }
231
236
  }
@@ -255,6 +260,44 @@ export default function (pi: ExtensionAPI) {
255
260
  pi.on("session_start", async (_e, ctx) => {
256
261
  registerSavedFlowCommands(ctx);
257
262
 
263
+ // Optional: copy built-in agents into .pi/agents/ so Pi's native
264
+ // subagent tool (and other extensions) can discover them. This is
265
+ // disabled by default to avoid surprising project file creation.
266
+ try {
267
+ const settings = readSubagentSettings();
268
+ if (shouldSyncBuiltinAgentsToProject(settings.taskflow)) {
269
+ syncBuiltinAgentsToProject(ctx.cwd);
270
+ }
271
+ } catch {
272
+ // Best-effort: a locked or readonly .pi/ directory must not block
273
+ // session startup.
274
+ }
275
+
276
+ // Upgrade hint: if the project already has .pi/agents/ with agent
277
+ // files but no explicit taskflow settings, the user is upgrading
278
+ // from the old default (sync=true) and may be surprised that sync
279
+ // is now disabled by default.
280
+ try {
281
+ const raw = readSettings();
282
+ if (!("taskflow" in raw)) {
283
+ const fs = await import("node:fs");
284
+ const path = await import("node:path");
285
+ const projectAgentsDir = path.join(ctx.cwd, ".pi", "agents");
286
+ try {
287
+ const entries = fs.readdirSync(projectAgentsDir).filter((e: string) => e.endsWith(".md"));
288
+ if (entries.length > 0) {
289
+ console.warn(
290
+ `[taskflow] Note: built-in agents are no longer synced to .pi/agents/ by default. ` +
291
+ `If you rely on this, run /tf init → 'Configure taskflow preferences' to re-enable. ` +
292
+ `(This is a one-time upgrade hint.)`,
293
+ );
294
+ }
295
+ } catch { /* .pi/agents/ doesn't exist — no hint needed */ }
296
+ }
297
+ } catch {
298
+ // Best-effort: settings.json missing or unreadable is not an error.
299
+ }
300
+
258
301
  // Hint: prompt to configure model roles if not set
259
302
  try {
260
303
  const settings = readSubagentSettings();
@@ -272,20 +315,19 @@ export default function (pi: ExtensionAPI) {
272
315
  name: "taskflow",
273
316
  label: "Taskflow",
274
317
  description: [
275
- "Orchestrate a multi-phase workflow of subagents from a declarative definition.",
276
- "Phases (agent, parallel, map, gate, reduce, approval, flow) form a DAG; intermediate outputs stay out of your context only the final phase output is returned.",
277
- "Use action=run with an inline `define` (you write the DSL) or a saved `name`.",
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}).",
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.",
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?}]}.",
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.",
318
+ "IMPORTANT: Before using this tool for the first time in a session, invoke skill_load('taskflow') to read the full documentation (DSL syntax, examples, best practices). This tool description is a reference, not a tutorial.",
319
+ "Shorthand (same API as subagent): pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` for parallel, or `chain:[{task,agent?}]` for sequential (use {previous.output}).",
320
+ "DSL: use action=run with an inline `define` (you write the DAG) or a saved `name`. Phases (agent, parallel, map, gate, reduce, approval, flow, loop, tournament) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
321
+ "Every delegation is tracked (runId), resumable across sessions, and saveable as /tf:<name> via action=save.",
322
+ "Use action=agents to list the 18 built-in agents (executor, scout, planner, analyst, critic, reviewer, risk-reviewer, security-reviewer, plan-arbiter, final-arbiter, test-engineer, doc-writer, executor-code, executor-fast, executor-ui, recover, verifier, visual-explorer). Do NOT invent agent names.",
323
+ "Phase types: agent, parallel (static branches), map (dynamic fan-out over array), gate (VERDICT: PASS/BLOCK), reduce (aggregate from N), approval (human-in-the-loop), flow (run saved sub-flow), loop (iterate until condition/convergence/cap), tournament (N variants, judge picks best/aggregate).",
282
324
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
283
325
  ].join(" "),
284
326
  parameters: TaskflowParams,
285
- promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
327
+ promptSnippet: "Orchestrate subagents single, parallel, chain, or DAG with tracking, resume, and context isolation. Replaces the subagent tool.",
286
328
  promptGuidelines: [
287
- "Prefer taskflow whenever a request spans a whole project/codebase or many items e.g. 'explore / 探索 / 审计 / analyze the project', auditing endpoints, reviewing or migrating many files/modules, or cross-checked research. It fans out to many subagents across phases and aggregates the result, keeping intermediate work out of your context.",
288
- "Choose taskflow over ad-hoc parallel subagents when the work has multiple phases (discover work review report), needs dynamic fan-out over a discovered list, or should be saved and rerun. For simple single/parallel/chain delegations use the shorthand `task`/`tasks`/`chain` (no DSL) when you want the run tracked, resumable, or saveable; otherwise the plain subagent tool is fine.",
329
+ "BEFORE FIRST USE: invoke skill_load('taskflow') to read the full skill documentation (DSL syntax, phase types, examples, best practices). This tool description is a condensed reference only — the skill is the authoritative guide.\n\nUse taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:<name>. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.",
330
+ "For complex multi-phase work (explore / 审计 / analyze the project, auditing endpoints, reviewing or migrating many files/modules, cross-checked research), use the full DSL with phases. For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
289
331
  "For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
290
332
  ],
291
333
 
@@ -361,6 +403,7 @@ export default function (pi: ExtensionAPI) {
361
403
  modelRegistry: ctx.modelRegistry,
362
404
  modelList,
363
405
  currentRoles: current,
406
+ currentTaskflowSettings: readSubagentSettings().taskflow,
364
407
  });
365
408
  const text = formatFlowResult(result);
366
409
  return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
@@ -373,7 +416,7 @@ export default function (pi: ExtensionAPI) {
373
416
  if (action === "agents") {
374
417
  const scope = params.scope ?? "both";
375
418
  const settings2 = readSubagentSettings();
376
- const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles);
419
+ const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles, settings2.taskflow);
377
420
  const text = agents.length
378
421
  ? agents
379
422
  .map(
@@ -704,17 +747,13 @@ export default function (pi: ExtensionAPI) {
704
747
  modelRegistry: ctx.modelRegistry,
705
748
  modelList,
706
749
  currentRoles,
750
+ currentTaskflowSettings: readSubagentSettings().taskflow,
707
751
  });
708
- ctx.ui.notify(
709
- result.kind === "saved"
710
- ? `Saved model roles to ${result.savedPath}:\n${Object.entries(result.chosen)
711
- .map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
712
- .join("\n")}`
713
- : result.kind === "no-change"
714
- ? "No changes made."
715
- : "Init cancelled.",
716
- result.kind === "saved" ? "info" : "info",
717
- );
752
+ if (result.kind === "cancelled") {
753
+ ctx.ui.notify("Init cancelled.", "info");
754
+ } else {
755
+ ctx.ui.notify(formatFlowResult(result), "info");
756
+ }
718
757
  return;
719
758
  }
720
759
 
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import type { Api, Model } from "@earendil-works/pi-ai";
18
18
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
19
  import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
+ import { DEFAULT_TASKFLOW_SETTINGS, normalizeTaskflowSettings, type TaskflowSettings } from "./agents.ts";
20
21
  import { writeFileAtomic } from "./store.ts";
21
22
 
22
23
  // ---------------------------------------------------------------------------
@@ -330,8 +331,23 @@ export function formatDiffReport(
330
331
  return lines.join("\n");
331
332
  }
332
333
 
334
+ export function formatTaskflowSettingsReport(settings: TaskflowSettings): string {
335
+ return [
336
+ "Taskflow preferences:",
337
+ ` Built-in agents: ${settings.builtInAgents ? "enabled" : "disabled"}`,
338
+ ` Sync built-ins to project .pi/agents: ${settings.syncBuiltinAgentsToProject ? "enabled" : "disabled"}`,
339
+ ].join("\n");
340
+ }
341
+
333
342
  export function formatFlowResult(result: InitFlowResult): string {
334
343
  if (result.kind === "cancelled") return "Init cancelled.";
344
+ if (result.kind === "preferences-no-change") {
345
+ return "No changes.\n" + formatTaskflowSettingsReport(result.settings);
346
+ }
347
+ if (result.kind === "preferences-saved") {
348
+ const savedPath = formatSettingsPath(result.savedPath);
349
+ return `Saved taskflow preferences to ${savedPath}:\n` + formatTaskflowSettingsReport(result.settings);
350
+ }
335
351
  if (result.kind === "no-change") {
336
352
  return (
337
353
  "No changes.\n" +
@@ -357,6 +373,8 @@ export function formatFlowResult(result: InitFlowResult): string {
357
373
  export type InitFlowResult =
358
374
  | { kind: "saved"; chosen: Record<string, string>; savedPath: string }
359
375
  | { kind: "no-change"; chosen: Record<string, string> }
376
+ | { kind: "preferences-saved"; settings: TaskflowSettings; savedPath: string }
377
+ | { kind: "preferences-no-change"; settings: TaskflowSettings }
360
378
  | { kind: "cancelled" };
361
379
 
362
380
  export async function runInteractiveInit(ctx: {
@@ -366,6 +384,7 @@ export async function runInteractiveInit(ctx: {
366
384
  modelRegistry: ExtensionContext["modelRegistry"];
367
385
  modelList: Model<Api>[];
368
386
  currentRoles: Record<string, string>;
387
+ currentTaskflowSettings?: TaskflowSettings;
369
388
  }): Promise<InitFlowResult> {
370
389
  if (!ctx.hasUI) {
371
390
  throw new Error("runInteractiveInit requires an interactive session (hasUI=true).");
@@ -373,6 +392,7 @@ export async function runInteractiveInit(ctx: {
373
392
 
374
393
  const recommended = RECOMMENDED_DEFAULTS;
375
394
  const current = ctx.currentRoles;
395
+ const currentTaskflowSettings = ctx.currentTaskflowSettings ?? normalizeTaskflowSettings(readSettings().taskflow);
376
396
  const hasCurrent = Object.keys(current).length > 0;
377
397
 
378
398
  // ---- Action menu ----
@@ -381,10 +401,11 @@ export async function runInteractiveInit(ctx: {
381
401
  "Use recommended defaults",
382
402
  "Configure each role",
383
403
  "Edit one role",
404
+ "Configure taskflow preferences",
384
405
  "Show current roles",
385
406
  "Cancel",
386
407
  ]
387
- : ["Use recommended defaults", "Configure each role"];
408
+ : ["Use recommended defaults", "Configure each role", "Configure taskflow preferences"];
388
409
 
389
410
  const action = await ctx.ui.select(
390
411
  "What do you want to do with model roles?",
@@ -416,6 +437,11 @@ export async function runInteractiveInit(ctx: {
416
437
  // ---- Cancel ----
417
438
  if (action === "Cancel") return { kind: "cancelled" };
418
439
 
440
+ // ---- Configure taskflow preferences ----
441
+ if (action === "Configure taskflow preferences") {
442
+ return configureTaskflowPreferences(ctx, currentTaskflowSettings);
443
+ }
444
+
419
445
  // ---- Configure each role ----
420
446
  if (action === "Configure each role") {
421
447
  const chosen = await collectRolePicks(ctx, current, recommended, undefined);
@@ -437,6 +463,59 @@ export async function runInteractiveInit(ctx: {
437
463
  // Internal helpers
438
464
  // ---------------------------------------------------------------------------
439
465
 
466
+ function taskflowSettingsIdentical(a: TaskflowSettings, b: TaskflowSettings): boolean {
467
+ return a.builtInAgents === b.builtInAgents && a.syncBuiltinAgentsToProject === b.syncBuiltinAgentsToProject;
468
+ }
469
+
470
+ async function configureTaskflowPreferences(
471
+ ctx: { signal: AbortSignal; ui: ExtensionUIContext },
472
+ current: TaskflowSettings,
473
+ ): Promise<InitFlowResult> {
474
+ const builtInPick = await ctx.ui.select(
475
+ "Taskflow built-in agents",
476
+ [
477
+ `Enable built-in agents${current.builtInAgents ? " (current)" : ""}`,
478
+ `Disable built-in agents${!current.builtInAgents ? " (current)" : ""}`,
479
+ "Back to action menu",
480
+ ],
481
+ { signal: ctx.signal },
482
+ );
483
+ if (builtInPick === undefined || builtInPick === "Back to action menu") return { kind: "cancelled" };
484
+
485
+ const chosen: TaskflowSettings = {
486
+ ...DEFAULT_TASKFLOW_SETTINGS,
487
+ builtInAgents: builtInPick.startsWith("Enable"),
488
+ };
489
+
490
+ if (chosen.builtInAgents) {
491
+ const syncPick = await ctx.ui.select(
492
+ "Expose built-in agents to native Pi/project discovery?",
493
+ [
494
+ `Do not copy to project .pi/agents${!current.syncBuiltinAgentsToProject ? " (current)" : ""}`,
495
+ `Copy to project .pi/agents on session start${current.syncBuiltinAgentsToProject ? " (current)" : ""}`,
496
+ "Back to action menu",
497
+ ],
498
+ { signal: ctx.signal },
499
+ );
500
+ if (syncPick === undefined || syncPick === "Back to action menu") return { kind: "cancelled" };
501
+ chosen.syncBuiltinAgentsToProject = syncPick.startsWith("Copy");
502
+ }
503
+
504
+ if (taskflowSettingsIdentical(current, chosen)) {
505
+ return { kind: "preferences-no-change", settings: chosen };
506
+ }
507
+
508
+ const preview = await ctx.ui.select(
509
+ `Review taskflow preferences:\n\n${formatTaskflowSettingsReport(chosen)}`,
510
+ ["Save these preferences", "Cancel"],
511
+ { signal: ctx.signal },
512
+ );
513
+ if (preview !== "Save these preferences") return { kind: "cancelled" };
514
+
515
+ const savedPath = writeSettings({ ...readSettings(), taskflow: chosen });
516
+ return { kind: "preferences-saved", settings: chosen, savedPath };
517
+ }
518
+
440
519
  /** Collect picks for all roles. Returns undefined if user escapes to action menu. */
441
520
  async function collectRolePicks(
442
521
  ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
@@ -121,6 +121,10 @@ const DEFAULT_MAX_KEPT_TERMINAL = 100;
121
121
  /** Remove terminal runs older than this (days). */
122
122
  const DEFAULT_MAX_AGE_DAYS = 30;
123
123
 
124
+ // Re-exported for use in TaskflowSettings defaults (agents.ts).
125
+ export const DEFAULT_KEPT_RUNS = DEFAULT_MAX_KEPT_TERMINAL;
126
+ export const DEFAULT_RUN_AGE_DAYS = DEFAULT_MAX_AGE_DAYS;
127
+
124
128
  /** Last cleanup timestamp — module-level so it persists across calls. */
125
129
  let lastCleanupAt = 0;
126
130
 
@@ -460,6 +464,12 @@ function cleanupTerminalRuns(
460
464
 
461
465
  if (toRemove.length === 0) return;
462
466
 
467
+ console.warn(
468
+ `[taskflow] Cleaning up ${toRemove.length} old run(s) ` +
469
+ `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` +
470
+ `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`,
471
+ );
472
+
463
473
  // Delete run files + lock files (outside the index lock).
464
474
  for (const e of toRemove) {
465
475
  const filePath = path.join(runsRoot, e.relPath);
@@ -548,6 +558,8 @@ export function getFlow(cwd: string, name: string): SavedFlow | null {
548
558
  return listFlows(cwd).find((f) => f.name === name) ?? null;
549
559
  }
550
560
 
561
+ let _piCreationHinted = false;
562
+
551
563
  export function saveFlow(
552
564
  cwd: string,
553
565
  def: Taskflow,
@@ -558,9 +570,20 @@ export function saveFlow(
558
570
  const safe = def.name.replace(/[^\w.-]+/g, "_");
559
571
  const filePath = path.join(dir, `${safe}.json`);
560
572
  writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`);
573
+
574
+ // One-shot: let the user know we're creating a .pi/ directory on first save.
575
+ if (!_piCreationHinted) {
576
+ _piCreationHinted = true;
577
+ console.warn(
578
+ `[taskflow] Created .pi/taskflows/ for project-scoped flow storage. ` +
579
+ `Add .pi/ to .gitignore if desired.`,
580
+ );
581
+ }
582
+
561
583
  return { filePath };
562
584
  }
563
585
 
586
+
564
587
  // --- Run state ---
565
588
 
566
589
  function runsDir(cwd: string): string {
@@ -590,7 +613,7 @@ export function newRunId(flowName: string): string {
590
613
  * F-009: shallow-clones state before stamping updatedAt to avoid mutating the
591
614
  * caller's reference.
592
615
  */
593
- export function saveRun(state: RunState): void {
616
+ export function saveRun(state: RunState, cleanup?: { maxKeep?: number; maxAgeDays?: number }): void {
594
617
  const root = runsDir(state.cwd);
595
618
  const flowDir = flowRunDir(root, state.flowName);
596
619
  fs.mkdirSync(flowDir, { recursive: true });
@@ -608,7 +631,11 @@ export function saveRun(state: RunState): void {
608
631
  });
609
632
 
610
633
  // Opportunistic cleanup — throttled to once per CLEANUP_INTERVAL_MS.
611
- cleanupTerminalRuns(root);
634
+ const maxKeep = cleanup?.maxKeep ?? DEFAULT_MAX_KEPT_TERMINAL;
635
+ const maxAgeDays = cleanup?.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
636
+ if (maxKeep > 0 || maxAgeDays > 0) {
637
+ cleanupTerminalRuns(root, maxKeep, maxAgeDays);
638
+ }
612
639
  }
613
640
 
614
641
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Lightweight workflow orchestration for the Pi coding agent — declarative multi-phase taskflows with dynamic fan-out, isolated subagent context, resumable runs, and saveable commands.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,12 +31,14 @@
31
31
  "skills",
32
32
  "examples",
33
33
  "README.md",
34
+ "README.zh-CN.md",
35
+ "CHANGELOG.md",
34
36
  "DESIGN.md",
35
37
  "LICENSE"
36
38
  ],
37
39
  "scripts": {
38
40
  "typecheck": "tsc --noEmit",
39
- "test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/init.test.ts test/render.test.ts test/desugar.test.ts test/cache.test.ts test/loop.test.ts test/tournament.test.ts test/verify.test.ts test/gate-eval.test.ts",
41
+ "test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/init.test.ts test/render.test.ts test/desugar.test.ts test/cache.test.ts test/loop.test.ts test/tournament.test.ts test/verify.test.ts test/gate-eval.test.ts test/transient-error.test.ts test/runtime-branches.test.ts test/interpolate-extended.test.ts test/store-extended.test.ts",
40
42
  "test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts",
41
43
  "test:dogfood-cache": "node --experimental-strip-types test/dogfood-cache.mts"
42
44
  },
@@ -46,7 +48,8 @@
46
48
  ],
47
49
  "skills": [
48
50
  "./skills"
49
- ]
51
+ ],
52
+ "image": "https://raw.githubusercontent.com/heggria/pi-taskflow/main/assets/social-preview.png"
50
53
  },
51
54
  "peerDependencies": {
52
55
  "@earendil-works/pi-agent-core": "*",