frappe-builder 1.1.0-dev.9 → 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.
Files changed (47) hide show
  1. package/.fb/state.db +0 -0
  2. package/.frappe-builder/po-approval/implementation-artifacts/sprint-status.yaml +15 -0
  3. package/AGENTS.md +59 -130
  4. package/README.md +14 -21
  5. package/agents/frappe-architect.md +29 -0
  6. package/agents/frappe-ba.md +28 -0
  7. package/agents/frappe-dev.md +25 -0
  8. package/agents/frappe-docs.md +27 -0
  9. package/agents/frappe-planner.md +28 -0
  10. package/agents/frappe-qa.md +28 -0
  11. package/config/constants.ts +45 -0
  12. package/config/defaults.ts +11 -3
  13. package/config/loader.ts +18 -84
  14. package/dist/cli.mjs +49 -36
  15. package/dist/init-DvtJrAiJ.mjs +233 -0
  16. package/extensions/agent-chain.ts +254 -0
  17. package/extensions/frappe-gates.ts +31 -7
  18. package/extensions/frappe-session.ts +11 -3
  19. package/extensions/frappe-state.ts +110 -20
  20. package/extensions/frappe-tools.ts +52 -29
  21. package/extensions/frappe-ui.ts +100 -40
  22. package/extensions/frappe-workflow.ts +82 -13
  23. package/extensions/pi-types.ts +53 -0
  24. package/package.json +2 -2
  25. package/state/artifacts.ts +85 -0
  26. package/state/db.ts +18 -4
  27. package/state/fsm.ts +33 -13
  28. package/state/schema.ts +42 -3
  29. package/tools/agent-tools.ts +71 -5
  30. package/tools/bench-tools.ts +4 -8
  31. package/tools/context-sandbox.ts +11 -7
  32. package/tools/feature-tools.ts +125 -8
  33. package/tools/frappe-context7.ts +28 -32
  34. package/tools/frappe-query-tools.ts +75 -20
  35. package/tools/project-tools.ts +14 -11
  36. package/dist/coverage-check-DLGO_qwW.mjs +0 -55
  37. package/dist/db-Cx_EyUEu.mjs +0 -58
  38. package/dist/frappe-gates-c4HHJp-4.mjs +0 -349
  39. package/dist/frappe-session-BfFveYq1.mjs +0 -5
  40. package/dist/frappe-session-BzM5oUCb.mjs +0 -5
  41. package/dist/frappe-state-k--gX3wq.mjs +0 -6
  42. package/dist/frappe-tools-Dwz0eEQ-.mjs +0 -13
  43. package/dist/frappe-ui-htmQgO8t.mjs +0 -3
  44. package/dist/frappe-workflow-VId2tr9e.mjs +0 -4
  45. package/dist/fsm-DkLob1CA.mjs +0 -3
  46. package/dist/init-ChmHonBN.mjs +0 -159
  47. package/dist/loader-DC2PlJU7.mjs +0 -68
@@ -1,21 +1,20 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { startFeature, completeComponent } from "../tools/feature-tools.js";
2
+ import type { PiPlugin } from "./pi-types.js";
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
- import { getFrappeDocs } from "../tools/frappe-context7.js";
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",
@@ -54,6 +53,26 @@ export default function (pi: any) {
54
53
  },
55
54
  });
56
55
 
56
+ pi.registerTool({
57
+ name: "create_component",
58
+ label: "Create Component",
59
+ description:
60
+ "Registers a planned unit of work as an in-progress component for the active feature. Call this BEFORE writing any code — one component per logical deliverable. Updates sprint-status.yaml and sets the active component on the session. Valid in planning and implementation phases.",
61
+ parameters: Type.Object({
62
+ featureId: Type.String({ description: "Feature ID (kebab-case slug, e.g. 'po-approval')" }),
63
+ componentId: Type.String({ description: "Component ID (e.g. 'auth-module', 'po-doctype')" }),
64
+ description: Type.Optional(Type.String({ description: "Short description of what this component implements" })),
65
+ sortOrder: Type.Optional(Type.Number({ description: "Order within the feature (lower = earlier)" })),
66
+ }),
67
+ execute: async (_toolCallId: string, params: ToolParams) => {
68
+ const result = createComponent(params);
69
+ return {
70
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
71
+ details: result,
72
+ };
73
+ },
74
+ });
75
+
57
76
  pi.registerTool({
58
77
  name: "set_active_project",
59
78
  label: "Set Active Project",
@@ -62,6 +81,9 @@ export default function (pi: any) {
62
81
  parameters: Type.Object({
63
82
  projectId: Type.String({ description: "Project identifier (e.g. 'my-frappe-site')" }),
64
83
  sitePath: Type.String({ description: "Absolute path to the Frappe bench site (e.g. '/home/user/frappe-bench/sites/site1.local')" }),
84
+ siteUrl: Type.Optional(Type.String({ description: "Frappe site URL (e.g. 'http://site1.localhost'). Required for frappe_query." })),
85
+ apiKey: Type.Optional(Type.String({ description: "Frappe API key from User > API Access. Required for frappe_query." })),
86
+ apiSecret: Type.Optional(Type.String({ description: "Frappe API secret from User > API Access. Required for frappe_query." })),
65
87
  }),
66
88
  execute: async (_toolCallId: string, params: ToolParams) => {
67
89
  const result = await setActiveProject(params);
@@ -122,29 +144,11 @@ export default function (pi: any) {
122
144
  },
123
145
  });
124
146
 
125
- pi.registerTool({
126
- name: "scaffold_doctype",
127
- label: "Scaffold DocType",
128
- description:
129
- "Creates a new Frappe DocType via bench. Valid only in the 'implementation' phase. (Story 4.2 deferred)",
130
- parameters: Type.Object({
131
- name: Type.String({ description: "DocType name in PascalCase (e.g. 'Purchase Order')" }),
132
- module: Type.String({ description: "Frappe module name (e.g. 'Buying')" }),
133
- }),
134
- execute: async (_toolCallId: string, params: ToolParams) => {
135
- const result = scaffoldDoctype(params);
136
- return {
137
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
138
- details: result,
139
- };
140
- },
141
- });
142
-
143
147
  pi.registerTool({
144
148
  name: "run_tests",
145
149
  label: "Run Tests",
146
150
  description:
147
- "Runs the Frappe test suite for the active feature via bench. Valid only in the 'testing' phase. (Epic 6)",
151
+ "NOT YET IMPLEMENTED (Epic 6 deferred). Returns an error. Use bench_execute with 'bench run-tests --app {app}' instead.",
148
152
  parameters: Type.Object({
149
153
  featureId: Type.Optional(Type.String({ description: "Feature ID to scope tests (optional)" })),
150
154
  }),
@@ -192,12 +196,31 @@ export default function (pi: any) {
192
196
  },
193
197
  });
194
198
 
195
- // 9th tool outside the 8 core Frappe-semantic tools; bypasses phase guard via ALWAYS_ALLOWED_TOOLS
199
+ // General-purpose library docs tool via context7 bypasses phase guard via ALWAYS_ALLOWED_TOOLS
200
+ pi.registerTool({
201
+ name: "get_library_docs",
202
+ label: "Get Library Docs",
203
+ description:
204
+ "Retrieves up-to-date documentation for any library via context7 MCP (React, Next.js, Frappe, ERPNext, Python, etc.). Raw web content never enters the LLM context. Valid in any phase.",
205
+ parameters: Type.Object({
206
+ query: Type.String({ description: "What you want to know (e.g. 'how do hooks work', 'useEffect cleanup')" }),
207
+ library: Type.Optional(Type.String({ description: "Library name (e.g. 'frappe', 'react', 'nextjs'). Defaults to 'frappe'." })),
208
+ }),
209
+ execute: async (_toolCallId: string, params: ToolParams) => {
210
+ const result = await getLibraryDocs(params);
211
+ return {
212
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
213
+ details: result,
214
+ };
215
+ },
216
+ });
217
+
218
+ // Frappe-specific alias kept for backwards compatibility
196
219
  pi.registerTool({
197
220
  name: "get_frappe_docs",
198
221
  label: "Get Frappe Docs",
199
222
  description:
200
- "Retrieves compressed Frappe/ERPNext documentation via context7 MCP. Raw web content never enters the LLM context (~90% token reduction). Valid in any phase. (Story 4.4)",
223
+ "Retrieves Frappe/ERPNext documentation via context7. Alias for get_library_docs with library='frappe'. Valid in any phase.",
201
224
  parameters: Type.Object({
202
225
  topic: Type.String({ description: "Documentation topic (e.g. 'DocType', 'hooks', 'frappe.db')" }),
203
226
  version: Type.Optional(Type.String({ description: "Frappe version to scope docs (e.g. 'v15')" })),
@@ -1,72 +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";
5
+ import type { PiPlugin, PiUiContext } from "./pi-types.js";
3
6
 
4
- interface FooterState {
7
+ interface DashboardState {
5
8
  projectId: string | null;
6
9
  featureName: string | null;
10
+ featureMode: string | null;
11
+ componentId: string | null;
7
12
  phase: string;
8
- tokenCount: number;
13
+ chainStep: string | null;
14
+ progressDone: number;
15
+ progressTotal: number;
16
+ permissionMode: string;
9
17
  }
10
18
 
11
- function readFooterState(): FooterState {
19
+ // Keep FooterState alias so existing tests compile without changes.
20
+ type FooterState = Pick<DashboardState, "projectId" | "featureName" | "componentId">;
21
+
22
+ function readDashboardState(): DashboardState {
12
23
  try {
13
24
  const session = db
14
25
  .prepare(
15
- `SELECT s.project_id, f.name AS feature_name, s.current_phase
26
+ `SELECT s.project_id, s.current_phase, s.component_id, s.chain_step,
27
+ f.name AS feature_name, f.mode AS feature_mode,
28
+ f.progress_done, f.progress_total
16
29
  FROM sessions s
17
30
  LEFT JOIN features f ON f.feature_id = s.feature_id
18
31
  WHERE s.is_active = 1 LIMIT 1`
19
32
  )
20
33
  .get() as
21
- | { project_id: string | null; feature_name: string | null; current_phase: string }
34
+ | {
35
+ project_id: string | null;
36
+ current_phase: string;
37
+ component_id: string | null;
38
+ chain_step: string | null;
39
+ feature_name: string | null;
40
+ feature_mode: string | null;
41
+ progress_done: number | null;
42
+ progress_total: number | null;
43
+ }
22
44
  | undefined;
23
45
 
24
- // Token count: count journal_entries for active session; table may not exist yet
25
- let tokenCount = 0;
26
- try {
27
- const tokenRow = db
28
- .prepare(
29
- `SELECT COUNT(*) as count FROM journal_entries
30
- WHERE session_id = (SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1)`
31
- )
32
- .get() as { count: number } | undefined;
33
- tokenCount = tokenRow?.count ?? 0;
34
- } catch {
35
- // journal_entries table not yet created — default to 0
36
- }
46
+ const permissionMode = (() => {
47
+ try { return loadConfig().permissionMode ?? DEFAULT_PERMISSION_MODE; } catch { return DEFAULT_PERMISSION_MODE; }
48
+ })();
37
49
 
38
50
  return {
39
51
  projectId: session?.project_id ?? null,
40
52
  featureName: session?.feature_name ?? null,
53
+ featureMode: session?.feature_mode ?? null,
54
+ componentId: session?.component_id ?? null,
41
55
  phase: session?.current_phase ?? "idle",
42
- tokenCount,
56
+ chainStep: session?.chain_step ?? null,
57
+ progressDone: session?.progress_done ?? 0,
58
+ progressTotal: session?.progress_total ?? 0,
59
+ permissionMode,
43
60
  };
44
61
  } catch {
45
- return { projectId: null, featureName: null, phase: "idle", tokenCount: 0 };
62
+ return { projectId: null, featureName: null, featureMode: null, componentId: null, phase: "idle", chainStep: null, progressDone: 0, progressTotal: 0, permissionMode: DEFAULT_PERMISSION_MODE };
46
63
  }
47
64
  }
48
65
 
49
66
  /**
50
- * Pure formatting function no I/O, no side effects.
67
+ * Formats dashboard state as a string[] for ctx.ui.setWidget().
51
68
  * Exported for unit testing.
52
69
  */
53
- export function formatFooter(state: FooterState): string {
70
+ export function formatDashboard(state: DashboardState): string[] {
54
71
  if (!state.projectId) {
55
- return `No active project | Phase: ${state.phase}`;
72
+ return ["frappe-builder | No active project"];
56
73
  }
57
- if (!state.featureName) {
58
- return `Project: ${state.projectId} | No active feature | Phase: ${state.phase}`;
74
+
75
+ const parts: string[] = [`Project: ${state.projectId}`];
76
+
77
+ if (state.featureName) {
78
+ const modeTag = state.featureMode ? ` [${state.featureMode}]` : "";
79
+ parts.push(`Feature: ${state.featureName}${modeTag}`);
80
+ } else {
81
+ parts.push("No active feature");
82
+ }
83
+
84
+ if (state.componentId) {
85
+ parts.push(`Component: ${state.componentId}`);
86
+ }
87
+
88
+ if (state.phase === "chain_running") {
89
+ parts.push(`Phase: chain:${state.chainStep ?? "starting"}`);
90
+ } else {
91
+ parts.push(`Phase: ${state.phase}`);
92
+ }
93
+
94
+ if (state.progressTotal > 0) {
95
+ parts.push(`${state.progressDone}/${state.progressTotal} components`);
59
96
  }
60
- return `Project: ${state.projectId} | Feature: ${state.featureName} | Phase: ${state.phase} | Tokens: ${state.tokenCount}`;
97
+
98
+ parts.push(`Mode: ${state.permissionMode}`);
99
+
100
+ return [`frappe-builder | ${parts.join(" | ")}`];
101
+ }
102
+
103
+ /**
104
+ * Legacy single-line footer format — kept for backward-compat with existing tests.
105
+ * Exported for unit testing.
106
+ */
107
+ export function formatFooter(state: FooterState): string {
108
+ if (!state.projectId) return "No active project";
109
+ if (!state.featureName) return `Project: ${state.projectId} | No active feature`;
110
+ if (!state.componentId) return `Project: ${state.projectId} | Feature: ${state.featureName}`;
111
+ return `Project: ${state.projectId} | Feature: ${state.featureName} | Component: ${state.componentId}`;
61
112
  }
62
113
 
63
- function renderFooter(): void {
114
+ function renderWidget(ctx: PiUiContext | undefined): void {
64
115
  try {
65
- const state = readFooterState();
66
- const line = formatFooter(state);
67
- process.stderr.write(`\r\x1b[2K[frappe-builder] ${line}\n`);
116
+ const lines = formatDashboard(readDashboardState());
117
+ ctx?.ui.setWidget?.("frappe-builder", lines, { placement: "aboveEditor" });
68
118
  } catch {
69
- // 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 */ }
70
124
  }
71
125
  }
72
126
 
@@ -98,22 +152,28 @@ export async function handleAfterToolCall(
98
152
  _context: AfterToolCallContext,
99
153
  _signal?: AbortSignal
100
154
  ): Promise<AfterToolCallResult | undefined> {
101
- renderFooter();
102
155
  return undefined;
103
156
  }
104
157
 
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- export default function (pi: any) {
107
- pi.on("session_start", () => {
108
- renderFooter();
158
+ export default function (pi: PiPlugin) {
159
+ pi.on("session_start", (_event?: unknown, ctx?: PiUiContext) => {
160
+ renderWidget(ctx);
109
161
  });
110
162
 
111
163
  // Announcement hook — fires before FSM guard in frappe-workflow.ts (FR25)
112
- pi.on("tool_call", (event: { toolName?: string }) => {
113
- return handleBeforeToolCall(event.toolName ?? "");
164
+ pi.on("tool_call", (event: { toolName?: string; input?: Record<string, unknown> }, ctx?: PiUiContext) => {
165
+ try {
166
+ const state = readDashboardState();
167
+ const lines = formatDashboard(state);
168
+ const callLine = `→ ${event.toolName ?? "tool"} [${state.phase}]`;
169
+ ctx?.ui.setWidget?.("frappe-builder", [...lines, callLine], { placement: "aboveEditor" });
170
+ } catch {
171
+ handleBeforeToolCall(event.toolName ?? "");
172
+ }
114
173
  });
115
174
 
116
- pi.on("after_tool_call", async (context: AfterToolCallContext) => {
117
- return await handleAfterToolCall(context);
175
+ // "tool_result" fires after each tool completes — update the persistent widget.
176
+ pi.on("tool_result", (_event?: unknown, ctx?: PiUiContext) => {
177
+ renderWidget(ctx);
118
178
  });
119
179
  }
@@ -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,13 +1,12 @@
1
1
  {
2
2
  "name": "frappe-builder",
3
- "version": "1.1.0-dev.9",
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
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",
@@ -38,6 +37,7 @@
38
37
  "dependencies": {
39
38
  "@mariozechner/pi-agent-core": "0.62.0",
40
39
  "@mariozechner/pi-ai": "0.62.0",
40
+ "@mariozechner/pi-coding-agent": "^0.63.1",
41
41
  "@types/better-sqlite3": "^7.6.13",
42
42
  "better-sqlite3": "^12.8.0",
43
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
+ }