frappe-builder 1.1.0-dev.13 → 1.1.0-dev.17

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.
@@ -0,0 +1,248 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { appendFileSync } from "node:fs";
6
+ import { db } from "../state/db.js";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ 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;
13
+ const PREV_ARTIFACT_MAX_CHARS = 4096;
14
+
15
+ interface ChainStep {
16
+ phase: string;
17
+ specialist: string;
18
+ artifactFile: string; // relative to artifactDir
19
+ tools: string[];
20
+ }
21
+
22
+ export const CHAIN_STEPS: ChainStep[] = [
23
+ {
24
+ phase: "requirements",
25
+ specialist: "frappe-ba",
26
+ artifactFile: "planning-artifacts/requirements.md",
27
+ tools: ["Read", "Write", "get_frappe_docs"],
28
+ },
29
+ {
30
+ phase: "architecture",
31
+ specialist: "frappe-architect",
32
+ artifactFile: "planning-artifacts/architecture.md",
33
+ tools: ["Read", "Write", "get_frappe_docs"],
34
+ },
35
+ {
36
+ phase: "planning",
37
+ specialist: "frappe-planner",
38
+ artifactFile: "planning-artifacts/plan.md",
39
+ tools: ["Read", "Write", "create_component"],
40
+ },
41
+ {
42
+ phase: "implementation",
43
+ specialist: "frappe-dev",
44
+ artifactFile: "implementation-artifacts/sprint-status.yaml",
45
+ tools: ["Read", "Write", "Edit", "Bash", "create_component", "complete_component", "bench_execute"],
46
+ },
47
+ {
48
+ phase: "testing",
49
+ specialist: "frappe-qa",
50
+ artifactFile: "implementation-artifacts/test-report.md",
51
+ tools: ["Read", "Write", "Bash", "bench_execute"],
52
+ },
53
+ {
54
+ phase: "documentation",
55
+ specialist: "frappe-docs",
56
+ artifactFile: "implementation-artifacts/docs.md",
57
+ tools: ["Read", "Write"],
58
+ },
59
+ ];
60
+
61
+ const PHASE_TASKS: Record<string, string> = {
62
+ requirements:
63
+ "Analyse the feature description and produce requirements.md covering DocTypes, roles, process flows, and acceptance criteria.",
64
+ architecture:
65
+ "Design the Frappe-native technical solution based on the requirements above. Produce architecture.md covering DocType schemas, relationships, server logic, and permissions.",
66
+ planning:
67
+ "Break the architecture into ordered implementation components. Produce plan.md, then call create_component for every component listed.",
68
+ implementation:
69
+ "Implement every component from the plan in order. Call complete_component after each one passes tests.",
70
+ testing:
71
+ "Run the full test suite, verify all acceptance criteria, check the permission matrix, and produce test-report.md with Result: PASS.",
72
+ documentation:
73
+ "Document the implemented feature — DocType fields, hooks, docstrings, and changelog entry — and produce docs.md.",
74
+ };
75
+
76
+ function appendChainEvent(entry: Record<string, unknown>): void {
77
+ try {
78
+ mkdirSync(".fb", { recursive: true });
79
+ appendFileSync(CHAIN_EVENTS_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n", "utf-8");
80
+ } catch { /* non-fatal */ }
81
+ }
82
+
83
+ function readArtifact(artifactDir: string, artifactFile: string): string | null {
84
+ try {
85
+ const content = readFileSync(join(artifactDir, artifactFile), "utf-8");
86
+ return content.slice(0, PREV_ARTIFACT_MAX_CHARS);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function verifyArtifact(artifactDir: string, artifactFile: string): boolean {
93
+ try {
94
+ const content = readFileSync(join(artifactDir, artifactFile), "utf-8");
95
+ return content.length >= ARTIFACT_MIN_BYTES;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ function buildTaskPrompt(
102
+ step: ChainStep,
103
+ featureId: string,
104
+ featureName: string,
105
+ artifactDir: string,
106
+ prevArtifact: string | null,
107
+ ): string {
108
+ const prevSection = prevArtifact
109
+ ? `## Previous Phase Output\n\`\`\`\n${prevArtifact}\n\`\`\`\n\n`
110
+ : "";
111
+
112
+ return [
113
+ `Feature: "${featureName}" (ID: ${featureId})`,
114
+ `Artifact directory: ${artifactDir}`,
115
+ "",
116
+ prevSection,
117
+ `## Your Task`,
118
+ PHASE_TASKS[step.phase],
119
+ "",
120
+ `Write your output to: ${join(artifactDir, step.artifactFile)}`,
121
+ `Do not stop until the artifact file exists with substantive content.`,
122
+ ].join("\n");
123
+ }
124
+
125
+ function runSubprocess(
126
+ step: ChainStep,
127
+ taskPrompt: string,
128
+ ): Promise<{ exitCode: number; stderr: string }> {
129
+ return new Promise((resolvePromise) => {
130
+ const agentFile = join(AGENTS_DIR, `${step.specialist}.md`);
131
+ let systemPrompt = "";
132
+ try {
133
+ systemPrompt = readFileSync(agentFile, "utf-8");
134
+ } catch {
135
+ systemPrompt = `You are ${step.specialist}, a Frappe specialist. ${PHASE_TASKS[step.phase]}`;
136
+ }
137
+
138
+ const args = [
139
+ "--mode", "json",
140
+ "-p",
141
+ "--no-extensions",
142
+ "--tools", step.tools.join(","),
143
+ "--append-system-prompt", systemPrompt,
144
+ taskPrompt,
145
+ ];
146
+
147
+ const proc = spawn("pi", args, {
148
+ stdio: ["ignore", "pipe", "pipe"],
149
+ env: { ...process.env },
150
+ });
151
+
152
+ const stderrChunks: string[] = [];
153
+ proc.stderr?.setEncoding("utf-8");
154
+ proc.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
155
+
156
+ // Hard timeout per step
157
+ const timer = setTimeout(() => proc.kill("SIGTERM"), STEP_TIMEOUT_MS);
158
+
159
+ proc.on("close", (code) => {
160
+ clearTimeout(timer);
161
+ resolvePromise({
162
+ exitCode: code ?? 1,
163
+ stderr: stderrChunks.slice(-20).join("").slice(-2000),
164
+ });
165
+ });
166
+ });
167
+ }
168
+
169
+ function updateDb(featureId: string, phase: string, chainStep: string | null): void {
170
+ try {
171
+ db.transaction(() => {
172
+ db.prepare("UPDATE sessions SET chain_step = ? WHERE is_active = 1").run(chainStep);
173
+ db.prepare("UPDATE features SET current_phase = ? WHERE feature_id = ?").run(phase, featureId);
174
+ })();
175
+ } catch { /* non-fatal — chain continues */ }
176
+ }
177
+
178
+ /**
179
+ * Runs the full agent chain for a feature. Fire-and-forget: called via setImmediate from startFeature.
180
+ * Each phase spawns an isolated pi subprocess with a specialist system prompt.
181
+ * Artifacts are passed between phases via files in artifactDir.
182
+ */
183
+ export async function spawnChain(
184
+ featureId: string,
185
+ featureName: string,
186
+ artifactDir: string,
187
+ ): Promise<void> {
188
+ appendChainEvent({ featureId, featureName, status: "chain_started" });
189
+
190
+ let prevArtifact: string | null = null;
191
+
192
+ for (const step of CHAIN_STEPS) {
193
+ // Update state: chain is now on this phase
194
+ updateDb(featureId, step.phase, step.phase);
195
+ appendChainEvent({ featureId, phase: step.phase, status: "started" });
196
+
197
+ // Ensure artifact subdirs exist
198
+ const planningDir = join(artifactDir, "planning-artifacts");
199
+ const implDir = join(artifactDir, "implementation-artifacts");
200
+ mkdirSync(planningDir, { recursive: true });
201
+ mkdirSync(implDir, { recursive: true });
202
+
203
+ const taskPrompt = buildTaskPrompt(step, featureId, featureName, artifactDir, prevArtifact);
204
+
205
+ // Run subprocess — retry once if artifact missing despite exit 0
206
+ let result = await runSubprocess(step, taskPrompt);
207
+ const artifactOk = verifyArtifact(artifactDir, step.artifactFile);
208
+
209
+ if (result.exitCode === 0 && !artifactOk) {
210
+ // Retry with explicit nudge
211
+ const retryPrompt = taskPrompt +
212
+ `\n\nWARNING: Your artifact was not detected at ${join(artifactDir, step.artifactFile)}. ` +
213
+ `You MUST write this file before exiting.`;
214
+ result = await runSubprocess(step, retryPrompt);
215
+ }
216
+
217
+ if (result.exitCode !== 0 || !verifyArtifact(artifactDir, step.artifactFile)) {
218
+ // Chain failed — write error file and update state
219
+ const errorContent = [
220
+ `# Chain Error`,
221
+ `Phase: ${step.phase}`,
222
+ `Exit code: ${result.exitCode}`,
223
+ `Artifact expected: ${join(artifactDir, step.artifactFile)}`,
224
+ `Artifact found: ${verifyArtifact(artifactDir, step.artifactFile)}`,
225
+ ``,
226
+ `## Stderr (last 2000 chars)`,
227
+ result.stderr,
228
+ ].join("\n");
229
+
230
+ try {
231
+ writeFileSync(join(artifactDir, "chain_error.md"), errorContent, "utf-8");
232
+ } catch { /* non-fatal */ }
233
+
234
+ updateDb(featureId, "chain_failed", null);
235
+ appendChainEvent({ featureId, phase: step.phase, status: "failed", exitCode: result.exitCode });
236
+ return;
237
+ }
238
+
239
+ appendChainEvent({ featureId, phase: step.phase, status: "complete" });
240
+
241
+ // Pass this phase's artifact to the next phase
242
+ prevArtifact = readArtifact(artifactDir, step.artifactFile);
243
+ }
244
+
245
+ // All steps complete
246
+ updateDb(featureId, "done", null);
247
+ appendChainEvent({ featureId, status: "chain_complete" });
248
+ }
@@ -290,6 +290,11 @@ export async function handleSessionStart(): Promise<string> {
290
290
 
291
291
  // Specialist auto-load on session resume (Story 3.4)
292
292
  // AGENTS.md base identity (Nexus) is loaded natively by pi from project root.
293
+ if (ctx.phase === "chain_running") {
294
+ // Agent chain is running as a background subprocess — no specialist injection.
295
+ // Use get_project_status to monitor chain progress.
296
+ return `${stateBlock}\n\nAgent chain is running. Use get_project_status to monitor progress.`;
297
+ }
293
298
  if (ctx.phase !== "idle") {
294
299
  try {
295
300
  const specCtx = buildStateContext(null, "session_start");
@@ -8,6 +8,28 @@ import { loadCredentials } from "../config/loader.js";
8
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
9
  export default function (pi: any) {
10
10
  pi.on("after_tool_call", handleAfterToolCall);
11
+
12
+ // Session-end guard: warn the agent about untracked features before shutdown.
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ pi.on("session_shutdown", (_event: any, ctx: any) => {
15
+ try {
16
+ const orphans = db
17
+ .prepare(
18
+ `SELECT feature_id, name, mode FROM features
19
+ WHERE current_phase != 'idle' AND progress_total = 0`
20
+ )
21
+ .all() as Array<{ feature_id: string; name: string; mode: string }>;
22
+
23
+ if (orphans.length > 0) {
24
+ const list = orphans.map((f) => ` • ${f.name} [${f.feature_id}] (${f.mode})`).join("\n");
25
+ 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.`;
26
+ try {
27
+ ctx.ui.setStatus("frappe-builder", `⚠ ${orphans.length} untracked feature(s) — run create_component`);
28
+ } catch { /* non-fatal */ }
29
+ console.error(warning);
30
+ }
31
+ } catch { /* non-fatal — never block shutdown */ }
32
+ });
11
33
  }
12
34
 
13
35
  /** Reads current_phase and feature_id from the active session. */
@@ -64,14 +86,16 @@ function isSpawnAgentResult(value: unknown): value is { status: string; skill: s
64
86
  }
65
87
 
66
88
  /** Type guard for the return value of startFeature(). */
67
- function isStartFeatureResult(value: unknown): value is { mode: string; feature_id: string } {
89
+ function isStartFeatureResult(value: unknown): value is { mode: string; feature_id: string; current_phase: string } {
68
90
  return (
69
91
  typeof value === "object" &&
70
92
  value !== null &&
71
93
  "mode" in value &&
72
94
  "feature_id" in value &&
95
+ "current_phase" in value &&
73
96
  typeof (value as Record<string, unknown>).mode === "string" &&
74
- typeof (value as Record<string, unknown>).feature_id === "string"
97
+ typeof (value as Record<string, unknown>).feature_id === "string" &&
98
+ typeof (value as Record<string, unknown>).current_phase === "string"
75
99
  );
76
100
  }
77
101
 
@@ -154,13 +178,21 @@ export async function handleAfterToolCall(
154
178
  // non-JSON result — skip phase transition silently
155
179
  }
156
180
  if (isStartFeatureResult(parsed)) {
157
- const newPhase = parsed.mode === "quick" ? "implementation" : "requirements";
158
- applyPhaseTransition(parsed.feature_id, newPhase);
181
+ // Full mode: chain running as background subprocess — inject status banner
182
+ if (parsed.current_phase === "chain_running") {
183
+ const banner: TextContent = {
184
+ type: "text",
185
+ text: `\n\nAgent chain started for "${parsed.feature_id}" (full mode).\n` +
186
+ `Each phase runs as an isolated subprocess. Monitor progress with get_project_status.\n` +
187
+ `The chain will advance through: requirements → architecture → planning → implementation → testing → documentation.`,
188
+ };
189
+ return { content: [...context.result.content, banner] };
190
+ }
159
191
 
160
- // Specialist auto-load after phase transition (Story 3.4) — non-fatal
192
+ // Quick mode: specialist auto-load after phase transition (Story 3.4) — non-fatal
161
193
  try {
162
194
  const specCtx = buildStateContext(parsed.feature_id, toolName);
163
- const specialistBlock = loadSpecialist(newPhase, specCtx);
195
+ const specialistBlock = loadSpecialist(parsed.current_phase, specCtx);
164
196
  if (specialistBlock) {
165
197
  const specialistContent: TextContent = {
166
198
  type: "text",
@@ -170,7 +202,6 @@ export async function handleAfterToolCall(
170
202
  }
171
203
  } catch (specialistErr) {
172
204
  console.error(`[frappe-state] specialist load failed: ${specialistErr}`);
173
- // non-fatal — session continues without specialist block
174
205
  }
175
206
  }
176
207
  }
@@ -1,59 +1,126 @@
1
1
  import type { AfterToolCallContext, AfterToolCallResult } from "@mariozechner/pi-agent-core";
2
2
  import { db, getCurrentPhase } from "../state/db.js";
3
+ import { loadConfig } from "../config/loader.js";
4
+ import { DEFAULT_PERMISSION_MODE } from "../config/defaults.js";
3
5
 
4
- interface FooterState {
6
+ interface DashboardState {
5
7
  projectId: string | null;
6
8
  featureName: string | null;
9
+ featureMode: string | null;
7
10
  componentId: string | null;
11
+ phase: string;
12
+ chainStep: string | null;
13
+ progressDone: number;
14
+ progressTotal: number;
15
+ permissionMode: string;
8
16
  }
9
17
 
10
- function readFooterState(): FooterState {
18
+ // Keep FooterState alias so existing tests compile without changes.
19
+ type FooterState = Pick<DashboardState, "projectId" | "featureName" | "componentId">;
20
+
21
+ function readDashboardState(): DashboardState {
11
22
  try {
12
23
  const session = db
13
24
  .prepare(
14
- `SELECT s.project_id, f.name AS feature_name, s.component_id
25
+ `SELECT s.project_id, s.current_phase, s.component_id, s.chain_step,
26
+ f.name AS feature_name, f.mode AS feature_mode,
27
+ f.progress_done, f.progress_total
15
28
  FROM sessions s
16
29
  LEFT JOIN features f ON f.feature_id = s.feature_id
17
30
  WHERE s.is_active = 1 LIMIT 1`
18
31
  )
19
32
  .get() as
20
- | { project_id: string | null; feature_name: string | null; component_id: string | null }
33
+ | {
34
+ project_id: string | null;
35
+ current_phase: string;
36
+ component_id: string | null;
37
+ chain_step: string | null;
38
+ feature_name: string | null;
39
+ feature_mode: string | null;
40
+ progress_done: number | null;
41
+ progress_total: number | null;
42
+ }
21
43
  | undefined;
22
44
 
45
+ const permissionMode = (() => {
46
+ try { return loadConfig().permissionMode ?? DEFAULT_PERMISSION_MODE; } catch { return DEFAULT_PERMISSION_MODE; }
47
+ })();
48
+
23
49
  return {
24
50
  projectId: session?.project_id ?? null,
25
51
  featureName: session?.feature_name ?? null,
52
+ featureMode: session?.feature_mode ?? null,
26
53
  componentId: session?.component_id ?? null,
54
+ phase: session?.current_phase ?? "idle",
55
+ chainStep: session?.chain_step ?? null,
56
+ progressDone: session?.progress_done ?? 0,
57
+ progressTotal: session?.progress_total ?? 0,
58
+ permissionMode,
27
59
  };
28
60
  } catch {
29
- return { projectId: null, featureName: null, componentId: null };
61
+ return { projectId: null, featureName: null, featureMode: null, componentId: null, phase: "idle", chainStep: null, progressDone: 0, progressTotal: 0, permissionMode: DEFAULT_PERMISSION_MODE };
30
62
  }
31
63
  }
32
64
 
33
65
  /**
34
- * Pure formatting function no I/O, no side effects.
66
+ * Formats dashboard state as a string[] for ctx.ui.setWidget().
35
67
  * Exported for unit testing.
36
68
  */
37
- export function formatFooter(state: FooterState): string {
69
+ export function formatDashboard(state: DashboardState): string[] {
38
70
  if (!state.projectId) {
39
- return `No active project`;
71
+ return ["frappe-builder | No active project"];
40
72
  }
41
- if (!state.featureName) {
42
- return `Project: ${state.projectId} | No active feature`;
73
+
74
+ const parts: string[] = [`Project: ${state.projectId}`];
75
+
76
+ if (state.featureName) {
77
+ const modeTag = state.featureMode ? ` [${state.featureMode}]` : "";
78
+ parts.push(`Feature: ${state.featureName}${modeTag}`);
79
+ } else {
80
+ parts.push("No active feature");
43
81
  }
44
- if (!state.componentId) {
45
- return `Project: ${state.projectId} | Feature: ${state.featureName}`;
82
+
83
+ if (state.componentId) {
84
+ parts.push(`Component: ${state.componentId}`);
46
85
  }
86
+
87
+ if (state.phase === "chain_running") {
88
+ parts.push(`Phase: chain:${state.chainStep ?? "starting"}`);
89
+ } else {
90
+ parts.push(`Phase: ${state.phase}`);
91
+ }
92
+
93
+ if (state.progressTotal > 0) {
94
+ parts.push(`${state.progressDone}/${state.progressTotal} components`);
95
+ }
96
+
97
+ parts.push(`Mode: ${state.permissionMode}`);
98
+
99
+ return [`frappe-builder | ${parts.join(" | ")}`];
100
+ }
101
+
102
+ /**
103
+ * Legacy single-line footer format — kept for backward-compat with existing tests.
104
+ * Exported for unit testing.
105
+ */
106
+ export function formatFooter(state: FooterState): string {
107
+ if (!state.projectId) return "No active project";
108
+ if (!state.featureName) return `Project: ${state.projectId} | No active feature`;
109
+ if (!state.componentId) return `Project: ${state.projectId} | Feature: ${state.featureName}`;
47
110
  return `Project: ${state.projectId} | Feature: ${state.featureName} | Component: ${state.componentId}`;
48
111
  }
49
112
 
50
- function renderFooter(): void {
113
+ function renderWidget(ctx: unknown): void {
51
114
  try {
52
- const state = readFooterState();
53
- const line = formatFooter(state);
54
- process.stderr.write(`\r\x1b[2K[frappe-builder] ${line}\n`);
115
+ 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" });
55
118
  } catch {
56
- // Silently swallow all rendering errors footer is non-critical
119
+ // Fallback: write to stderr if widget API unavailable
120
+ try {
121
+ const state = readDashboardState();
122
+ process.stderr.write(`\r\x1b[2K[frappe-builder] ${formatDashboard(state).join(" ")}\n`);
123
+ } catch { /* non-critical */ }
57
124
  }
58
125
  }
59
126
 
@@ -85,7 +152,6 @@ export async function handleAfterToolCall(
85
152
  _context: AfterToolCallContext,
86
153
  _signal?: AbortSignal
87
154
  ): Promise<AfterToolCallResult | undefined> {
88
- renderFooter();
89
155
  return undefined;
90
156
  }
91
157
 
@@ -93,33 +159,26 @@ export async function handleAfterToolCall(
93
159
  export default function (pi: any) {
94
160
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
161
  pi.on("session_start", (_event: any, ctx: any) => {
96
- try {
97
- ctx.ui.setStatus("frappe-builder", formatFooter(readFooterState()));
98
- } catch {
99
- renderFooter();
100
- }
162
+ renderWidget(ctx);
101
163
  });
102
164
 
103
165
  // Announcement hook — fires before FSM guard in frappe-workflow.ts (FR25)
104
166
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
167
  pi.on("tool_call", (event: any, ctx: any) => {
106
168
  try {
107
- const phase = getCurrentPhase();
108
- ctx.ui.setStatus("frappe-builder", `→ Calling ${event.toolName ?? ""} [${phase}]`);
169
+ const state = readDashboardState();
170
+ const lines = formatDashboard(state);
171
+ 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" });
109
174
  } catch {
110
175
  handleBeforeToolCall(event.toolName ?? "");
111
176
  }
112
177
  });
113
178
 
114
- // "tool_result" is the pi-coding-agent ExtensionAPI event name for after-tool hooks.
115
- // Using ctx.ui.setStatus() injects into pi's native footer row instead of writing
116
- // raw bytes to stderr (which pi's TUI redraws over).
179
+ // "tool_result" fires after each tool completes update the persistent widget.
117
180
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
181
  pi.on("tool_result", (_event: any, ctx: any) => {
119
- try {
120
- ctx.ui.setStatus("frappe-builder", formatFooter(readFooterState()));
121
- } catch {
122
- renderFooter();
123
- }
182
+ renderWidget(ctx);
124
183
  });
125
184
  }
@@ -1,6 +1,22 @@
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
+
7
+ // Re-export for use in tests and other modules
8
+ export type { PermissionMode };
9
+
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
+ ]);
4
20
 
5
21
  interface BlockedResponse {
6
22
  blocked: true;
@@ -35,7 +51,7 @@ function buildBlockedResponse(
35
51
  * Never throws — always returns a value or undefined.
36
52
  */
37
53
  // Tools valid in all phases — never blocked by the phase guard (FR34)
38
- const ALWAYS_ALLOWED_TOOLS = ["invoke_debugger", "end_debug", "spawn_agent", "get_frappe_docs", "get_audit_log"];
54
+ const ALWAYS_ALLOWED_TOOLS = ["invoke_debugger", "end_debug", "spawn_agent", "get_frappe_docs", "get_audit_log", "get_project_status"];
39
55
 
40
56
  export function beforeToolCall(
41
57
  toolName: string,
@@ -74,13 +90,75 @@ export function beforeToolCall(
74
90
  return undefined; // allow
75
91
  }
76
92
 
93
+ /**
94
+ * Checks the active permission mode for write tools.
95
+ * - auto: always allowed
96
+ * - plan: always blocked with dry-run message
97
+ * - default: prompts via ctx.ui.input(); falls through if unavailable
98
+ *
99
+ * Returns a blocked response to halt execution, or undefined to allow.
100
+ * Never throws.
101
+ */
102
+ export async function checkPermissionMode(
103
+ toolName: string,
104
+ mode: PermissionMode,
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ ctx?: any,
107
+ ): Promise<BlockedResponse | undefined> {
108
+ if (!WRITE_TOOLS.has(toolName)) return undefined;
109
+ if (mode === "auto") return undefined;
110
+
111
+ if (mode === "plan") {
112
+ return {
113
+ blocked: true,
114
+ tool: toolName,
115
+ current_phase: getCurrentPhase() as Phase,
116
+ valid_phase: "any",
117
+ message: `[plan mode] ${toolName} is a write operation — dry-run only. Switch to default or auto mode to execute.`,
118
+ };
119
+ }
120
+
121
+ // default mode: prompt via ctx.ui.input()
122
+ if (ctx) {
123
+ try {
124
+ const answer: string = await ctx.ui.input(`Allow ${toolName}? (yes/no)`);
125
+ if (!["yes", "y"].includes(answer?.toLowerCase?.() ?? "")) {
126
+ return {
127
+ blocked: true,
128
+ tool: toolName,
129
+ current_phase: getCurrentPhase() as Phase,
130
+ valid_phase: "any",
131
+ message: `${toolName} blocked by user.`,
132
+ };
133
+ }
134
+ } catch {
135
+ // ctx.ui.input unavailable — fail open (allow)
136
+ }
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
77
142
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
143
  export default function (pi: any) {
79
- pi.on("tool_call", (event: { toolName?: string; input?: Record<string, unknown> }) => {
80
- const result = beforeToolCall(event.toolName ?? "", event.input ?? {});
81
- if (result) {
82
- return { block: true, reason: result.message };
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) => {
146
+ const toolName = event.toolName ?? "";
147
+
148
+ // Phase guard
149
+ const phaseResult = beforeToolCall(toolName, event.input ?? {});
150
+ if (phaseResult) {
151
+ return { block: true, reason: phaseResult.message };
83
152
  }
153
+
154
+ // Permission mode guard (Story 9.2–9.4)
155
+ const config = loadConfig();
156
+ const mode: PermissionMode = config.permissionMode ?? DEFAULT_PERMISSION_MODE;
157
+ const permResult = await checkPermissionMode(toolName, mode, ctx);
158
+ if (permResult) {
159
+ return { block: true, reason: permResult.message };
160
+ }
161
+
84
162
  return undefined;
85
163
  });
86
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-builder",
3
- "version": "1.1.0-dev.13",
3
+ "version": "1.1.0-dev.17",
4
4
  "description": "Frappe-native AI co-pilot for building and customising Frappe/ERPNext applications",
5
5
  "type": "module",
6
6
  "bin": {