pi-taskflow 0.0.12 → 0.0.14

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,13 +10,19 @@
10
10
  * host conversation context — only the final phase output is returned.
11
11
  */
12
12
 
13
- import * as fs from "node:fs";
14
- import * as path from "node:path";
15
13
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
14
  import { StringEnum } from "@earendil-works/pi-ai";
17
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
+ import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
19
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";
20
26
  import { Type } from "typebox";
21
27
  import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
22
28
  import { renderRunResult, summarizeRun } from "./render.ts";
@@ -33,6 +39,7 @@ import {
33
39
  saveFlow,
34
40
  saveRun,
35
41
  } from "./store.ts";
42
+ import { CacheStore } from "./cache.ts";
36
43
 
37
44
  interface TaskflowDetails {
38
45
  state?: RunState;
@@ -53,8 +60,8 @@ const ShorthandStep = Type.Object(
53
60
  );
54
61
 
55
62
  const TaskflowParams = Type.Object({
56
- action: StringEnum(["run", "save", "resume", "list", "agents", "init"] as const, {
57
- description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, or init model role configuration",
63
+ action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "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",
58
65
  default: "run",
59
66
  }),
60
67
  name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
@@ -87,6 +94,19 @@ const TaskflowParams = Type.Object({
87
94
  scope: Type.Optional(
88
95
  StringEnum(["user", "project"] as const, { description: "Where to save (action=save)", default: "project" }),
89
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
+ ),
90
110
  });
91
111
 
92
112
  function makeRunState(def: Taskflow, args: Record<string, unknown>, cwd: string): RunState {
@@ -258,7 +278,7 @@ export default function (pi: ExtensionAPI) {
258
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}).",
259
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.",
260
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?}]}.",
261
- "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.",
262
282
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
263
283
  ].join(" "),
264
284
  parameters: TaskflowParams,
@@ -273,52 +293,81 @@ export default function (pi: ExtensionAPI) {
273
293
  const action = params.action ?? "run";
274
294
 
275
295
  // init — configure model roles
276
- if (action === "init") {
277
- const settingsPath = path.join(getAgentDir(), "settings.json");
278
- let existing: Record<string, unknown> = {};
279
- try { existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch {}
280
-
281
- const roleDescs: Record<string, string> = {
282
- fast: "cheap & quick (executor, scout, recover, verifier, doc-writer, test-engineer)",
283
- strong: "balanced (planner, reviewer, executor-code)",
284
- thinker: "deep analysis (analyst, critic)",
285
- arbiter: "final judgment (plan-arbiter, final-arbiter)",
286
- vision: "multimodal (executor-ui, visual-explorer)",
287
- reasoner: "cautious reasoning (risk-reviewer, security-reviewer)",
288
- };
289
-
290
- if (existing.modelRoles) {
291
- const roles = existing.modelRoles as Record<string, string>;
292
- const text = [
293
- `Model roles already configured in ${settingsPath}:`,
294
- ...Object.entries(roles).map(([k, v]) => ` ${k.padEnd(10)} → ${v} (${roleDescs[k] ?? ""})`),
295
- ``,
296
- `To reconfigure, run /tf init interactively or edit settings.json directly.`,
297
- ].join("\n");
298
- return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
299
- }
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
+ }
300
326
 
301
- const defaults: Record<string, string> = {
302
- fast: "openrouter/deepseek/deepseek-v4-flash",
303
- strong: "openrouter/xiaomi/mimo-v2.5-pro",
304
- thinker: "openrouter/deepseek/deepseek-v4-pro",
305
- arbiter: "openrouter/qwen/qwen3.7-max",
306
- vision: "minimax/MiniMax-M3",
307
- reasoner: "z-ai/glm-5.1",
308
- };
309
-
310
- const newSettings = { ...existing, modelRoles: defaults };
311
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
312
- fs.writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2) + "\n", "utf-8");
313
-
314
- const text = [
315
- `Wrote default model roles to ${settingsPath}:`,
316
- ...Object.entries(defaults).map(([k, v]) => ` ${k.padEnd(10)} → ${v} (${roleDescs[k]})`),
317
- ``,
318
- `These models require provider-specific API keys. Edit settings.json or run /tf init interactively.`,
319
- ].join("\n");
320
- return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
321
- }
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
+ }
322
371
 
323
372
  // agents — list available agents the LLM can use in phase definitions
324
373
  if (action === "agents") {
@@ -345,6 +394,61 @@ export default function (pi: ExtensionAPI) {
345
394
  return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
346
395
  }
347
396
 
397
+ if (action === "verify") {
398
+ const { verifyTaskflow } = await import("./verify.ts");
399
+ // Load definition: inline define takes priority, then saved name
400
+ let def: Taskflow | undefined;
401
+ if (params.define) {
402
+ const d = params.define as Record<string, unknown>;
403
+ if (typeof d === "object" && d !== null && Array.isArray(d.phases)) {
404
+ def = d as unknown as Taskflow;
405
+ } else if (isShorthand(params.define)) {
406
+ const r = validateTaskflow(params.define);
407
+ if (r.ok) def = params.define as unknown as Taskflow;
408
+ }
409
+ } else if (params.name) {
410
+ const saved = getFlow(ctx.cwd, params.name);
411
+ if (saved) def = saved.def;
412
+ }
413
+ if (!def) {
414
+ return errorResult(action, "Provide 'define' (DSL) or 'name' (saved flow) to verify.");
415
+ }
416
+ // Schema validation first
417
+ const vr = validateTaskflow(def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined });
418
+ if (!vr.ok) {
419
+ return errorResult(action, `Schema validation failed:\n${vr.errors.join("\n")}`);
420
+ }
421
+ const result = verifyTaskflow({ name: def.name!, phases: def.phases!, budget: def.budget, concurrency: def.concurrency });
422
+ const lines: string[] = [];
423
+ lines.push(`# Verification of "${def.name}"`);
424
+ lines.push("");
425
+ if (result.issues.length === 0) {
426
+ lines.push("✅ No issues found.");
427
+ } else {
428
+ const errors = result.issues.filter((i) => i.severity === "error");
429
+ const warnings = result.issues.filter((i) => i.severity === "warning");
430
+ if (errors.length) {
431
+ lines.push(`## Errors (${errors.length})`);
432
+ for (const e of errors) lines.push(`- **${e.category}**${e.phaseId ? ` [${e.phaseId}]` : ""}: ${e.message}`);
433
+ }
434
+ if (warnings.length) {
435
+ lines.push(`## Warnings (${warnings.length})`);
436
+ for (const w of warnings) lines.push(`- ${w.category}${w.phaseId ? ` [${w.phaseId}]` : ""}: ${w.message}`);
437
+ }
438
+ lines.push("");
439
+ lines.push(result.ok ? "Status: PASS (no errors)" : "Status: FAIL (errors found)");
440
+ }
441
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { action } satisfies TaskflowDetails };
442
+ }
443
+
444
+ if (action === "cache-clear") {
445
+ const removed = new CacheStore(ctx.cwd).clear();
446
+ return {
447
+ content: [{ type: "text", text: `Cleared ${removed} cross-run cache entr${removed === 1 ? "y" : "ies"}.` }],
448
+ details: { action } satisfies TaskflowDetails,
449
+ };
450
+ }
451
+
348
452
  // resume
349
453
  if (action === "resume") {
350
454
  if (!params.runId)
@@ -559,85 +663,57 @@ export default function (pi: ExtensionAPI) {
559
663
  }
560
664
 
561
665
  if (sub === "init") {
562
- const settingsPath = path.join(getAgentDir(), "settings.json");
563
- let existing: Record<string, unknown> = {};
564
- try { existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch {}
565
- const currentRoles = (existing.modelRoles ?? {}) as Record<string, string>;
566
-
567
- // Role definitions: name { description, recommended models }
568
- // Role definitions: name → description (no per-role filtering)
569
- const roleDefs: Array<{ role: string; desc: string }> = [
570
- { role: "fast", desc: "Cheap & quick — high-volume, low-stakes tasks (executor, scout, recover, verifier, doc-writer, test-engineer)" },
571
- { role: "strong", desc: "Balanced — planning, review, moderate complexity (planner, reviewer, executor-code)" },
572
- { role: "thinker", desc: "Deep analysis requirements, ambiguity detection, critique (analyst, critic)" },
573
- { role: "arbiter", desc: "Final judgment — tiebreak, plan quality gates (plan-arbiter, final-arbiter)" },
574
- { role: "vision", desc: "Multimodal — UI work, design reading, Figma analysis (executor-ui, visual-explorer)" },
575
- { role: "reasoner", desc: "Cautious reasoning — security, risk review, sensitive changes (risk-reviewer, security-reviewer)" },
576
- ];
666
+ let settings: Record<string, unknown>;
667
+ try {
668
+ settings = readSettings();
669
+ } catch (e) {
670
+ ctx.ui.notify(
671
+ `Failed to read settings.json: ${e instanceof Error ? e.message : String(e)}`,
672
+ "error",
673
+ );
674
+ return;
675
+ }
676
+ const currentRoles = (settings.modelRoles ?? {}) as Record<string, string>;
577
677
 
578
678
  if (!ctx.hasUI) {
579
679
  if (Object.keys(currentRoles).length > 0) {
580
680
  ctx.ui.notify(
581
- `Current model roles:\n` +
582
- Object.entries(currentRoles).map(([k, v]) => ` ${k.padEnd(10)} → ${v}`).join("\n"),
583
- "info"
681
+ formatRolesReport(currentRoles),
682
+ "info",
584
683
  );
585
684
  } else {
586
685
  ctx.ui.notify(
587
- `No modelRoles configured. Run /tf init in an interactive session to select models.`,
588
- "warning"
686
+ "No modelRoles configured. Run /tf init in an interactive session to select models.",
687
+ "warning",
589
688
  );
590
689
  }
591
690
  return;
592
691
  }
593
692
 
594
- // Use the user's scoped/enabled models (same list as /model command).
595
- // Fall back to all auth-configured models if none are scoped.
596
- const enabledModels = (existing.enabledModels as string[] | undefined) ?? [];
597
- const modelList = enabledModels.length > 0
598
- ? enabledModels
599
- : ctx.modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
600
-
601
- // Interactive: walk through each role using the same model list
602
- const chosen: Record<string, string> = {};
603
- for (const rd of roleDefs) {
604
- const current = currentRoles[rd.role];
605
-
606
- const seen = new Set<string>();
607
- const options: string[] = [];
608
- for (const m of modelList) {
609
- if (seen.has(m)) continue;
610
- seen.add(m);
611
- options.push(m === current ? `${m} (current)` : m);
612
- }
613
- options.push("───────────────");
614
- options.push("Custom (type your own)");
615
-
616
- const title = `Model for '${rd.role}' — ${rd.desc}` + (current ? `\nCurrent: ${current}` : "");
617
- const pick = await ctx.ui.select(title, options, { signal: ctx.signal });
618
-
619
- if (!pick || pick.startsWith("───")) {
620
- chosen[rd.role] = current ?? modelList[0] ?? "";
621
- continue;
622
- }
623
-
624
- if (pick === "Custom (type your own)") {
625
- const custom = await ctx.ui.input(`Enter model identifier for '${rd.role}'`, "provider/model-id", { signal: ctx.signal });
626
- chosen[rd.role] = custom?.trim() || current || "";
627
- } else {
628
- chosen[rd.role] = pick.replace(" (current)", "");
629
- }
630
- }
631
-
632
- // Save
633
- const newSettings = { ...existing, modelRoles: chosen };
634
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
635
- fs.writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2) + "\n", "utf-8");
636
-
693
+ const enabledModels = (settings.enabledModels as string[] | undefined) ?? [];
694
+ const modelList =
695
+ enabledModels.length > 0
696
+ ? enabledModels
697
+ .map((id) => ctx.modelRegistry.find(id.split("/")[0], id.split("/").slice(1).join("/")))
698
+ .filter((m): m is NonNullable<typeof m> => m !== undefined)
699
+ : ctx.modelRegistry.getAvailable();
700
+ const result = await runInteractiveInit({
701
+ hasUI: ctx.hasUI,
702
+ signal: ctx.signal ?? new AbortController().signal,
703
+ ui: ctx.ui,
704
+ modelRegistry: ctx.modelRegistry,
705
+ modelList,
706
+ currentRoles,
707
+ });
637
708
  ctx.ui.notify(
638
- `Saved model roles to ${settingsPath}:\n` +
639
- Object.entries(chosen).map(([k, v]) => ` ${k.padEnd(10)}${v}`).join("\n"),
640
- "info"
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",
641
717
  );
642
718
  return;
643
719
  }