frappe-builder 1.1.0-dev.8 → 1.2.0-dev.29

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.
@@ -1,26 +1,37 @@
1
1
  import { getCurrentPhase, db } from "../state/db.js";
2
2
  import { isToolAllowedInPhase, getValidPhase, type Phase } from "../state/fsm.js";
3
3
  import { appendEntry } from "../state/journal.js";
4
+ import { loadConfig } from "../config/loader.js";
5
+ import { type PermissionMode, DEFAULT_PERMISSION_MODE } from "../config/defaults.js";
6
+ import { ALWAYS_ALLOWED_TOOLS, WRITE_TOOLS } from "../config/constants.js";
7
+ import type { PiPlugin, PiUiContext } from "./pi-types.js";
8
+
9
+ // Re-export for use in tests and other modules
10
+ export type { PermissionMode };
11
+
12
+ // WRITE_TOOLS imported from config/constants.ts — single source of truth
13
+ export { WRITE_TOOLS };
4
14
 
5
15
  interface BlockedResponse {
6
16
  blocked: true;
7
17
  tool: string;
8
18
  current_phase: Phase;
9
- valid_phase: Phase | "any";
19
+ valid_phase: string;
10
20
  message: string;
11
21
  }
12
22
 
13
23
  function buildBlockedResponse(
14
24
  tool: string,
15
25
  currentPhase: Phase,
16
- validPhase: Phase | "any"
26
+ validPhase: Phase | Phase[] | "any"
17
27
  ): BlockedResponse {
28
+ const validLabel = Array.isArray(validPhase) ? validPhase.join(", ") : validPhase;
18
29
  return {
19
30
  blocked: true,
20
31
  tool,
21
32
  current_phase: currentPhase,
22
- valid_phase: validPhase,
23
- message: `${tool} is not available in ${currentPhase} phase. Available in: ${validPhase}`,
33
+ valid_phase: validLabel,
34
+ message: `${tool} is not available in ${currentPhase} phase. Available in: ${validLabel}`,
24
35
  };
25
36
  }
26
37
 
@@ -33,15 +44,14 @@ function buildBlockedResponse(
33
44
  *
34
45
  * Never throws — always returns a value or undefined.
35
46
  */
36
- // Tools valid in all phases never blocked by the phase guard (FR34)
37
- const ALWAYS_ALLOWED_TOOLS = ["invoke_debugger", "end_debug", "spawn_agent", "get_frappe_docs", "get_audit_log"];
47
+ // ALWAYS_ALLOWED_TOOLS imported from config/constants.tssingle source of truth
38
48
 
39
49
  export function beforeToolCall(
40
50
  toolName: string,
41
51
  args: Record<string, unknown>
42
52
  ): BlockedResponse | undefined {
43
53
  // Always-allowed bypass — checked before everything else
44
- if (ALWAYS_ALLOWED_TOOLS.includes(toolName)) return undefined;
54
+ if (ALWAYS_ALLOWED_TOOLS.has(toolName)) return undefined;
45
55
 
46
56
  const currentPhase = getCurrentPhase() as Phase;
47
57
 
@@ -73,13 +83,72 @@ export function beforeToolCall(
73
83
  return undefined; // allow
74
84
  }
75
85
 
76
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
- export default function (pi: any) {
78
- pi.on("tool_call", (event: { toolName?: string; input?: Record<string, unknown> }) => {
79
- const result = beforeToolCall(event.toolName ?? "", event.input ?? {});
80
- if (result) {
81
- return { block: true, reason: result.message };
86
+ /**
87
+ * Checks the active permission mode for write tools.
88
+ * - auto: always allowed
89
+ * - plan: always blocked with dry-run message
90
+ * - default: prompts via ctx.ui.input(); falls through if unavailable
91
+ *
92
+ * Returns a blocked response to halt execution, or undefined to allow.
93
+ * Never throws.
94
+ */
95
+ export async function checkPermissionMode(
96
+ toolName: string,
97
+ mode: PermissionMode,
98
+ ctx?: PiUiContext,
99
+ ): Promise<BlockedResponse | undefined> {
100
+ if (!WRITE_TOOLS.has(toolName)) return undefined;
101
+ if (mode === "auto") return undefined;
102
+
103
+ if (mode === "plan") {
104
+ return {
105
+ blocked: true,
106
+ tool: toolName,
107
+ current_phase: getCurrentPhase() as Phase,
108
+ valid_phase: "any",
109
+ message: `[plan mode] ${toolName} is a write operation — dry-run only. Switch to default or auto mode to execute.`,
110
+ };
111
+ }
112
+
113
+ // default mode: prompt via ctx.ui.input()
114
+ if (ctx) {
115
+ try {
116
+ const answer = await ctx.ui.input?.(`Allow ${toolName}? (yes/no)`);
117
+ if (!["yes", "y"].includes(answer?.toLowerCase?.() ?? "")) {
118
+ return {
119
+ blocked: true,
120
+ tool: toolName,
121
+ current_phase: getCurrentPhase() as Phase,
122
+ valid_phase: "any",
123
+ message: `${toolName} blocked by user.`,
124
+ };
125
+ }
126
+ } catch {
127
+ // ctx.ui.input unavailable — fail open (allow)
128
+ }
129
+ }
130
+
131
+ return undefined;
132
+ }
133
+
134
+ export default function (pi: PiPlugin) {
135
+ pi.on("tool_call", async (event: { toolName?: string; input?: Record<string, unknown> }, ctx?: PiUiContext) => {
136
+ const toolName = event.toolName ?? "";
137
+
138
+ // Phase guard
139
+ const phaseResult = beforeToolCall(toolName, event.input ?? {});
140
+ if (phaseResult) {
141
+ return { block: true, reason: phaseResult.message };
142
+ }
143
+
144
+ // Permission mode guard (Story 9.2–9.4)
145
+ const config = loadConfig();
146
+ const mode: PermissionMode = config.permissionMode ?? DEFAULT_PERMISSION_MODE;
147
+ const permResult = await checkPermissionMode(toolName, mode, ctx);
148
+ if (permResult) {
149
+ return { block: true, reason: permResult.message };
82
150
  }
151
+
83
152
  return undefined;
84
153
  });
85
154
  }
@@ -0,0 +1,53 @@
1
+ import type { AfterToolCallContext, AfterToolCallResult } from "@mariozechner/pi-agent-core";
2
+
3
+ /** Minimal structural type for the pi event context — covers all current usages.
4
+ * Methods are optional because the pi API is unversioned and tests use partial mocks.
5
+ * All call sites wrap invocations in try-catch or use optional chaining. */
6
+ export interface PiUiContext {
7
+ ui: Partial<{
8
+ setWidget(name: string, lines: string[], options?: { placement?: string }): void;
9
+ setStatus(name: string, message: string): void;
10
+ notify(message: string, severity: string): void;
11
+ input(prompt: string): Promise<string>;
12
+ }>;
13
+ }
14
+
15
+ /** Structural type for the `pi` plugin object passed to each extension's default export.
16
+ * Derived from observed pi-agent-core v0.62.0 behaviour; no published schema exists. */
17
+ export interface PiPlugin {
18
+ on(event: "session_start", handler: (event?: unknown, ctx?: PiUiContext) => void | Promise<void>): void;
19
+ on(event: "session_shutdown", handler: (event?: unknown, ctx?: PiUiContext) => void): void;
20
+ on(
21
+ event: "tool_call",
22
+ handler: (
23
+ event: { toolName?: string; input?: Record<string, unknown> },
24
+ ctx?: PiUiContext,
25
+ ) => unknown,
26
+ ): void;
27
+ on(event: "tool_result", handler: (event?: unknown, ctx?: PiUiContext) => void): void;
28
+ on(
29
+ event: "after_tool_call",
30
+ handler: (
31
+ ctx: AfterToolCallContext,
32
+ signal?: AbortSignal,
33
+ ) => AfterToolCallResult | undefined | Promise<AfterToolCallResult | undefined>,
34
+ ): void;
35
+ registerTool(definition: PiToolDefinition): void;
36
+ registerCommand?: (name: string, definition: PiCommandDefinition) => void;
37
+ }
38
+
39
+ export interface PiToolDefinition {
40
+ name: string;
41
+ label: string;
42
+ description: string;
43
+ parameters: unknown;
44
+ execute: (toolCallId: string, params: unknown) => Promise<{
45
+ content: Array<{ type: string; text: string }>;
46
+ details?: unknown;
47
+ }>;
48
+ }
49
+
50
+ export interface PiCommandDefinition {
51
+ description: string;
52
+ handler: (args: string, ctx: Record<string, unknown>) => Promise<string>;
53
+ }
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "frappe-builder",
3
- "version": "1.1.0-dev.8",
3
+ "version": "1.2.0-dev.29",
4
4
  "description": "Frappe-native AI co-pilot for building and customising Frappe/ERPNext applications",
5
5
  "type": "module",
6
+ "bin": {
7
+ "frappe-builder": "./dist/cli.mjs"
8
+ },
6
9
  "pi": {
7
- "_note": "TODO: verify 'pi' field schema against @mariozechner/pi-agent-core docs — no schema found in installed package. Reference schema below is a best-guess pending confirmation.",
8
10
  "extensions": [
9
11
  "./extensions/frappe-session.ts",
10
12
  "./extensions/frappe-state.ts",
@@ -35,6 +37,7 @@
35
37
  "dependencies": {
36
38
  "@mariozechner/pi-agent-core": "0.62.0",
37
39
  "@mariozechner/pi-ai": "0.62.0",
40
+ "@mariozechner/pi-coding-agent": "^0.63.1",
38
41
  "@types/better-sqlite3": "^7.6.13",
39
42
  "better-sqlite3": "^12.8.0",
40
43
  "execa": "^9.6.1",
@@ -0,0 +1,85 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { db } from "./db.js";
4
+
5
+ /**
6
+ * Returns the artifact directory for a feature.
7
+ * When app_path is set: {app_path}/.frappe-builder/{featureId}/
8
+ * Fallback (no app_path): {cwd}/.frappe-builder/{featureId}/
9
+ */
10
+ export function getArtifactDir(featureId: string): string {
11
+ const row = db
12
+ .prepare("SELECT app_path FROM sessions WHERE is_active = 1 LIMIT 1")
13
+ .get() as { app_path: string | null } | undefined;
14
+ const root = row?.app_path ?? process.cwd();
15
+ return join(root, ".frappe-builder", featureId);
16
+ }
17
+
18
+ /**
19
+ * Regenerates sprint-status.yaml for the given feature from current DB state.
20
+ * Called after every create_component and complete_component.
21
+ * Creates the artifact directory if it does not exist.
22
+ * Non-fatal — caller should catch any errors.
23
+ */
24
+ export function regenerateSprintStatus(featureId: string): void {
25
+ const feature = db
26
+ .prepare("SELECT feature_id, name, mode, current_phase FROM features WHERE feature_id = ?")
27
+ .get(featureId) as
28
+ | { feature_id: string; name: string; mode: string; current_phase: string }
29
+ | undefined;
30
+
31
+ if (!feature) return;
32
+
33
+ const components = db
34
+ .prepare(
35
+ `SELECT component_id, description, sort_order, status, completed_at
36
+ FROM components WHERE feature_id = ?
37
+ ORDER BY sort_order ASC, component_id ASC`
38
+ )
39
+ .all(featureId) as Array<{
40
+ component_id: string;
41
+ description: string | null;
42
+ sort_order: number;
43
+ status: string;
44
+ completed_at: string | null;
45
+ }>;
46
+
47
+ const done = components.filter((c) => c.status === "complete").length;
48
+ const total = components.length;
49
+
50
+ const componentLines = components
51
+ .map((c) => {
52
+ const descLine = c.description
53
+ ? ` description: "${c.description.replace(/"/g, '\\"')}"`
54
+ : "";
55
+ return [
56
+ ` - id: ${c.component_id}`,
57
+ descLine,
58
+ ` sort_order: ${c.sort_order}`,
59
+ ` status: ${c.status}`,
60
+ ` completed_at: ${c.completed_at ?? "null"}`,
61
+ ]
62
+ .filter(Boolean)
63
+ .join("\n");
64
+ })
65
+ .join("\n");
66
+
67
+ const yaml = `feature_id: ${feature.feature_id}
68
+ feature_name: "${feature.name.replace(/"/g, '\\"')}"
69
+ mode: ${feature.mode}
70
+ phase: ${feature.current_phase}
71
+ updated_at: ${new Date().toISOString()}
72
+
73
+ components:
74
+ ${componentLines || " []"}
75
+
76
+ progress:
77
+ done: ${done}
78
+ total: ${total}
79
+ `;
80
+
81
+ const artifactDir = getArtifactDir(featureId);
82
+ const implDir = join(artifactDir, "implementation-artifacts");
83
+ mkdirSync(implDir, { recursive: true });
84
+ writeFileSync(join(implDir, "sprint-status.yaml"), yaml, "utf-8");
85
+ }
package/state/db.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import Database from "better-sqlite3";
2
2
  import type { Database as DatabaseType } from "better-sqlite3";
3
3
  import { mkdirSync } from "node:fs";
4
- import { initSchema } from "./schema.js";
4
+ import { initSchema, migrateSchema } from "./schema.js";
5
5
  import { appendEntry } from "./journal.js";
6
6
 
7
7
  mkdirSync(".fb", { recursive: true });
@@ -10,6 +10,7 @@ mkdirSync(".fb", { recursive: true });
10
10
  export let db: DatabaseType = new Database(".fb/state.db");
11
11
 
12
12
  initSchema(db);
13
+ migrateSchema(db);
13
14
 
14
15
  /**
15
16
  * Replaces the db singleton — intended for test use only.
@@ -24,9 +25,18 @@ export function setDb(instance: DatabaseType): void {
24
25
  * a new session for newProjectId, restoring its last known phase if a prior
25
26
  * session exists. Writes a "state_transition" JSONL entry before the close.
26
27
  *
27
- * sitePath is optional so existing callers without a site path continue to work.
28
+ * sitePath and appPath are optional so existing callers without them continue to work.
28
29
  */
29
- export function switchProject(newProjectId: string, sitePath?: string): void {
30
+ export interface ProjectCredentials {
31
+ sitePath?: string;
32
+ appPath?: string;
33
+ siteUrl?: string;
34
+ apiKey?: string;
35
+ apiSecret?: string;
36
+ }
37
+
38
+ export function switchProject(newProjectId: string, creds: ProjectCredentials = {}): void {
39
+ const { sitePath, appPath, siteUrl, apiKey, apiSecret } = creds;
30
40
  db.transaction(() => {
31
41
  // 1. Read current active session before closing
32
42
  const current = db
@@ -62,12 +72,16 @@ export function switchProject(newProjectId: string, sitePath?: string): void {
62
72
 
63
73
  // 5. Create new session, restoring prior phase if available; feature_id defaults to NULL
64
74
  db.prepare(
65
- "INSERT INTO sessions (session_id, project_id, current_phase, site_path, started_at, is_active) VALUES (?, ?, ?, ?, ?, 1)"
75
+ "INSERT INTO sessions (session_id, project_id, current_phase, site_path, app_path, site_url, api_key, api_secret, started_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)"
66
76
  ).run(
67
77
  crypto.randomUUID(),
68
78
  newProjectId,
69
79
  prior?.current_phase ?? "idle",
70
80
  sitePath ?? null,
81
+ appPath ?? null,
82
+ siteUrl ?? null,
83
+ apiKey ?? null,
84
+ apiSecret ?? null,
71
85
  new Date().toISOString()
72
86
  );
73
87
  })();
package/state/fsm.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createMachine, state, transition } from "robot3";
2
+ import { ALWAYS_ALLOWED_TOOLS } from "../config/constants.js";
2
3
 
3
4
  export type Phase =
4
5
  | "idle"
@@ -7,7 +8,8 @@ export type Phase =
7
8
  | "planning"
8
9
  | "implementation"
9
10
  | "testing"
10
- | "documentation";
11
+ | "documentation"
12
+ | "chain_running"; // sentinel: full-mode chain active externally, not an in-session FSM node
11
13
 
12
14
  export const ALL_PHASES: Phase[] = [
13
15
  "idle",
@@ -17,52 +19,70 @@ export const ALL_PHASES: Phase[] = [
17
19
  "implementation",
18
20
  "testing",
19
21
  "documentation",
22
+ "chain_running",
20
23
  ];
21
24
 
22
25
  function buildMachineStates() {
23
26
  return {
27
+ // Quick mode only: idle → implementation.
28
+ // Full mode phases (requirements, architecture, planning, testing, documentation)
29
+ // are handled by agent-chain.ts subprocesses — not in-session FSM transitions.
24
30
  idle: state(
25
- transition("start_full", "requirements"),
26
- transition("start_quick", "implementation") as never // quick mode bypass (Story 3.3)
31
+ transition("start_quick", "implementation")
27
32
  ),
28
- requirements: state(transition("approve", "architecture")),
29
- architecture: state(transition("approve", "planning")),
30
- planning: state(transition("approve", "implementation")),
31
33
  implementation: state(transition("approve", "testing")),
32
34
  testing: state(transition("approve", "documentation")),
33
35
  documentation: state(transition("complete", "idle")),
36
+ // chain_running is a storage sentinel — not a FSM node with transitions.
37
+ // The parent session cannot transition out of chain_running via FSM events;
38
+ // the chain runner updates the DB directly when the chain completes.
34
39
  };
35
40
  }
36
41
 
37
- /** Creates a Robot3 FSM starting at the given phase — fast-forward, no event replay. */
42
+ type FsmPhase = "idle" | "implementation" | "testing" | "documentation";
43
+ const FSM_PHASES = new Set<string>(["idle", "implementation", "testing", "documentation"]);
44
+
45
+ function isFsmPhase(p: string): p is FsmPhase {
46
+ return FSM_PHASES.has(p);
47
+ }
48
+
49
+ /** Creates a Robot3 FSM starting at the given phase — fast-forward, no event replay.
50
+ * Non-FSM phases (chain_running, requirements, architecture, planning) fall back to idle. */
38
51
  export function createFsmAtPhase(phase: Phase) {
39
- return createMachine(phase, buildMachineStates());
52
+ const fsmPhase: FsmPhase = isFsmPhase(phase) ? phase : "idle";
53
+ return createMachine(fsmPhase, buildMachineStates());
40
54
  }
41
55
 
42
56
  /**
43
57
  * Maps each tool to its valid phase (or 'any').
44
58
  * Tools not listed here are allowed in any phase — do not block unregistered tools.
45
59
  */
46
- const TOOL_PHASE_MAP: Record<string, Phase | "any"> = {
60
+ const TOOL_PHASE_MAP: Record<string, Phase | Phase[] | "any"> = {
47
61
  set_active_project: "any",
48
- start_feature: "idle",
62
+ start_feature: "idle", // blocked in chain_running to prevent double-start
63
+ create_component: ["planning", "implementation"],
49
64
  complete_component: "implementation",
50
- scaffold_doctype: "implementation",
51
65
  run_tests: "testing",
52
66
  get_project_status: "any",
53
67
  frappe_query: "any",
54
- bench_execute: "any", // allowed in all phases — includes testing (bench run-tests) and implementation (bench migrate)
68
+ bench_execute: "any",
55
69
  };
56
70
 
71
+ // ALWAYS_ALLOWED_TOOLS imported from config/constants.ts — single source of truth
72
+
57
73
  /** Returns true if toolName is allowed to run in the given phase. Unknown tools are always allowed. */
58
74
  export function isToolAllowedInPhase(toolName: string, phase: Phase): boolean {
75
+ if (ALWAYS_ALLOWED_TOOLS.has(toolName)) return true;
76
+ // chain_running: only always-allowed tools pass; all others (including start_feature) are blocked
77
+ if (phase === "chain_running") return false;
59
78
  const validPhase = TOOL_PHASE_MAP[toolName];
60
79
  if (validPhase === undefined) return true; // unknown tool — do not block
61
80
  if (validPhase === "any") return true;
81
+ if (Array.isArray(validPhase)) return validPhase.includes(phase);
62
82
  return validPhase === phase;
63
83
  }
64
84
 
65
85
  /** Returns the phase where toolName is valid, or 'any' for unrestricted/unknown tools. */
66
- export function getValidPhase(toolName: string): Phase | "any" {
86
+ export function getValidPhase(toolName: string): Phase | Phase[] | "any" {
67
87
  return TOOL_PHASE_MAP[toolName] ?? "any";
68
88
  }
package/state/schema.ts CHANGED
@@ -18,9 +18,13 @@ export function initSchema(db: Database): void {
18
18
  );
19
19
 
20
20
  CREATE TABLE IF NOT EXISTS components (
21
- component_id TEXT NOT NULL,
22
- feature_id TEXT NOT NULL,
23
- status TEXT NOT NULL DEFAULT 'in-progress',
21
+ component_id TEXT NOT NULL,
22
+ feature_id TEXT NOT NULL,
23
+ status TEXT NOT NULL DEFAULT 'in-progress'
24
+ CHECK (status IN ('in-progress', 'complete')),
25
+ description TEXT,
26
+ sort_order INTEGER NOT NULL DEFAULT 0,
27
+ created_at TEXT,
24
28
  completed_at TEXT,
25
29
  PRIMARY KEY (feature_id, component_id),
26
30
  FOREIGN KEY (feature_id) REFERENCES features(feature_id)
@@ -31,7 +35,14 @@ export function initSchema(db: Database): void {
31
35
  project_id TEXT NOT NULL,
32
36
  current_phase TEXT NOT NULL DEFAULT 'idle',
33
37
  site_path TEXT,
38
+ app_path TEXT,
39
+ site_url TEXT,
40
+ api_key TEXT,
41
+ api_secret TEXT,
42
+ chain_step TEXT,
43
+ chain_pid INTEGER,
34
44
  feature_id TEXT,
45
+ component_id TEXT,
35
46
  last_tool TEXT,
36
47
  started_at TEXT NOT NULL,
37
48
  ended_at TEXT,
@@ -39,3 +50,31 @@ export function initSchema(db: Database): void {
39
50
  );
40
51
  `);
41
52
  }
53
+
54
+ /**
55
+ * Adds new columns to existing databases without dropping data.
56
+ * Safe to call on any DB version — ignores "duplicate column" errors.
57
+ * Call this after initSchema() in db.ts.
58
+ */
59
+ export function migrateSchema(db: Database): void {
60
+ const alters = [
61
+ "ALTER TABLE components ADD COLUMN description TEXT",
62
+ "ALTER TABLE components ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
63
+ "ALTER TABLE components ADD COLUMN created_at TEXT",
64
+ "ALTER TABLE sessions ADD COLUMN component_id TEXT",
65
+ "ALTER TABLE sessions ADD COLUMN app_path TEXT",
66
+ "ALTER TABLE sessions ADD COLUMN chain_step TEXT",
67
+ "ALTER TABLE sessions ADD COLUMN chain_pid INTEGER",
68
+ "ALTER TABLE sessions ADD COLUMN site_url TEXT",
69
+ "ALTER TABLE sessions ADD COLUMN api_key TEXT",
70
+ "ALTER TABLE sessions ADD COLUMN api_secret TEXT",
71
+ ];
72
+ for (const sql of alters) {
73
+ try {
74
+ db.exec(sql);
75
+ } catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ if (!msg.includes("duplicate column name")) throw err;
78
+ }
79
+ }
80
+ }
@@ -1,4 +1,12 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
1
5
  import { loadConfig } from "../config/loader.js";
6
+ import { CHAIN_STEP_TIMEOUT_MS } from "../config/constants.js";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const AGENTS_DIR = resolve(__dirname, "../agents");
2
10
 
3
11
  export interface SpawnAgentArgs {
4
12
  skill: string;
@@ -17,11 +25,69 @@ export interface SpawnAgentResult {
17
25
  }
18
26
 
19
27
  /**
20
- * Stub: no sub-agent spawn API found in @mariozechner/pi-agent-core at this version.
21
- * Returns spawned status with null result replace body with actual API when available.
28
+ * Spawns an isolated `pi` subprocess for the given skill.
29
+ * The sub-agent gets its own context window. Reads system prompt from agents/{skill}.md.
30
+ * Uses `reason` as the task prompt. Times out after CHAIN_STEP_TIMEOUT_MS.
22
31
  */
23
- async function doSpawn(skill: string, trigger: string): Promise<SpawnAgentResult> {
24
- return { status: "spawned", skill, trigger, result: null };
32
+ async function doSpawn(skill: string, trigger: string, reason: string): Promise<SpawnAgentResult> {
33
+ const agentFile = join(AGENTS_DIR, `${skill}.md`);
34
+ let systemPrompt: string;
35
+ try {
36
+ systemPrompt = readFileSync(agentFile, "utf-8");
37
+ } catch {
38
+ return {
39
+ status: "disabled",
40
+ skill,
41
+ trigger,
42
+ error: `No agent definition found for skill "${skill}" (expected ${agentFile})`,
43
+ };
44
+ }
45
+
46
+ return new Promise((resolvePromise) => {
47
+ const args = [
48
+ "--mode", "json",
49
+ "-p",
50
+ "--no-extensions",
51
+ "--append-system-prompt", systemPrompt,
52
+ reason,
53
+ ];
54
+
55
+ const proc = spawn("pi", args, {
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ env: { ...process.env },
58
+ });
59
+
60
+ const stderrChunks: string[] = [];
61
+ proc.stderr?.setEncoding("utf-8");
62
+ proc.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
63
+
64
+ const timer = setTimeout(() => proc.kill("SIGTERM"), CHAIN_STEP_TIMEOUT_MS);
65
+
66
+ proc.on("close", (code) => {
67
+ clearTimeout(timer);
68
+ if (code === 0) {
69
+ resolvePromise({ status: "spawned", skill, trigger });
70
+ } else {
71
+ const stderr = stderrChunks.slice(-20).join("").slice(-2000);
72
+ resolvePromise({
73
+ status: "disabled",
74
+ skill,
75
+ trigger,
76
+ error: `Agent "${skill}" exited with code ${code ?? 1}${stderr ? `: ${stderr}` : ""}`,
77
+ });
78
+ }
79
+ });
80
+
81
+ proc.on("error", (err) => {
82
+ clearTimeout(timer);
83
+ resolvePromise({
84
+ status: "disabled",
85
+ skill,
86
+ trigger,
87
+ error: `Failed to spawn pi process: ${err.message}`,
88
+ });
89
+ });
90
+ });
25
91
  }
26
92
 
27
93
  /**
@@ -56,5 +122,5 @@ export async function spawnAgent(args: SpawnAgentArgs): Promise<SpawnAgentResult
56
122
  return { status: "rejected", skill: args.skill, trigger: args.trigger };
57
123
  }
58
124
 
59
- return doSpawn(args.skill, args.trigger);
125
+ return doSpawn(args.skill, args.trigger, args.reason);
60
126
  }
@@ -52,12 +52,8 @@ export async function benchExecute({
52
52
  }
53
53
  }
54
54
 
55
- /** Stub full implementation deferred (Story 4.2). */
56
- export function scaffoldDoctype(_args: unknown): { error: string } {
57
- return { error: "scaffold_doctype: not yet implemented Story 4.2 (deferred)" };
58
- }
59
-
60
- /** Stub — full implementation Epic 6. */
61
- export function runTests(_args: unknown): { error: string } {
62
- return { error: "run_tests: not yet implemented — Epic 6" };
55
+ /** @NOT_IMPLEMENTED Epic 6 (deferred). Use bench_execute with 'bench run-tests --app {app}' instead. */
56
+ export function runTests(_args: unknown): { error: string; _stub: true } {
57
+ console.warn("[bench-tools] run_tests called but not yet implemented (Epic 6)");
58
+ return { error: "run_tests: not yet implemented — Epic 6", _stub: true };
63
59
  }
@@ -1,9 +1,14 @@
1
1
  /**
2
- * Context-mode sandbox routing for Frappe tool output.
2
+ * Output truncation guard for Frappe tool output.
3
3
  *
4
- * Context-mode MCP tools are accessible to the LLM session but not callable
5
- * directly from extension code. Until an HTTP endpoint is discoverable,
6
- * routeThroughContextMode applies the 8K truncation fallback.
4
+ * Context-mode MCP tools (ctx_execute, ctx_search, etc.) are only callable by
5
+ * the LLM agent — NOT from Node.js extension code. There is no HTTP endpoint
6
+ * to route to from here.
7
+ *
8
+ * This module is a last-resort truncation guard for bench_execute and
9
+ * frappe_query outputs that would otherwise flood the context window.
10
+ * The agent is instructed via AGENTS.md to use ctx_execute / ctx_batch_execute
11
+ * proactively instead of relying on this fallback.
7
12
  *
8
13
  * NFR17: truncation is NEVER silent — warning is always prepended.
9
14
  */
@@ -11,12 +16,11 @@
11
16
  const CHAR_LIMIT = 8192 * 4; // ~8K tokens at 4 chars/token
12
17
 
13
18
  /**
14
- * Routes raw tool output through the context-mode sandbox.
15
- * Falls back to 8K truncation with a visible warning if sandbox unavailable.
19
+ * Applies the 8K truncation guard to raw tool output.
20
+ * Named routeThroughContextMode for API compatibility with existing callers.
16
21
  * Never throws.
17
22
  */
18
23
  export async function routeThroughContextMode(raw: string): Promise<string> {
19
- // TODO: wire to context-mode MCP HTTP endpoint when discoverable from extension code
20
24
  return applyTruncationFallback(raw);
21
25
  }
22
26