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 +0 -0
- package/.frappe-builder/po-approval/implementation-artifacts/sprint-status.yaml +2 -2
- package/README.md +14 -21
- package/config/constants.ts +45 -0
- package/config/defaults.ts +1 -0
- package/dist/cli.mjs +1 -1
- package/dist/{init-CkLSZ_3g.mjs → init-DvtJrAiJ.mjs} +25 -9
- package/extensions/agent-chain.ts +13 -7
- package/extensions/frappe-gates.ts +31 -7
- package/extensions/frappe-session.ts +2 -2
- package/extensions/frappe-state.ts +4 -5
- package/extensions/frappe-tools.ts +5 -24
- package/extensions/frappe-ui.ts +8 -13
- package/extensions/frappe-workflow.ts +10 -20
- package/extensions/pi-types.ts +53 -0
- package/package.json +1 -2
- package/state/fsm.ts +7 -10
- package/state/schema.ts +6 -1
- package/tools/agent-tools.ts +71 -5
- package/tools/bench-tools.ts +4 -8
- package/tools/frappe-query-tools.ts +41 -2
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-
|
|
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-
|
|
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
|
|
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
|
|
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;
|
package/config/defaults.ts
CHANGED
|
@@ -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-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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"),
|
|
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,
|
|
199
|
-
const implDir = join(artifactDir,
|
|
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
|
-
|
|
34
|
-
type AnyModule = Record<string, any>;
|
|
36
|
+
type AnyModule = Record<string, unknown>;
|
|
35
37
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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",
|
|
@@ -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",
|
package/extensions/frappe-ui.ts
CHANGED
|
@@ -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:
|
|
114
|
+
function renderWidget(ctx: PiUiContext | undefined): void {
|
|
114
115
|
try {
|
|
115
116
|
const lines = formatDashboard(readDashboardState());
|
|
116
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.ts — single 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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
package/tools/agent-tools.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/tools/bench-tools.ts
CHANGED
|
@@ -52,12 +52,8 @@ export async function benchExecute({
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
export function
|
|
57
|
-
|
|
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 =
|
|
45
|
-
|
|
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",
|