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.
- package/.fb/state.db +0 -0
- package/.frappe-builder/po-approval/implementation-artifacts/sprint-status.yaml +15 -0
- package/AGENTS.md +59 -130
- package/README.md +14 -21
- package/agents/frappe-architect.md +29 -0
- package/agents/frappe-ba.md +28 -0
- package/agents/frappe-dev.md +25 -0
- package/agents/frappe-docs.md +27 -0
- package/agents/frappe-planner.md +28 -0
- package/agents/frappe-qa.md +28 -0
- package/config/constants.ts +45 -0
- package/config/defaults.ts +11 -3
- package/config/loader.ts +18 -84
- package/dist/cli.mjs +49 -36
- package/dist/init-DvtJrAiJ.mjs +233 -0
- package/extensions/agent-chain.ts +254 -0
- package/extensions/frappe-gates.ts +31 -7
- package/extensions/frappe-session.ts +11 -3
- package/extensions/frappe-state.ts +110 -20
- package/extensions/frappe-tools.ts +52 -29
- package/extensions/frappe-ui.ts +100 -40
- package/extensions/frappe-workflow.ts +82 -13
- package/extensions/pi-types.ts +53 -0
- package/package.json +2 -2
- package/state/artifacts.ts +85 -0
- package/state/db.ts +18 -4
- package/state/fsm.ts +33 -13
- package/state/schema.ts +42 -3
- package/tools/agent-tools.ts +71 -5
- package/tools/bench-tools.ts +4 -8
- package/tools/context-sandbox.ts +11 -7
- package/tools/feature-tools.ts +125 -8
- package/tools/frappe-context7.ts +28 -32
- package/tools/frappe-query-tools.ts +75 -20
- package/tools/project-tools.ts +14 -11
- package/dist/coverage-check-DLGO_qwW.mjs +0 -55
- package/dist/db-Cx_EyUEu.mjs +0 -58
- package/dist/frappe-gates-c4HHJp-4.mjs +0 -349
- package/dist/frappe-session-BfFveYq1.mjs +0 -5
- package/dist/frappe-session-BzM5oUCb.mjs +0 -5
- package/dist/frappe-state-k--gX3wq.mjs +0 -6
- package/dist/frappe-tools-Dwz0eEQ-.mjs +0 -13
- package/dist/frappe-ui-htmQgO8t.mjs +0 -3
- package/dist/frappe-workflow-VId2tr9e.mjs +0 -4
- package/dist/fsm-DkLob1CA.mjs +0 -3
- package/dist/init-ChmHonBN.mjs +0 -159
- package/dist/loader-DC2PlJU7.mjs +0 -68
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
//
|
|
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
|
|
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')" })),
|
package/extensions/frappe-ui.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
13
|
+
chainStep: string | null;
|
|
14
|
+
progressDone: number;
|
|
15
|
+
progressTotal: number;
|
|
16
|
+
permissionMode: string;
|
|
9
17
|
}
|
|
10
18
|
|
|
11
|
-
|
|
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,
|
|
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
|
-
| {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
*
|
|
67
|
+
* Formats dashboard state as a string[] for ctx.ui.setWidget().
|
|
51
68
|
* Exported for unit testing.
|
|
52
69
|
*/
|
|
53
|
-
export function
|
|
70
|
+
export function formatDashboard(state: DashboardState): string[] {
|
|
54
71
|
if (!state.projectId) {
|
|
55
|
-
return
|
|
72
|
+
return ["frappe-builder | No active project"];
|
|
56
73
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
114
|
+
function renderWidget(ctx: PiUiContext | undefined): void {
|
|
64
115
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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:
|
|
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:
|
|
23
|
-
message: `${tool} is not available in ${currentPhase} phase. Available in: ${
|
|
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
|
-
//
|
|
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.ts — single 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.
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
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
|
+
}
|