frappe-builder 1.1.0-dev.26 → 1.1.0-dev.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.fb/state.db CHANGED
Binary file
@@ -2,13 +2,13 @@ feature_id: po-approval
2
2
  feature_name: "PO Approval"
3
3
  mode: full
4
4
  phase: testing
5
- updated_at: 2026-03-28T14:38:26.634Z
5
+ updated_at: 2026-03-28T16:59:13.498Z
6
6
 
7
7
  components:
8
8
  - id: final-comp
9
9
  sort_order: 0
10
10
  status: complete
11
- completed_at: 2026-03-28T14:38:26.633Z
11
+ completed_at: 2026-03-28T16:59:13.498Z
12
12
 
13
13
  progress:
14
14
  done: 1
package/README.md CHANGED
@@ -18,17 +18,14 @@ npm install -g frappe-builder
18
18
 
19
19
  ## Initial Setup
20
20
 
21
- Run once per project after installation:
21
+ Run once per machine after installation:
22
22
 
23
23
  ```bash
24
24
  cd /path/to/your-frappe-project
25
25
  frappe-builder init
26
26
  ```
27
27
 
28
- This will:
29
- - Prompt for your LLM API key → saves to `~/.frappe-builder/config.json`
30
- - Prompt for your Frappe site URL and API credentials → saves to `.frappe-builder-config.json`
31
- - Automatically add `.frappe-builder-config.json` to `.gitignore`
28
+ This installs and configures the agent toolchain (context-mode, mcp2cli, context7) and patches `.gitignore` with `.fb/`. No credential prompts — site credentials are provided at runtime via `set_active_project`.
32
29
 
33
30
  After setup, start a session with:
34
31
 
@@ -36,6 +33,18 @@ After setup, start a session with:
36
33
  frappe-builder
37
34
  ```
38
35
 
36
+ See [docs/user/getting-started.md](docs/user/getting-started.md) for the full walkthrough.
37
+
38
+ ## Documentation
39
+
40
+ - **[docs/index.md](docs/index.md)** — full documentation index (user guides + contributor guides)
41
+ - **[docs/user/getting-started.md](docs/user/getting-started.md)** — install, init, first feature
42
+ - **[docs/contributor/architecture.md](docs/contributor/architecture.md)** — how frappe-builder works internally
43
+
44
+ ## Contributing
45
+
46
+ See [docs/contributor/architecture.md](docs/contributor/architecture.md) for the design overview, then [docs/contributor/adding-a-gate.md](docs/contributor/adding-a-gate.md) or [docs/contributor/adding-a-tool.md](docs/contributor/adding-a-tool.md) to contribute a gate or tool.
47
+
39
48
  ## Development
40
49
 
41
50
  See [docs/dev-mode.md](docs/dev-mode.md) for the hot-reload dev workflow.
@@ -46,22 +55,6 @@ npm test # run all tests
46
55
  npm run typecheck # TypeScript compile check
47
56
  ```
48
57
 
49
- ## Configuration
50
-
51
- frappe-builder requires two config files before starting:
52
-
53
- | File | Location | Contents |
54
- |---|---|---|
55
- | Global config | `~/.frappe-builder/config.json` | LLM API key, provider |
56
- | Site credentials | `{project}/.frappe-builder-config.json` | Frappe site URL, API key/secret |
57
-
58
- The site credentials file **must** be listed in your project's `.gitignore` before the session will start:
59
-
60
- ```
61
- # .gitignore
62
- .frappe-builder-config.json
63
- ```
64
-
65
58
  ## Upgrading
66
59
 
67
60
  To update to the latest stable release:
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared constants — single source of truth for values referenced across multiple modules.
3
+ * Centralised here to eliminate silent divergence bugs.
4
+ */
5
+
6
+ /** Tools that bypass the phase guard entirely and are valid in every phase (FR34). */
7
+ export const ALWAYS_ALLOWED_TOOLS = new Set([
8
+ "invoke_debugger",
9
+ "end_debug",
10
+ "spawn_agent",
11
+ "get_frappe_docs",
12
+ "get_library_docs",
13
+ "get_audit_log",
14
+ "get_project_status",
15
+ ]);
16
+
17
+ /** Tools that mutate files, run shell commands, or write state.
18
+ * Subject to default-mode confirmation and plan-mode blocking. */
19
+ export const WRITE_TOOLS = new Set([
20
+ "Write", "Edit", "NotebookEdit",
21
+ "Bash",
22
+ "bench_execute",
23
+ "create_component", "complete_component",
24
+ ]);
25
+
26
+ /** File-write tools that trigger quality gate scans. Strict subset of WRITE_TOOLS. */
27
+ export const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "NotebookEdit"]);
28
+
29
+ /** frappe-builder state directory (relative to project root). */
30
+ export const FB_DIR = ".fb";
31
+
32
+ /** JSONL chain event log filename inside FB_DIR. */
33
+ export const CHAIN_EVENTS_FILE = "chain_events.jsonl";
34
+
35
+ /** Planning artifacts subdirectory name (inside feature artifact dir). */
36
+ export const PLANNING_ARTIFACTS_DIR = "planning-artifacts";
37
+
38
+ /** Implementation artifacts subdirectory name (inside feature artifact dir). */
39
+ export const IMPL_ARTIFACTS_DIR = "implementation-artifacts";
40
+
41
+ /** Per-step timeout for agent chain subprocesses — 10 minutes. */
42
+ export const CHAIN_STEP_TIMEOUT_MS = 10 * 60 * 1000;
43
+
44
+ /** Minimum bytes an artifact file must contain to be considered substantive. */
45
+ export const ARTIFACT_MIN_BYTES = 100;
@@ -14,6 +14,7 @@ export interface AppConfig {
14
14
  defaultMode?: "full" | "quick"; // default feature mode: "quick" skips planning phases
15
15
  chainModel?: string; // model for chain subprocess agents (inherits parent model when unset)
16
16
  permissionMode?: PermissionMode; // agent autonomy level: auto | default | plan
17
+ gateStrictMode?: boolean; // when true, unimplemented gates block writes instead of warning
17
18
  }
18
19
 
19
20
  export const defaults: AppConfig = {
package/dist/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { homedir } from "node:os";
15
15
  */
16
16
  const cmd = process.argv[2];
17
17
  if (cmd === "init") {
18
- const { runInit } = await import("./init-CkLSZ_3g.mjs");
18
+ const { runInit } = await import("./init-DvtJrAiJ.mjs");
19
19
  await runInit();
20
20
  process.exit(0);
21
21
  }
@@ -41,8 +41,7 @@ async function runInit(opts = {}) {
41
41
  const written = [];
42
42
  if (gitignoreResult === "patched") written.push(".gitignore (patched with .fb/)");
43
43
  else if (gitignoreResult === "created") written.push(".gitignore (created with .fb/)");
44
- await setupContextMode(homeDir);
45
- setupMcp2cli(homeDir);
44
+ setupMcp2cli(homeDir, await setupContextMode(homeDir));
46
45
  setupContext7();
47
46
  if (written.length > 0) {
48
47
  console.log("\nFiles written:");
@@ -51,17 +50,32 @@ async function runInit(opts = {}) {
51
50
  console.log("\nReady. Run: frappe-builder\n");
52
51
  }
53
52
  /**
53
+ * Resolves the actual path to context-mode's start.mjs after build.
54
+ * Checks candidates in order: repo root, dist/, dist/index, node_modules self-ref.
55
+ * Returns the first path that exists, or the first candidate as a fallback for mcp.json.
56
+ */
57
+ function resolveContextModeStartScript(extDir) {
58
+ const candidates = [
59
+ join(extDir, "start.mjs"),
60
+ join(extDir, "dist", "start.mjs"),
61
+ join(extDir, "dist", "index.mjs"),
62
+ join(extDir, "node_modules", "context-mode", "start.mjs")
63
+ ];
64
+ return candidates.find((p) => existsSync(p)) ?? candidates[0];
65
+ }
66
+ /**
54
67
  * Installs and configures the context-mode pi MCP extension.
55
68
  * Clones https://github.com/mksglu/context-mode into ~/.pi/extensions/context-mode,
56
69
  * builds it, and patches ~/.pi/settings/mcp.json with the server entry.
57
70
  *
71
+ * Returns the resolved start.mjs path (for use by setupMcp2cli).
58
72
  * Non-fatal — failures are logged as warnings, never abort init.
59
73
  */
60
74
  async function setupContextMode(homeDir) {
61
75
  const extDir = join(homeDir, ".pi", "extensions", "context-mode");
62
76
  const mcpSettingsDir = join(homeDir, ".pi", "settings");
63
77
  const mcpSettingsPath = join(mcpSettingsDir, "mcp.json");
64
- const startScript = join(extDir, "node_modules", "context-mode", "start.mjs");
78
+ const startScript = resolveContextModeStartScript(extDir);
65
79
  console.log("\n[context-mode MCP extension]");
66
80
  if (existsSync(extDir)) console.log(" ✓ context-mode already installed at ~/.pi/extensions/context-mode");
67
81
  else {
@@ -75,7 +89,7 @@ async function setupContextMode(homeDir) {
75
89
  if (clone.status !== 0) {
76
90
  console.warn(` ⚠ git clone failed: ${clone.stderr?.toString().trim()}`);
77
91
  console.warn(" Skipping context-mode setup. Install manually: https://github.com/mksglu/context-mode");
78
- return;
92
+ return startScript;
79
93
  }
80
94
  const install = spawnSync("npm", ["install"], {
81
95
  cwd: extDir,
@@ -83,7 +97,7 @@ async function setupContextMode(homeDir) {
83
97
  });
84
98
  if (install.status !== 0) {
85
99
  console.warn(` ⚠ npm install failed: ${install.stderr?.toString().trim()}`);
86
- return;
100
+ return startScript;
87
101
  }
88
102
  const build = spawnSync("npm", ["run", "build"], {
89
103
  cwd: extDir,
@@ -91,7 +105,7 @@ async function setupContextMode(homeDir) {
91
105
  });
92
106
  if (build.status !== 0) {
93
107
  console.warn(` ⚠ npm run build failed: ${build.stderr?.toString().trim()}`);
94
- return;
108
+ return startScript;
95
109
  }
96
110
  console.log(" ✓ context-mode installed and built");
97
111
  }
@@ -103,7 +117,7 @@ async function setupContextMode(homeDir) {
103
117
  const servers = mcpConfig.mcpServers ?? {};
104
118
  if (servers["context-mode"]) {
105
119
  console.log(" ✓ context-mode already in ~/.pi/settings/mcp.json");
106
- return;
120
+ return startScript;
107
121
  }
108
122
  servers["context-mode"] = {
109
123
  command: "node",
@@ -113,15 +127,17 @@ async function setupContextMode(homeDir) {
113
127
  writeAtomic(mcpSettingsPath, JSON.stringify(mcpConfig, null, 2) + "\n");
114
128
  console.log(" ✓ Added context-mode to ~/.pi/settings/mcp.json");
115
129
  console.log(" Restart pi (or frappe-builder) for context-mode to activate.");
130
+ return startScript;
116
131
  }
117
132
  /**
118
133
  * Installs the mcp2cli Claude Code skill and bakes the context-mode connection
119
134
  * so the agent can call `mcp2cli @context-mode <tool>` without repeating flags.
120
135
  *
136
+ * startScript is passed in from setupContextMode so both functions use the same resolved path.
121
137
  * Non-fatal — failures are logged as warnings, never abort init.
122
138
  */
123
- function setupMcp2cli(homeDir) {
124
- const startScript = join(homeDir, ".pi", "extensions", "context-mode", "node_modules", "context-mode", "start.mjs");
139
+ function setupMcp2cli(homeDir, startScript) {
140
+ if (!startScript) startScript = resolveContextModeStartScript(join(homeDir, ".pi", "extensions", "context-mode"));
125
141
  console.log("\n[mcp2cli skill + context-mode bake]");
126
142
  if (spawnSync("mcp2cli", ["--version"], { stdio: "pipe" }).status === 0) console.log(" ✓ mcp2cli already installed");
127
143
  else {
@@ -4,12 +4,18 @@ import { join, dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { appendFileSync } from "node:fs";
6
6
  import { db } from "../state/db.js";
7
+ import {
8
+ FB_DIR,
9
+ CHAIN_EVENTS_FILE,
10
+ PLANNING_ARTIFACTS_DIR,
11
+ IMPL_ARTIFACTS_DIR,
12
+ CHAIN_STEP_TIMEOUT_MS,
13
+ ARTIFACT_MIN_BYTES,
14
+ } from "../config/constants.js";
7
15
 
8
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
17
  const AGENTS_DIR = resolve(__dirname, "../agents");
10
- const CHAIN_EVENTS_PATH = join(".fb", "chain_events.jsonl");
11
- const STEP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per step
12
- const ARTIFACT_MIN_BYTES = 100;
18
+ const CHAIN_EVENTS_PATH = join(FB_DIR, CHAIN_EVENTS_FILE);
13
19
  const PREV_ARTIFACT_MAX_CHARS = 4096;
14
20
 
15
21
  interface ChainStep {
@@ -75,7 +81,7 @@ const PHASE_TASKS: Record<string, string> = {
75
81
 
76
82
  function appendChainEvent(entry: Record<string, unknown>): void {
77
83
  try {
78
- mkdirSync(".fb", { recursive: true });
84
+ mkdirSync(FB_DIR, { recursive: true });
79
85
  appendFileSync(CHAIN_EVENTS_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n", "utf-8");
80
86
  } catch { /* non-fatal */ }
81
87
  }
@@ -154,7 +160,7 @@ function runSubprocess(
154
160
  proc.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
155
161
 
156
162
  // Hard timeout per step
157
- const timer = setTimeout(() => proc.kill("SIGTERM"), STEP_TIMEOUT_MS);
163
+ const timer = setTimeout(() => proc.kill("SIGTERM"), CHAIN_STEP_TIMEOUT_MS);
158
164
 
159
165
  proc.on("close", (code) => {
160
166
  clearTimeout(timer);
@@ -195,8 +201,8 @@ export async function spawnChain(
195
201
  appendChainEvent({ featureId, phase: step.phase, status: "started" });
196
202
 
197
203
  // Ensure artifact subdirs exist
198
- const planningDir = join(artifactDir, "planning-artifacts");
199
- const implDir = join(artifactDir, "implementation-artifacts");
204
+ const planningDir = join(artifactDir, PLANNING_ARTIFACTS_DIR);
205
+ const implDir = join(artifactDir, IMPL_ARTIFACTS_DIR);
200
206
  mkdirSync(planningDir, { recursive: true });
201
207
  mkdirSync(implDir, { recursive: true });
202
208
 
@@ -14,6 +14,9 @@ import { getCurrentPhase, db } from "../state/db.js";
14
14
  import { appendEntry } from "../state/journal.js";
15
15
  import { checkFrappeNative } from "../gates/frappe-native-check.js";
16
16
  import type { GateFn, GateContext, GateResult } from "../gates/types.js";
17
+ import { FILE_WRITE_TOOLS as FILE_WRITE_TOOLS_CONST } from "../config/constants.js";
18
+ import { loadConfig } from "../config/loader.js";
19
+ import type { PiPlugin } from "./pi-types.js";
17
20
 
18
21
  // Adapt frappe-native-check's custom result type to the standard GateResult
19
22
  function nativeAdapter(code: string, _context: GateContext): GateResult {
@@ -30,12 +33,30 @@ function nativeAdapter(code: string, _context: GateContext): GateResult {
30
33
  };
31
34
  }
32
35
 
33
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- type AnyModule = Record<string, any>;
36
+ type AnyModule = Record<string, unknown>;
35
37
 
36
- function tryLoadGate(mod: AnyModule, exportName: string, gateName: string): { name: string; fn: GateFn } | null {
38
+ /**
39
+ * Loads a gate function from a module.
40
+ * In strict mode: returns a blocking gate if the export is missing (write is denied).
41
+ * In default mode: logs a warning and returns null (gate is skipped).
42
+ */
43
+ function tryLoadGate(
44
+ mod: AnyModule,
45
+ exportName: string,
46
+ gateName: string,
47
+ strictMode: boolean,
48
+ ): { name: string; fn: GateFn } | null {
37
49
  const fn = mod[exportName];
38
50
  if (typeof fn === "function") return { name: gateName, fn: fn as GateFn };
51
+ if (strictMode) {
52
+ return {
53
+ name: gateName,
54
+ fn: (_code: string, _ctx: GateContext): GateResult => ({
55
+ passed: false,
56
+ violations: [{ reason: `${gateName} gate is not implemented — blocked in strict mode` }],
57
+ }),
58
+ };
59
+ }
39
60
  console.warn(`[GATE WARNING: ${gateName} gate not yet implemented — skipping]`);
40
61
  return null;
41
62
  }
@@ -51,6 +72,9 @@ const GATE_REGISTRY: Array<{ name: string; fn: GateFn }> = [
51
72
  { name: "frappe_native", fn: nativeAdapter },
52
73
  ];
53
74
 
75
+ let gateStrictMode = false;
76
+ try { gateStrictMode = loadConfig().gateStrictMode ?? false; } catch { /* non-fatal — default false */ }
77
+
54
78
  for (const [mod, exportName, gateName] of [
55
79
  [permCheckMod, "permissionCheck", "permission_check"],
56
80
  [queryCheckMod, "queryCheck", "query_check"],
@@ -58,11 +82,12 @@ for (const [mod, exportName, gateName] of [
58
82
  [coverageCheckMod, "coverageCheck", "coverage_check"],
59
83
  [styleCheckMod, "styleCheck", "style_check"],
60
84
  ] as [AnyModule, string, string][]) {
61
- const entry = tryLoadGate(mod, exportName, gateName);
85
+ const entry = tryLoadGate(mod, exportName, gateName, gateStrictMode);
62
86
  if (entry) GATE_REGISTRY.push(entry);
63
87
  }
64
88
 
65
- export const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "NotebookEdit"]);
89
+ // FILE_WRITE_TOOLS imported from config/constants.ts re-exported for consumers
90
+ export const FILE_WRITE_TOOLS = FILE_WRITE_TOOLS_CONST;
66
91
 
67
92
  export interface BlockedGateResponse {
68
93
  blocked: true;
@@ -152,8 +177,7 @@ export function runGates(
152
177
  return undefined;
153
178
  }
154
179
 
155
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
- export default function (pi: any) {
180
+ export default function (pi: PiPlugin) {
157
181
  pi.on("tool_call", (event: { toolName?: string; input?: Record<string, unknown> }) => {
158
182
  return runGates(event.toolName ?? "", event.input ?? {});
159
183
  });
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { db } from "../state/db.js";
6
6
  import { createFsmAtPhase, type Phase } from "../state/fsm.js";
7
+ import type { PiPlugin } from "./pi-types.js";
7
8
 
8
9
  /** Maps FSM phases to their specialist names. frappe-debugger is manually invoked (Story 3.5). */
9
10
  export const PHASE_TO_SPECIALIST: Record<string, string | null> = {
@@ -315,8 +316,7 @@ export async function handleSessionStart(): Promise<string> {
315
316
  }
316
317
  }
317
318
 
318
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
319
- export default function (pi: any) {
319
+ export default function (pi: PiPlugin) {
320
320
  pi.on("session_start", async () => {
321
321
  await handleSessionStart();
322
322
  });
@@ -3,14 +3,13 @@ import type { TextContent } from "@mariozechner/pi-ai";
3
3
  import { db, getCurrentPhase } from "../state/db.js";
4
4
  import { appendEntry } from "../state/journal.js";
5
5
  import { buildStateContext, loadSpecialist, loadDebuggerSpecialist } from "./frappe-session.js";
6
+ import type { PiPlugin, PiUiContext } from "./pi-types.js";
6
7
 
7
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- export default function (pi: any) {
8
+ export default function (pi: PiPlugin) {
9
9
  pi.on("after_tool_call", handleAfterToolCall);
10
10
 
11
11
  // Session-end guard: warn the agent about untracked features before shutdown.
12
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
- pi.on("session_shutdown", (_event: any, ctx: any) => {
12
+ pi.on("session_shutdown", (_event?: unknown, ctx?: PiUiContext) => {
14
13
  try {
15
14
  const orphans = db
16
15
  .prepare(
@@ -23,7 +22,7 @@ export default function (pi: any) {
23
22
  const list = orphans.map((f) => ` • ${f.name} [${f.feature_id}] (${f.mode})`).join("\n");
24
23
  const warning = `\n⚠ Session ending with ${orphans.length} feature(s) that have no components tracked:\n${list}\n\nCall create_component() + complete_component() before the next session to preserve work history.`;
25
24
  try {
26
- ctx.ui.setStatus("frappe-builder", `⚠ ${orphans.length} untracked feature(s) — run create_component`);
25
+ ctx?.ui.setStatus?.("frappe-builder", `⚠ ${orphans.length} untracked feature(s) — run create_component`);
27
26
  } catch { /* non-fatal */ }
28
27
  console.error(warning);
29
28
  }
@@ -1,21 +1,20 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import type { PiPlugin } from "./pi-types.js";
2
3
  import { startFeature, completeComponent, createComponent } from "../tools/feature-tools.js";
3
4
  import { setActiveProject, getProjectStatus } from "../tools/project-tools.js";
4
5
  import { getAuditLog } from "../state/journal.js";
5
6
  import { invokeDebugger, endDebug } from "../tools/debug-tools.js";
6
7
  import { spawnAgent } from "../tools/agent-tools.js";
7
- import { scaffoldDoctype, benchExecute, runTests } from "../tools/bench-tools.js";
8
+ import { benchExecute, runTests } from "../tools/bench-tools.js";
8
9
  import { frappeQuery } from "../tools/frappe-query-tools.js";
9
10
  import { getLibraryDocs, getFrappeDocs } from "../tools/frappe-context7.js";
10
11
 
11
- // pi.registerTool's execute callback is untyped (pi is `any`); params are
12
- // enforced at runtime via TypeBox schemas. One explicit-any alias avoids
13
- // noImplicitAny errors across all callbacks without per-line suppressions.
12
+ // pi.registerTool's execute callback params are enforced at runtime via TypeBox schemas.
13
+ // ToolParams = any avoids noImplicitAny across all callbacks without per-line suppressions.
14
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
15
  type ToolParams = any;
16
16
 
17
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
- export default function (pi: any) {
17
+ export default function (pi: PiPlugin) {
19
18
  pi.registerTool({
20
19
  name: "start_feature",
21
20
  label: "Start Feature",
@@ -145,24 +144,6 @@ export default function (pi: any) {
145
144
  },
146
145
  });
147
146
 
148
- pi.registerTool({
149
- name: "scaffold_doctype",
150
- label: "Scaffold DocType",
151
- description:
152
- "NOT YET IMPLEMENTED (Story 4.2 deferred). Returns an error. Do not call — create DocType JSON fixtures directly instead.",
153
- parameters: Type.Object({
154
- name: Type.String({ description: "DocType name in PascalCase (e.g. 'Purchase Order')" }),
155
- module: Type.String({ description: "Frappe module name (e.g. 'Buying')" }),
156
- }),
157
- execute: async (_toolCallId: string, params: ToolParams) => {
158
- const result = scaffoldDoctype(params);
159
- return {
160
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
161
- details: result,
162
- };
163
- },
164
- });
165
-
166
147
  pi.registerTool({
167
148
  name: "run_tests",
168
149
  label: "Run Tests",
@@ -2,6 +2,7 @@ import type { AfterToolCallContext, AfterToolCallResult } from "@mariozechner/pi
2
2
  import { db, getCurrentPhase } from "../state/db.js";
3
3
  import { loadConfig } from "../config/loader.js";
4
4
  import { DEFAULT_PERMISSION_MODE } from "../config/defaults.js";
5
+ import type { PiPlugin, PiUiContext } from "./pi-types.js";
5
6
 
6
7
  interface DashboardState {
7
8
  projectId: string | null;
@@ -110,11 +111,10 @@ export function formatFooter(state: FooterState): string {
110
111
  return `Project: ${state.projectId} | Feature: ${state.featureName} | Component: ${state.componentId}`;
111
112
  }
112
113
 
113
- function renderWidget(ctx: unknown): void {
114
+ function renderWidget(ctx: PiUiContext | undefined): void {
114
115
  try {
115
116
  const lines = formatDashboard(readDashboardState());
116
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
- (ctx as any).ui.setWidget("frappe-builder", lines, { placement: "aboveEditor" });
117
+ ctx?.ui.setWidget?.("frappe-builder", lines, { placement: "aboveEditor" });
118
118
  } catch {
119
119
  // Fallback: write to stderr if widget API unavailable
120
120
  try {
@@ -155,30 +155,25 @@ export async function handleAfterToolCall(
155
155
  return undefined;
156
156
  }
157
157
 
158
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
- export default function (pi: any) {
160
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
- pi.on("session_start", (_event: any, ctx: any) => {
158
+ export default function (pi: PiPlugin) {
159
+ pi.on("session_start", (_event?: unknown, ctx?: PiUiContext) => {
162
160
  renderWidget(ctx);
163
161
  });
164
162
 
165
163
  // Announcement hook — fires before FSM guard in frappe-workflow.ts (FR25)
166
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
167
- pi.on("tool_call", (event: any, ctx: any) => {
164
+ pi.on("tool_call", (event: { toolName?: string; input?: Record<string, unknown> }, ctx?: PiUiContext) => {
168
165
  try {
169
166
  const state = readDashboardState();
170
167
  const lines = formatDashboard(state);
171
168
  const callLine = `→ ${event.toolName ?? "tool"} [${state.phase}]`;
172
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
- (ctx as any).ui.setWidget("frappe-builder", [...lines, callLine], { placement: "aboveEditor" });
169
+ ctx?.ui.setWidget?.("frappe-builder", [...lines, callLine], { placement: "aboveEditor" });
174
170
  } catch {
175
171
  handleBeforeToolCall(event.toolName ?? "");
176
172
  }
177
173
  });
178
174
 
179
175
  // "tool_result" fires after each tool completes — update the persistent widget.
180
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
- pi.on("tool_result", (_event: any, ctx: any) => {
176
+ pi.on("tool_result", (_event?: unknown, ctx?: PiUiContext) => {
182
177
  renderWidget(ctx);
183
178
  });
184
179
  }
@@ -3,20 +3,14 @@ import { isToolAllowedInPhase, getValidPhase, type Phase } from "../state/fsm.js
3
3
  import { appendEntry } from "../state/journal.js";
4
4
  import { loadConfig } from "../config/loader.js";
5
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";
6
8
 
7
9
  // Re-export for use in tests and other modules
8
10
  export type { PermissionMode };
9
11
 
10
- /**
11
- * Tools that mutate files, run shell commands, or write state.
12
- * These are subject to default-mode confirmation and plan-mode blocking.
13
- */
14
- export const WRITE_TOOLS = new Set([
15
- "Write", "Edit", "NotebookEdit",
16
- "Bash",
17
- "scaffold_doctype", "bench_execute",
18
- "create_component", "complete_component",
19
- ]);
12
+ // WRITE_TOOLS imported from config/constants.ts — single source of truth
13
+ export { WRITE_TOOLS };
20
14
 
21
15
  interface BlockedResponse {
22
16
  blocked: true;
@@ -50,15 +44,14 @@ function buildBlockedResponse(
50
44
  *
51
45
  * Never throws — always returns a value or undefined.
52
46
  */
53
- // Tools valid in all phases never blocked by the phase guard (FR34)
54
- const ALWAYS_ALLOWED_TOOLS = ["invoke_debugger", "end_debug", "spawn_agent", "get_frappe_docs", "get_library_docs", "get_audit_log", "get_project_status"];
47
+ // ALWAYS_ALLOWED_TOOLS imported from config/constants.tssingle source of truth
55
48
 
56
49
  export function beforeToolCall(
57
50
  toolName: string,
58
51
  args: Record<string, unknown>
59
52
  ): BlockedResponse | undefined {
60
53
  // Always-allowed bypass — checked before everything else
61
- if (ALWAYS_ALLOWED_TOOLS.includes(toolName)) return undefined;
54
+ if (ALWAYS_ALLOWED_TOOLS.has(toolName)) return undefined;
62
55
 
63
56
  const currentPhase = getCurrentPhase() as Phase;
64
57
 
@@ -102,8 +95,7 @@ export function beforeToolCall(
102
95
  export async function checkPermissionMode(
103
96
  toolName: string,
104
97
  mode: PermissionMode,
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- ctx?: any,
98
+ ctx?: PiUiContext,
107
99
  ): Promise<BlockedResponse | undefined> {
108
100
  if (!WRITE_TOOLS.has(toolName)) return undefined;
109
101
  if (mode === "auto") return undefined;
@@ -121,7 +113,7 @@ export async function checkPermissionMode(
121
113
  // default mode: prompt via ctx.ui.input()
122
114
  if (ctx) {
123
115
  try {
124
- const answer: string = await ctx.ui.input(`Allow ${toolName}? (yes/no)`);
116
+ const answer = await ctx.ui.input?.(`Allow ${toolName}? (yes/no)`);
125
117
  if (!["yes", "y"].includes(answer?.toLowerCase?.() ?? "")) {
126
118
  return {
127
119
  blocked: true,
@@ -139,10 +131,8 @@ export async function checkPermissionMode(
139
131
  return undefined;
140
132
  }
141
133
 
142
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
143
- export default function (pi: any) {
144
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
- pi.on("tool_call", async (event: { toolName?: string; input?: Record<string, unknown> }, ctx: any) => {
134
+ export default function (pi: PiPlugin) {
135
+ pi.on("tool_call", async (event: { toolName?: string; input?: Record<string, unknown> }, ctx?: PiUiContext) => {
146
136
  const toolName = event.toolName ?? "";
147
137
 
148
138
  // Phase guard
@@ -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,13 +1,12 @@
1
1
  {
2
2
  "name": "frappe-builder",
3
- "version": "1.1.0-dev.26",
3
+ "version": "1.1.0-dev.28",
4
4
  "description": "Frappe-native AI co-pilot for building and customising Frappe/ERPNext applications",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "frappe-builder": "./dist/cli.mjs"
8
8
  },
9
9
  "pi": {
10
- "_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.",
11
10
  "extensions": [
12
11
  "./extensions/frappe-session.ts",
13
12
  "./extensions/frappe-state.ts",
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"
@@ -41,10 +42,14 @@ function buildMachineStates() {
41
42
  type FsmPhase = "idle" | "implementation" | "testing" | "documentation";
42
43
  const FSM_PHASES = new Set<string>(["idle", "implementation", "testing", "documentation"]);
43
44
 
45
+ function isFsmPhase(p: string): p is FsmPhase {
46
+ return FSM_PHASES.has(p);
47
+ }
48
+
44
49
  /** Creates a Robot3 FSM starting at the given phase — fast-forward, no event replay.
45
50
  * Non-FSM phases (chain_running, requirements, architecture, planning) fall back to idle. */
46
51
  export function createFsmAtPhase(phase: Phase) {
47
- const fsmPhase: FsmPhase = FSM_PHASES.has(phase) ? (phase as FsmPhase) : "idle";
52
+ const fsmPhase: FsmPhase = isFsmPhase(phase) ? phase : "idle";
48
53
  return createMachine(fsmPhase, buildMachineStates());
49
54
  }
50
55
 
@@ -57,21 +62,13 @@ const TOOL_PHASE_MAP: Record<string, Phase | Phase[] | "any"> = {
57
62
  start_feature: "idle", // blocked in chain_running to prevent double-start
58
63
  create_component: ["planning", "implementation"],
59
64
  complete_component: "implementation",
60
- scaffold_doctype: "implementation",
61
65
  run_tests: "testing",
62
66
  get_project_status: "any",
63
67
  frappe_query: "any",
64
68
  bench_execute: "any",
65
69
  };
66
70
 
67
- /** Always-allowed tools that bypass phase guard entirely. */
68
- const ALWAYS_ALLOWED_TOOLS = new Set([
69
- "invoke_debugger",
70
- "end_debug",
71
- "spawn_agent",
72
- "get_project_status",
73
- "get_frappe_docs",
74
- ]);
71
+ // ALWAYS_ALLOWED_TOOLS imported from config/constants.ts single source of truth
75
72
 
76
73
  /** Returns true if toolName is allowed to run in the given phase. Unknown tools are always allowed. */
77
74
  export function isToolAllowedInPhase(toolName: string, phase: Phase): boolean {
package/state/schema.ts CHANGED
@@ -70,6 +70,11 @@ export function migrateSchema(db: Database): void {
70
70
  "ALTER TABLE sessions ADD COLUMN api_secret TEXT",
71
71
  ];
72
72
  for (const sql of alters) {
73
- try { db.exec(sql); } catch { /* column already exists — safe to ignore */ }
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
+ }
74
79
  }
75
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
  }
@@ -17,6 +17,41 @@ interface SessionCredentials {
17
17
  api_secret: string | null;
18
18
  }
19
19
 
20
+ /**
21
+ * Retries a fetch on transient server errors (5xx) and rate limiting (429).
22
+ * Uses linear backoff (1s × attempt). Passes through 4xx client errors immediately.
23
+ */
24
+ async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
25
+ let lastResponse: Response | undefined;
26
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
27
+ const response = await fetch(url, options);
28
+ if (response.ok || (response.status >= 400 && response.status < 500 && response.status !== 429)) {
29
+ return response;
30
+ }
31
+ lastResponse = response;
32
+ if (attempt < maxRetries) {
33
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
34
+ }
35
+ }
36
+ return lastResponse!;
37
+ }
38
+
39
+ /**
40
+ * Builds and validates the Frappe REST resource URL.
41
+ * Strips trailing slash from siteUrl, uses URL constructor for validation.
42
+ * Returns null if siteUrl is not a valid URL.
43
+ */
44
+ function buildResourceUrl(siteUrl: string, doctype: string, params: URLSearchParams): string | null {
45
+ try {
46
+ const base = siteUrl.replace(/\/$/, "");
47
+ const url = new URL(`${base}/api/resource/${encodeURIComponent(doctype)}`);
48
+ url.search = params.toString();
49
+ return url.toString();
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
20
55
  /**
21
56
  * Queries Frappe data via direct REST API call using credentials stored in the
22
57
  * active session (set via set_active_project).
@@ -41,8 +76,12 @@ export async function frappeQuery({ doctype, filters }: FrappeQueryArgs): Promis
41
76
  params.set("filters", JSON.stringify(filters));
42
77
  }
43
78
 
44
- const url = `${session.site_url}/api/resource/${encodeURIComponent(doctype)}?${params.toString()}`;
45
- const response = await fetch(url, {
79
+ const url = buildResourceUrl(session.site_url, doctype, params);
80
+ if (!url) {
81
+ return { error: `Invalid site_url: "${session.site_url}". Must be a valid URL (e.g. http://site1.localhost).` };
82
+ }
83
+
84
+ const response = await fetchWithRetry(url, {
46
85
  headers: {
47
86
  Authorization: `token ${session.api_key}:${session.api_secret}`,
48
87
  "Content-Type": "application/json",