pi-taskflow 0.0.22 → 0.0.24

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.
@@ -44,6 +44,16 @@ import {
44
44
  } from "./store.ts";
45
45
  import { CacheStore } from "./cache.ts";
46
46
  import { safeParse } from "./interpolate.ts";
47
+ import {
48
+ isValidKey,
49
+ queueSpawn,
50
+ readVisibleFindings,
51
+ readTree,
52
+ nodeDepth,
53
+ writeFinding,
54
+ writeReport,
55
+ } from "./context-store.ts";
56
+ import { MAX_DYNAMIC_NESTING } from "./schema.ts";
47
57
 
48
58
  interface TaskflowDetails {
49
59
  state?: RunState;
@@ -73,8 +83,8 @@ const ShorthandStep = Type.Object(
73
83
  );
74
84
 
75
85
  const TaskflowParams = Type.Object({
76
- action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "cache-clear"] as const, {
77
- 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",
86
+ action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "compile", "cache-clear"] as const, {
87
+ description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, verify the DAG, compile the DAG to a Mermaid diagram + verification report, or clear the cross-run memoization cache",
78
88
  default: "run",
79
89
  }),
80
90
  name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
@@ -292,6 +302,21 @@ async function runFlow(
292
302
  }
293
303
 
294
304
  export default function (pi: ExtensionAPI) {
305
+ // ---- Dual identity ----------------------------------------------------
306
+ // When this extension is loaded INSIDE a subagent process that the taskflow
307
+ // runtime spawned with Shared Context Tree enabled, PI_TASKFLOW_CTX_DIR +
308
+ // PI_TASKFLOW_NODE_ID are present. In that case we register the ctx_* tools
309
+ // (the blackboard + supervision API) instead of the host `taskflow` tool —
310
+ // a subagent has no business orchestrating its own taskflows, and the host
311
+ // tool's heavy machinery is irrelevant there. When the env is absent we are
312
+ // the host: register `taskflow` + `/tf` exactly as before (zero change).
313
+ const ctxDir = process.env.PI_TASKFLOW_CTX_DIR;
314
+ const nodeId = process.env.PI_TASKFLOW_NODE_ID;
315
+ if (ctxDir && nodeId) {
316
+ registerCtxTools(pi, ctxDir, nodeId);
317
+ return;
318
+ }
319
+
295
320
  // ---- Register per-saved-flow shortcut commands on session start ----
296
321
  const registerSavedFlowCommands = (ctx: ExtensionContext) => {
297
322
  const flows = listFlows(ctx.cwd);
@@ -377,6 +402,7 @@ export default function (pi: ExtensionAPI) {
377
402
  "Every delegation is tracked (runId), resumable across sessions, and saveable as /tf:<name> via action=save.",
378
403
  "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.",
379
404
  "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).",
405
+ "Use action=compile to generate a Mermaid diagram + verification report from a saved or inline flow — 0 tokens.",
380
406
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
381
407
  ].join(" "),
382
408
  parameters: TaskflowParams,
@@ -545,6 +571,46 @@ export default function (pi: ExtensionAPI) {
545
571
  return { content: [{ type: "text", text: lines.join("\n") }], details: { action } satisfies TaskflowDetails };
546
572
  }
547
573
 
574
+ if (action === "compile") {
575
+ const { compileTaskflow } = await import("./compile.ts");
576
+ // Resolve definition: inline define (object or JSON/fenced string) then saved name.
577
+ let def: Taskflow | undefined;
578
+ let resolvedDefine: unknown = params.define;
579
+ if (typeof resolvedDefine === "string") {
580
+ const parsed = safeParse(resolvedDefine);
581
+ if (parsed && typeof parsed === "object") resolvedDefine = parsed;
582
+ }
583
+ if (resolvedDefine) {
584
+ const d = resolvedDefine as Record<string, unknown>;
585
+ if (typeof d === "object" && d !== null && Array.isArray(d.phases)) {
586
+ def = d as unknown as Taskflow;
587
+ } else if (isShorthand(resolvedDefine)) {
588
+ try {
589
+ def = desugar(resolvedDefine) as Taskflow;
590
+ } catch (e) {
591
+ return errorResult(action, `Invalid shorthand: ${e instanceof Error ? e.message : String(e)}`);
592
+ }
593
+ }
594
+ } else if (params.name) {
595
+ const saved = getFlow(ctx.cwd, params.name);
596
+ if (saved) def = saved.def;
597
+ }
598
+ if (!def) {
599
+ return errorResult(action, "Provide 'define' (DSL) or 'name' (saved flow) to compile.");
600
+ }
601
+ // Schema validation first so a malformed graph gives a clean error
602
+ // rather than a half-rendered diagram.
603
+ const vr = validateTaskflow(def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined });
604
+ if (!vr.ok) {
605
+ return errorResult(action, `Schema validation failed:\n${vr.errors.join("\n")}`);
606
+ }
607
+ const compiled = compileTaskflow(def);
608
+ return {
609
+ content: [{ type: "text", text: compiled.markdown }],
610
+ details: { action } satisfies TaskflowDetails,
611
+ };
612
+ }
613
+
548
614
  if (action === "cache-clear") {
549
615
  const removed = new CacheStore(ctx.cwd).clear();
550
616
  return {
@@ -754,9 +820,9 @@ export default function (pi: ExtensionAPI) {
754
820
 
755
821
  // ---- The /tf user command ----
756
822
  pi.registerCommand("tf", {
757
- description: "Taskflow: list | run <name> | show <name> | runs | init",
823
+ description: "Taskflow: list | run <name> | show <name> | compile <name> | runs | init",
758
824
  getArgumentCompletions: (prefix) => {
759
- const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify"];
825
+ const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify", "compile"];
760
826
  const items = subs.map((s) => ({ value: s, label: s }));
761
827
  const filtered = items.filter((i) => i.value.startsWith(prefix));
762
828
  return filtered.length > 0 ? filtered : null;
@@ -785,6 +851,33 @@ export default function (pi: ExtensionAPI) {
785
851
  return;
786
852
  }
787
853
 
854
+ if (sub === "compile") {
855
+ if (!arg) {
856
+ ctx.ui.notify("Usage: /tf compile <name> [lr|td]", "warning");
857
+ return;
858
+ }
859
+ // `arg` may carry an optional direction suffix: "<name> lr" / "<name> td".
860
+ const parts = arg.trim().split(/\s+/);
861
+ const flowName = parts[0];
862
+ const direction = parts[1]?.toLowerCase() === "lr" ? "LR" : "TD";
863
+ const flow = getFlow(ctx.cwd, flowName);
864
+ if (!flow) {
865
+ ctx.ui.notify(`Flow not found: ${flowName}`, "error");
866
+ return;
867
+ }
868
+ // Schema-validate before compiling so a malformed saved flow yields a
869
+ // clean error rather than a half-rendered diagram (mirrors the tool action).
870
+ const vr = validateTaskflow(flow.def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined });
871
+ if (!vr.ok) {
872
+ ctx.ui.notify(`Schema validation failed:\n${vr.errors.join("\n")}`, "error");
873
+ return;
874
+ }
875
+ const { compileTaskflow } = await import("./compile.ts");
876
+ const compiled = compileTaskflow(flow.def, { direction });
877
+ ctx.ui.notify(compiled.markdown, compiled.verification.ok ? "info" : "warning");
878
+ return;
879
+ }
880
+
788
881
  if (sub === "runs") {
789
882
  const runs = listRuns(ctx.cwd, 50);
790
883
  if (runs.length === 0) {
@@ -912,6 +1005,116 @@ export default function (pi: ExtensionAPI) {
912
1005
 
913
1006
  // --- helpers ---
914
1007
 
1008
+ /**
1009
+ * Register the Shared Context Tree tools inside a subagent process. These read
1010
+ * & write the per-run blackboard at `ctxDir` on behalf of node `nodeId`.
1011
+ *
1012
+ * - ctx_read : read findings visible to this node (own + ancestors + completed others)
1013
+ * - ctx_write : write a finding (last-write-wins per key) so siblings can reuse it
1014
+ * - ctx_report : report a result upward to the parent
1015
+ * - ctx_spawn : queue child tasks the runtime picks up after this node finishes
1016
+ */
1017
+ function registerCtxTools(pi: ExtensionAPI, ctxDir: string, nodeId: string) {
1018
+ const textResult = (text: string, isError = false): ToolResult => ({
1019
+ content: [{ type: "text", text }],
1020
+ details: { action: "ctx" },
1021
+ ...(isError ? { isError: true } : {}),
1022
+ });
1023
+
1024
+ pi.registerTool({
1025
+ name: "ctx_read",
1026
+ label: "Context Read",
1027
+ description:
1028
+ "Read shared findings from the taskflow blackboard (what sibling/ancestor agents already discovered). Pass a key to read one value, or omit to list all visible findings. Use this BEFORE re-reading files another agent may have already mapped.",
1029
+ parameters: Type.Object({
1030
+ key: Type.Optional(Type.String({ description: "Specific finding key to read; omit to get all visible findings." })),
1031
+ }),
1032
+ async execute(_id, params) {
1033
+ try {
1034
+ const out = readVisibleFindings(ctxDir, nodeId, params.key);
1035
+ return textResult(typeof out === "string" ? out : JSON.stringify(out ?? null, null, 2));
1036
+ } catch (e) {
1037
+ return textResult(`ctx_read failed: ${e instanceof Error ? e.message : String(e)}`, true);
1038
+ }
1039
+ },
1040
+ });
1041
+
1042
+ pi.registerTool({
1043
+ name: "ctx_write",
1044
+ label: "Context Write",
1045
+ description:
1046
+ "Write a finding to the shared taskflow blackboard so sibling/descendant agents can reuse it without re-reading files. Key must be [A-Za-z0-9._-] (<=128 chars). Value is any JSON. Last write wins per key.",
1047
+ parameters: Type.Object({
1048
+ key: Type.String({ description: "Finding key, e.g. 'endpoints' or 'auth.summary'." }),
1049
+ value: Type.Unknown({ description: "The value to store (string, number, object, or array)." }),
1050
+ }),
1051
+ async execute(_id, params) {
1052
+ if (!isValidKey(params.key)) {
1053
+ return textResult(`ctx_write rejected: invalid key '${params.key}'.`, true);
1054
+ }
1055
+ try {
1056
+ writeFinding(ctxDir, nodeId, params.key, params.value);
1057
+ return textResult(`Stored finding '${params.key}'.`);
1058
+ } catch (e) {
1059
+ return textResult(`ctx_write failed: ${e instanceof Error ? e.message : String(e)}`, true);
1060
+ }
1061
+ },
1062
+ });
1063
+
1064
+ pi.registerTool({
1065
+ name: "ctx_report",
1066
+ label: "Context Report",
1067
+ description:
1068
+ "Report your result upward to the parent task. Provide a concise summary and optional structured JSON. The parent (and downstream phases) will see this report.",
1069
+ parameters: Type.Object({
1070
+ summary: Type.String({ description: "Concise summary of what you accomplished / found." }),
1071
+ structured: Type.Optional(Type.Unknown({ description: "Optional structured result (JSON)." })),
1072
+ }),
1073
+ async execute(_id, params) {
1074
+ try {
1075
+ writeReport(ctxDir, nodeId, params.summary, params.structured);
1076
+ return textResult("Report recorded.");
1077
+ } catch (e) {
1078
+ return textResult(`ctx_report failed: ${e instanceof Error ? e.message : String(e)}`, true);
1079
+ }
1080
+ },
1081
+ });
1082
+
1083
+ pi.registerTool({
1084
+ name: "ctx_spawn",
1085
+ label: "Context Spawn",
1086
+ description:
1087
+ "Delegate sub-tasks to NEW child agents. After you finish, the runtime runs each child (isolated context) and folds their reports back into your output. Use when you discover the work needs to fan out. Each assignment is EITHER {task, agent?} for one flat task, OR {subflow, defaultAgent?} where subflow is an inline plan {phases:[...]} (a dependency-bearing DAG: phases can use dependsOn / map / gate / reduce). Use a subflow when the delegated work itself has multiple coordinated steps.",
1088
+ parameters: Type.Object({
1089
+ assignments: Type.Array(
1090
+ Type.Object({
1091
+ task: Type.Optional(Type.String({ description: "A single child task prompt (use this OR subflow, not both)." })),
1092
+ agent: Type.Optional(Type.String({ description: "Agent name for a flat task (optional)." })),
1093
+ subflow: Type.Optional(Type.Unknown({ description: "An inline Taskflow plan {phases:[...]} or a bare phases array, run as a nested validated sub-flow." })),
1094
+ defaultAgent: Type.Optional(Type.String({ description: "Fallback agent for subflow phases that don't name their own (optional)." })),
1095
+ }),
1096
+ { description: "Child tasks to spawn (1..16). Each is a flat {task} or a {subflow} DAG." },
1097
+ ),
1098
+ }),
1099
+ async execute(_id, params) {
1100
+ // Depth cap: walk the parent chain in the tree to find this node's depth.
1101
+ try {
1102
+ const depth = nodeDepth(readTree(ctxDir), nodeId);
1103
+ if (depth >= MAX_DYNAMIC_NESTING) {
1104
+ return textResult(
1105
+ `ctx_spawn rejected: depth ${depth} >= MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING}). Do the work yourself.`,
1106
+ true,
1107
+ );
1108
+ }
1109
+ const n = queueSpawn(ctxDir, nodeId, params.assignments);
1110
+ return textResult(`Queued ${n} child task(s); they will run after you finish and their reports will be appended to your output.`);
1111
+ } catch (e) {
1112
+ return textResult(`ctx_spawn failed: ${e instanceof Error ? e.message : String(e)}`, true);
1113
+ }
1114
+ },
1115
+ });
1116
+ }
1117
+
915
1118
  function errorResult(action: string, message: string): ToolResult {
916
1119
  return {
917
1120
  content: [{ type: "text", text: message }],
@@ -60,6 +60,14 @@ export interface RunOptions {
60
60
  * the prior behaviour (no idle timeout). Defaults to DEFAULT_IDLE_TIMEOUT_MS.
61
61
  */
62
62
  idleTimeoutMs?: number;
63
+ /**
64
+ * Shared Context Tree (opt-in). When set, the spawned subagent receives
65
+ * PI_TASKFLOW_CTX_DIR + PI_TASKFLOW_NODE_ID in its environment and is loaded
66
+ * with this extension via `--extension`, so it can register the ctx_* tools
67
+ * (read/write/report/spawn) that read & write the per-run blackboard.
68
+ */
69
+ ctxDir?: string;
70
+ nodeId?: string;
63
71
  }
64
72
 
65
73
  /**
@@ -71,6 +79,41 @@ export interface RunOptions {
71
79
  */
72
80
  const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000;
73
81
 
82
+ /** The Shared Context Tree tool names a subagent may call when sharing is on. */
83
+ export const CTX_TOOL_NAMES = ["ctx_read", "ctx_write", "ctx_report", "ctx_spawn"] as const;
84
+
85
+ /**
86
+ * Guidance appended to a subagent's system prompt when the Shared Context Tree
87
+ * is enabled for its phase. Registering the ctx_* tools makes them AVAILABLE;
88
+ * this block is what makes the model actually USE them with the right discipline
89
+ * (read-before-you-explore; publish reusable findings; report up; delegate when
90
+ * work fans out). Kept short and imperative on purpose.
91
+ */
92
+ export const CTX_TOOLS_GUIDANCE = [
93
+ "## Shared Context Tree (you are part of a coordinated team of agents)",
94
+ "",
95
+ "You are one agent in a tree working a shared goal, with a shared blackboard",
96
+ "and an upward report channel. Use these tools deliberately \u2014 they save tokens",
97
+ "and prevent the team from duplicating work:",
98
+ "",
99
+ "- ctx_read(key?): BEFORE exploring the codebase or re-reading files, call",
100
+ " ctx_read with no arguments to see what teammates already discovered. If a",
101
+ " finding you need already exists, REUSE it instead of re-deriving it.",
102
+ "- ctx_write(key, value): when you discover something other agents will likely",
103
+ " need (a file map, an endpoint list, an interface, a config value), publish it",
104
+ " under a short key (e.g. 'endpoints', 'db.schema'). Keep values concise and",
105
+ " structured (JSON) so others can consume them directly.",
106
+ "- ctx_report(summary, structured?): when you finish, report your result upward",
107
+ " so the parent task and downstream steps can see it. Lead with the outcome.",
108
+ "- ctx_spawn(assignments[]): if you discover the work should fan out into",
109
+ " independent sub-tasks, delegate them as child agents. They run after you",
110
+ " finish and their reports are folded back into your output. Only spawn when it",
111
+ " genuinely parallelizes \u2014 otherwise just do the work yourself.",
112
+ "",
113
+ "Default habit: ctx_read first, do the work (reusing shared findings), ctx_write",
114
+ "anything reusable, then ctx_report your result.",
115
+ ].join("\n");
116
+
74
117
  export function isFailed(r: RunResult): boolean {
75
118
  return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
76
119
  }
@@ -281,6 +324,25 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
281
324
  return { command: "pi", args };
282
325
  }
283
326
 
327
+ /**
328
+ * Resolve the path to this extension's entry file, so a spawned subagent can be
329
+ * launched with `--extension <path>` and register the ctx_* tools. Returns
330
+ * undefined if it cannot be resolved (the subagent then simply runs without the
331
+ * ctx tools — fail-open: context sharing degrades to "no sharing").
332
+ */
333
+ export function ctxExtensionPath(): string | undefined {
334
+ const override = process.env.PI_TASKFLOW_EXT_PATH;
335
+ if (override) return override;
336
+ try {
337
+ const here = path.dirname(new URL(import.meta.url).pathname);
338
+ const entry = path.join(here, "index.ts");
339
+ if (fs.existsSync(entry)) return entry;
340
+ } catch {
341
+ /* fall through */
342
+ }
343
+ return undefined;
344
+ }
345
+
284
346
  /**
285
347
  * Run a single subagent task. Resolves the agent from `agents` by name and
286
348
  * spawns an isolated pi process, returning structured output + usage.
@@ -310,7 +372,15 @@ export async function runAgentTask(
310
372
 
311
373
  const model = opts.model ?? agent.model;
312
374
  const thinking = opts.thinking ?? agent.thinking ?? globalThinking;
313
- const tools = opts.tools ?? agent.tools;
375
+ const ctxEnabledEarly = Boolean(opts.ctxDir && opts.nodeId);
376
+ let tools = opts.tools ?? agent.tools;
377
+ // If the agent restricts tools to a whitelist, the ctx_* tools we register
378
+ // would be filtered out by `--tools` even though they're registered. When
379
+ // context sharing is on, extend the whitelist so the subagent can actually
380
+ // call them. (No whitelist = all tools available = nothing to do.)
381
+ if (ctxEnabledEarly && tools && tools.length > 0) {
382
+ tools = [...new Set([...tools, ...CTX_TOOL_NAMES])];
383
+ }
314
384
 
315
385
  const args: string[] = ["--mode", "json", "-p", "--no-session"];
316
386
  if (model) args.push("--model", model);
@@ -332,18 +402,40 @@ export async function runAgentTask(
332
402
  };
333
403
 
334
404
  try {
335
- if (agent.systemPrompt.trim()) {
405
+ const ctxEnabled = Boolean(opts.ctxDir && opts.nodeId);
406
+ // Build the appended system prompt = the agent's own prompt PLUS, when the
407
+ // Shared Context Tree is enabled for this phase, a guidance block that tells
408
+ // the subagent the ctx_* tools exist and the discipline for using them.
409
+ // Without this the model only sees terse tool descriptions and rarely uses
410
+ // them proactively (capability != usage).
411
+ const appendedPrompt = [agent.systemPrompt.trim(), ctxEnabled ? CTX_TOOLS_GUIDANCE : ""]
412
+ .filter(Boolean)
413
+ .join("\n\n");
414
+ if (appendedPrompt) {
336
415
  // Allocate the temp dir + path BEFORE any fallible I/O so that if
337
416
  // writeFile throws, tmpPromptDir/tmpPromptPath are already set and
338
417
  // the finally block can clean up the directory (F-004).
339
418
  tmpPromptDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-taskflow-"));
340
419
  const safeName = agent.name.replace(/[^\w.-]+/g, "_");
341
420
  tmpPromptPath = path.join(tmpPromptDir, `prompt-${safeName}.md`);
342
- await writePromptToTempFile(tmpPromptPath, agent.systemPrompt);
421
+ await writePromptToTempFile(tmpPromptPath, appendedPrompt);
343
422
  args.push("--append-system-prompt", tmpPromptPath);
344
423
  }
345
424
  args.push(`Task: ${task}`);
346
425
 
426
+ // Shared Context Tree opt-in: load THIS extension into the subagent so it
427
+ // can register the ctx_* tools, and pass the blackboard dir + node id via
428
+ // env. `--extension` is the explicit, self-documenting fallback that does
429
+ // not rely on the subagent auto-discovering user/project extensions in
430
+ // `-p` mode. The env vars drive the dual-identity branch in index.ts.
431
+ const ctxEnv: Record<string, string> = {};
432
+ if (opts.ctxDir && opts.nodeId) {
433
+ const selfPath = ctxExtensionPath();
434
+ if (selfPath) args.push("--extension", selfPath);
435
+ ctxEnv.PI_TASKFLOW_CTX_DIR = opts.ctxDir;
436
+ ctxEnv.PI_TASKFLOW_NODE_ID = opts.nodeId;
437
+ }
438
+
347
439
  let wasAborted = false;
348
440
  let idleTimedOut = false;
349
441
  let killedBySignal: string | undefined;
@@ -353,6 +445,7 @@ export async function runAgentTask(
353
445
  cwd: opts.cwd ?? defaultCwd,
354
446
  shell: false,
355
447
  stdio: ["ignore", "pipe", "pipe"],
448
+ env: { ...process.env, ...ctxEnv },
356
449
  });
357
450
  if (proc.pid) activeChildren.add(proc.pid);
358
451
  let buffer = "";