frappe-builder 1.1.0-dev.8 → 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 +77 -0
- 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 +5 -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/tsdown.config.ts +1 -0
package/config/loader.ts
CHANGED
|
@@ -1,96 +1,16 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { defaults } from "./defaults.js";
|
|
5
5
|
import type { AppConfig } from "./defaults.js";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export interface GlobalConfig {
|
|
10
|
-
llm_api_key: string;
|
|
11
|
-
llm_provider?: string;
|
|
12
|
-
[key: string]: unknown;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface ProjectConfig {
|
|
16
|
-
site_url: string;
|
|
17
|
-
api_key: string;
|
|
18
|
-
api_secret: string;
|
|
19
|
-
[key: string]: unknown;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface CredentialConfig {
|
|
23
|
-
global: GlobalConfig;
|
|
24
|
-
project: ProjectConfig;
|
|
25
|
-
sessionLogDir: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Always ~/.frappe-builder/sessions/ — never the project directory (NFR9). */
|
|
7
|
+
/** Always ~/.frappe-builder/sessions/ — never the project directory. */
|
|
29
8
|
export const SESSION_LOG_DIR = join(homedir(), ".frappe-builder", "sessions");
|
|
30
9
|
|
|
31
|
-
const GITIGNORE_ERROR =
|
|
32
|
-
"Site credentials file is not gitignored. Add .frappe-builder-config.json to .gitignore before proceeding.";
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Validates that {projectRoot}/.gitignore lists .frappe-builder-config.json.
|
|
36
|
-
* Throws with the exact AC error message if missing or absent.
|
|
37
|
-
*/
|
|
38
|
-
export function validateGitignore(projectRoot: string): void {
|
|
39
|
-
const gitignorePath = join(projectRoot, ".gitignore");
|
|
40
|
-
if (!existsSync(gitignorePath)) {
|
|
41
|
-
throw new Error(GITIGNORE_ERROR);
|
|
42
|
-
}
|
|
43
|
-
const contents = readFileSync(gitignorePath, "utf8");
|
|
44
|
-
const lines = contents.split("\n").map((l) => l.trim());
|
|
45
|
-
if (!lines.includes(".frappe-builder-config.json")) {
|
|
46
|
-
throw new Error(GITIGNORE_ERROR);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Reads LLM API keys from ~/.frappe-builder/config.json (user-global, never project).
|
|
52
|
-
* Creates ~/.frappe-builder/ on first run. Returns empty defaults if file absent.
|
|
53
|
-
*/
|
|
54
|
-
export function loadGlobalConfig(): GlobalConfig {
|
|
55
|
-
const configDir = join(homedir(), ".frappe-builder");
|
|
56
|
-
mkdirSync(configDir, { recursive: true });
|
|
57
|
-
const configPath = join(configDir, "config.json");
|
|
58
|
-
if (!existsSync(configPath)) return { llm_api_key: "" };
|
|
59
|
-
try {
|
|
60
|
-
return JSON.parse(readFileSync(configPath, "utf8")) as GlobalConfig;
|
|
61
|
-
} catch {
|
|
62
|
-
return { llm_api_key: "" };
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Reads Frappe site credentials from {projectRoot}/.frappe-builder-config.json.
|
|
68
|
-
* Returns empty defaults if file absent (caller should surface a warning).
|
|
69
|
-
*/
|
|
70
|
-
export function loadProjectConfig(projectRoot: string): ProjectConfig {
|
|
71
|
-
const configPath = join(projectRoot, ".frappe-builder-config.json");
|
|
72
|
-
if (!existsSync(configPath)) return { site_url: "", api_key: "", api_secret: "" };
|
|
73
|
-
try {
|
|
74
|
-
return JSON.parse(readFileSync(configPath, "utf8")) as ProjectConfig;
|
|
75
|
-
} catch {
|
|
76
|
-
return { site_url: "", api_key: "", api_secret: "" };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Full credential loader: validates gitignore, loads global + project configs.
|
|
82
|
-
* Throws if .frappe-builder-config.json is not gitignored.
|
|
83
|
-
* API keys are only ever read from ~/.frappe-builder/ — never from projectRoot.
|
|
84
|
-
*/
|
|
85
|
-
export function loadCredentials(projectRoot: string): CredentialConfig {
|
|
86
|
-
validateGitignore(projectRoot);
|
|
87
|
-
const global = loadGlobalConfig();
|
|
88
|
-
const project = loadProjectConfig(projectRoot);
|
|
89
|
-
return { global, project, sessionLogDir: SESSION_LOG_DIR };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
10
|
/**
|
|
93
11
|
* Loads ~/.frappe-builder/config.json and merges with defaults.
|
|
12
|
+
* Stores agent behaviour preferences (permissionMode, requirePermission, etc.).
|
|
13
|
+
* Site credentials live in the session DB — not in this file.
|
|
94
14
|
* Never throws — returns defaults on any read/parse failure.
|
|
95
15
|
*/
|
|
96
16
|
export function loadConfig(): AppConfig {
|
|
@@ -103,3 +23,17 @@ export function loadConfig(): AppConfig {
|
|
|
103
23
|
return { ...defaults };
|
|
104
24
|
}
|
|
105
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Writes a partial config merged over the current config to ~/.frappe-builder/config.json.
|
|
29
|
+
* Creates the directory if needed. Non-fatal — never throws.
|
|
30
|
+
*/
|
|
31
|
+
export function saveConfig(partial: Partial<AppConfig>): void {
|
|
32
|
+
try {
|
|
33
|
+
const configDir = join(homedir(), ".frappe-builder");
|
|
34
|
+
mkdirSync(configDir, { recursive: true });
|
|
35
|
+
const configPath = join(configDir, "config.json");
|
|
36
|
+
const merged = { ...loadConfig(), ...partial };
|
|
37
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
38
|
+
} catch { /* non-fatal */ }
|
|
39
|
+
}
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
//#region src/cli.ts
|
|
9
|
+
/**
|
|
10
|
+
* src/cli.ts — frappe-builder CLI entry point
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* frappe-builder init — bootstrap agent toolchain (run once per machine)
|
|
14
|
+
* frappe-builder — start a pi session with frappe-builder extensions loaded
|
|
15
|
+
*/
|
|
16
|
+
const cmd = process.argv[2];
|
|
17
|
+
if (cmd === "init") {
|
|
18
|
+
const { runInit } = await import("./init-DvtJrAiJ.mjs");
|
|
19
|
+
await runInit();
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
if (cmd === "--help" || cmd === "-h") {
|
|
23
|
+
console.log(`
|
|
24
|
+
Usage: frappe-builder [command]
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
init Bootstrap agent toolchain: context-mode, mcp2cli, context7 (run once per machine)
|
|
28
|
+
(none) Start a frappe-builder session
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--help, -h Show this help message
|
|
32
|
+
`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
if (!existsSync(join(homedir(), ".frappe-builder", ".initialized"))) {
|
|
36
|
+
console.error("frappe-builder toolchain not set up.");
|
|
37
|
+
console.error("Run: frappe-builder init");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const pkgRoot = resolve(fileURLToPath(import.meta.url), "..", "..");
|
|
41
|
+
const extensions = [
|
|
42
|
+
"frappe-session",
|
|
43
|
+
"frappe-state",
|
|
44
|
+
"frappe-workflow",
|
|
45
|
+
"frappe-tools",
|
|
46
|
+
"frappe-gates",
|
|
47
|
+
"frappe-ui"
|
|
48
|
+
].map((name) => resolve(pkgRoot, "extensions", `${name}.ts`));
|
|
49
|
+
const agentsFile = resolve(pkgRoot, "AGENTS.md");
|
|
50
|
+
const require = createRequire(import.meta.url);
|
|
51
|
+
let piCli;
|
|
52
|
+
try {
|
|
53
|
+
piCli = require.resolve("@mariozechner/pi-coding-agent/dist/cli.js");
|
|
54
|
+
} catch {}
|
|
55
|
+
if (!piCli) {
|
|
56
|
+
const direct = resolve(pkgRoot, "node_modules/@mariozechner/pi-coding-agent/dist/cli.js");
|
|
57
|
+
if (existsSync(direct)) piCli = direct;
|
|
58
|
+
}
|
|
59
|
+
if (!piCli) {
|
|
60
|
+
const sibling = resolve(pkgRoot, "..", "@mariozechner", "pi-coding-agent", "dist", "cli.js");
|
|
61
|
+
if (existsSync(sibling)) piCli = sibling;
|
|
62
|
+
}
|
|
63
|
+
if (!piCli) {
|
|
64
|
+
console.error("Could not locate pi CLI.");
|
|
65
|
+
console.error("Try reinstalling: npm install -g frappe-builder@dev");
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const extArgs = extensions.flatMap((e) => ["-e", e]);
|
|
69
|
+
const result = spawnSync(process.execPath, [
|
|
70
|
+
piCli,
|
|
71
|
+
...extArgs,
|
|
72
|
+
"--append-system-prompt",
|
|
73
|
+
agentsFile
|
|
74
|
+
], { stdio: "inherit" });
|
|
75
|
+
process.exit(result.status ?? 0);
|
|
76
|
+
//#endregion
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
//#region src/init.ts
|
|
7
|
+
/**
|
|
8
|
+
* src/init.ts — toolchain setup for frappe-builder
|
|
9
|
+
*
|
|
10
|
+
* Bootstraps the agent environment: context-mode, mcp2cli, context7.
|
|
11
|
+
* No credential prompts — site credentials are collected at runtime via set_active_project.
|
|
12
|
+
*
|
|
13
|
+
* No imports from state/, extensions/, or gates/.
|
|
14
|
+
* Uses Node.js built-ins only.
|
|
15
|
+
*/
|
|
16
|
+
function writeAtomic(filePath, content) {
|
|
17
|
+
const tmp = filePath + ".tmp";
|
|
18
|
+
writeFileSync(tmp, content, "utf-8");
|
|
19
|
+
renameSync(tmp, filePath);
|
|
20
|
+
}
|
|
21
|
+
/** Patches .gitignore to include the exact entry if not already present. */
|
|
22
|
+
function patchGitignore(projectRoot, entry) {
|
|
23
|
+
const gitignorePath = join(projectRoot, ".gitignore");
|
|
24
|
+
if (!existsSync(gitignorePath)) {
|
|
25
|
+
writeFileSync(gitignorePath, entry + "\n", "utf-8");
|
|
26
|
+
return "created";
|
|
27
|
+
}
|
|
28
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
29
|
+
if (content.split("\n").includes(entry)) return "already-present";
|
|
30
|
+
writeFileSync(gitignorePath, content.endsWith("\n") ? content + entry + "\n" : content + "\n" + entry + "\n", "utf-8");
|
|
31
|
+
return "patched";
|
|
32
|
+
}
|
|
33
|
+
async function runInit(opts = {}) {
|
|
34
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
35
|
+
const homeDir = homedir();
|
|
36
|
+
const markerPath = join(homeDir, ".frappe-builder", ".initialized");
|
|
37
|
+
console.log("\n=== frappe-builder Setup ===\n");
|
|
38
|
+
mkdirSync(join(homeDir, ".frappe-builder"), { recursive: true });
|
|
39
|
+
writeFileSync(markerPath, (/* @__PURE__ */ new Date()).toISOString() + "\n", "utf-8");
|
|
40
|
+
const gitignoreResult = patchGitignore(projectRoot, ".fb/");
|
|
41
|
+
const written = [];
|
|
42
|
+
if (gitignoreResult === "patched") written.push(".gitignore (patched with .fb/)");
|
|
43
|
+
else if (gitignoreResult === "created") written.push(".gitignore (created with .fb/)");
|
|
44
|
+
setupMcp2cli(homeDir, await setupContextMode(homeDir));
|
|
45
|
+
setupContext7();
|
|
46
|
+
if (written.length > 0) {
|
|
47
|
+
console.log("\nFiles written:");
|
|
48
|
+
for (const f of written) console.log(` ✓ ${f}`);
|
|
49
|
+
}
|
|
50
|
+
console.log("\nReady. Run: frappe-builder\n");
|
|
51
|
+
}
|
|
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
|
+
/**
|
|
67
|
+
* Installs and configures the context-mode pi MCP extension.
|
|
68
|
+
* Clones https://github.com/mksglu/context-mode into ~/.pi/extensions/context-mode,
|
|
69
|
+
* builds it, and patches ~/.pi/settings/mcp.json with the server entry.
|
|
70
|
+
*
|
|
71
|
+
* Returns the resolved start.mjs path (for use by setupMcp2cli).
|
|
72
|
+
* Non-fatal — failures are logged as warnings, never abort init.
|
|
73
|
+
*/
|
|
74
|
+
async function setupContextMode(homeDir) {
|
|
75
|
+
const extDir = join(homeDir, ".pi", "extensions", "context-mode");
|
|
76
|
+
const mcpSettingsDir = join(homeDir, ".pi", "settings");
|
|
77
|
+
const mcpSettingsPath = join(mcpSettingsDir, "mcp.json");
|
|
78
|
+
const startScript = resolveContextModeStartScript(extDir);
|
|
79
|
+
console.log("\n[context-mode MCP extension]");
|
|
80
|
+
if (existsSync(extDir)) console.log(" ✓ context-mode already installed at ~/.pi/extensions/context-mode");
|
|
81
|
+
else {
|
|
82
|
+
console.log(" context-mode not found — installing (requires git + Node.js)...");
|
|
83
|
+
mkdirSync(join(homeDir, ".pi", "extensions"), { recursive: true });
|
|
84
|
+
const clone = spawnSync("git", [
|
|
85
|
+
"clone",
|
|
86
|
+
"https://github.com/mksglu/context-mode.git",
|
|
87
|
+
extDir
|
|
88
|
+
], { stdio: "pipe" });
|
|
89
|
+
if (clone.status !== 0) {
|
|
90
|
+
console.warn(` ⚠ git clone failed: ${clone.stderr?.toString().trim()}`);
|
|
91
|
+
console.warn(" Skipping context-mode setup. Install manually: https://github.com/mksglu/context-mode");
|
|
92
|
+
return startScript;
|
|
93
|
+
}
|
|
94
|
+
const install = spawnSync("npm", ["install"], {
|
|
95
|
+
cwd: extDir,
|
|
96
|
+
stdio: "pipe"
|
|
97
|
+
});
|
|
98
|
+
if (install.status !== 0) {
|
|
99
|
+
console.warn(` ⚠ npm install failed: ${install.stderr?.toString().trim()}`);
|
|
100
|
+
return startScript;
|
|
101
|
+
}
|
|
102
|
+
const build = spawnSync("npm", ["run", "build"], {
|
|
103
|
+
cwd: extDir,
|
|
104
|
+
stdio: "pipe"
|
|
105
|
+
});
|
|
106
|
+
if (build.status !== 0) {
|
|
107
|
+
console.warn(` ⚠ npm run build failed: ${build.stderr?.toString().trim()}`);
|
|
108
|
+
return startScript;
|
|
109
|
+
}
|
|
110
|
+
console.log(" ✓ context-mode installed and built");
|
|
111
|
+
}
|
|
112
|
+
mkdirSync(mcpSettingsDir, { recursive: true });
|
|
113
|
+
let mcpConfig = {};
|
|
114
|
+
if (existsSync(mcpSettingsPath)) try {
|
|
115
|
+
mcpConfig = JSON.parse(readFileSync(mcpSettingsPath, "utf-8"));
|
|
116
|
+
} catch {}
|
|
117
|
+
const servers = mcpConfig.mcpServers ?? {};
|
|
118
|
+
if (servers["context-mode"]) {
|
|
119
|
+
console.log(" ✓ context-mode already in ~/.pi/settings/mcp.json");
|
|
120
|
+
return startScript;
|
|
121
|
+
}
|
|
122
|
+
servers["context-mode"] = {
|
|
123
|
+
command: "node",
|
|
124
|
+
args: [startScript]
|
|
125
|
+
};
|
|
126
|
+
mcpConfig.mcpServers = servers;
|
|
127
|
+
writeAtomic(mcpSettingsPath, JSON.stringify(mcpConfig, null, 2) + "\n");
|
|
128
|
+
console.log(" ✓ Added context-mode to ~/.pi/settings/mcp.json");
|
|
129
|
+
console.log(" Restart pi (or frappe-builder) for context-mode to activate.");
|
|
130
|
+
return startScript;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Installs the mcp2cli Claude Code skill and bakes the context-mode connection
|
|
134
|
+
* so the agent can call `mcp2cli @context-mode <tool>` without repeating flags.
|
|
135
|
+
*
|
|
136
|
+
* startScript is passed in from setupContextMode so both functions use the same resolved path.
|
|
137
|
+
* Non-fatal — failures are logged as warnings, never abort init.
|
|
138
|
+
*/
|
|
139
|
+
function setupMcp2cli(homeDir, startScript) {
|
|
140
|
+
if (!startScript) startScript = resolveContextModeStartScript(join(homeDir, ".pi", "extensions", "context-mode"));
|
|
141
|
+
console.log("\n[mcp2cli skill + context-mode bake]");
|
|
142
|
+
if (spawnSync("mcp2cli", ["--version"], { stdio: "pipe" }).status === 0) console.log(" ✓ mcp2cli already installed");
|
|
143
|
+
else {
|
|
144
|
+
console.log(" Installing mcp2cli...");
|
|
145
|
+
if (spawnSync("uv", [
|
|
146
|
+
"tool",
|
|
147
|
+
"install",
|
|
148
|
+
"mcp2cli"
|
|
149
|
+
], { stdio: "pipe" }).status === 0) console.log(" ✓ mcp2cli installed via uv");
|
|
150
|
+
else if (spawnSync("pip", ["install", "mcp2cli"], { stdio: "pipe" }).status === 0) console.log(" ✓ mcp2cli installed via pip");
|
|
151
|
+
else {
|
|
152
|
+
console.warn(" ⚠ mcp2cli install failed (tried uv and pip)");
|
|
153
|
+
console.warn(" Install manually: uv tool install mcp2cli");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const skillAdd = spawnSync("npx", [
|
|
157
|
+
"skills",
|
|
158
|
+
"add",
|
|
159
|
+
"knowsuchagency/mcp2cli",
|
|
160
|
+
"--skill",
|
|
161
|
+
"mcp2cli"
|
|
162
|
+
], { stdio: "pipe" });
|
|
163
|
+
if (skillAdd.status !== 0) {
|
|
164
|
+
console.warn(` ⚠ mcp2cli skill install failed: ${skillAdd.stderr?.toString().trim()}`);
|
|
165
|
+
console.warn(" Install manually: npx skills add knowsuchagency/mcp2cli --skill mcp2cli");
|
|
166
|
+
} else console.log(" ✓ mcp2cli skill installed");
|
|
167
|
+
if (spawnSync("mcp2cli", [
|
|
168
|
+
"bake",
|
|
169
|
+
"show",
|
|
170
|
+
"context-mode"
|
|
171
|
+
], { stdio: "pipe" }).status === 0) {
|
|
172
|
+
console.log(" ✓ mcp2cli @context-mode already baked");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!existsSync(startScript)) {
|
|
176
|
+
console.warn(" ⚠ context-mode start.mjs not found — skipping bake (run init again after context-mode installs)");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const bakeCreate = spawnSync("mcp2cli", [
|
|
180
|
+
"bake",
|
|
181
|
+
"create",
|
|
182
|
+
"context-mode",
|
|
183
|
+
"--mcp-stdio",
|
|
184
|
+
`node ${startScript}`
|
|
185
|
+
], { stdio: "pipe" });
|
|
186
|
+
if (bakeCreate.status !== 0) console.warn(` ⚠ mcp2cli bake failed: ${bakeCreate.stderr?.toString().trim()}`);
|
|
187
|
+
else console.log(" ✓ mcp2cli @context-mode baked — agent can now call: mcp2cli @context-mode <tool>");
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Bakes the context7 cloud MCP server as @context7 and installs the
|
|
191
|
+
* netresearch context7 Claude Code skill for general-purpose library research.
|
|
192
|
+
*
|
|
193
|
+
* Non-fatal — failures are logged as warnings, never abort init.
|
|
194
|
+
*/
|
|
195
|
+
function setupContext7() {
|
|
196
|
+
console.log("\n[context7 MCP bake + skill]");
|
|
197
|
+
if (spawnSync("mcp2cli", [
|
|
198
|
+
"bake",
|
|
199
|
+
"show",
|
|
200
|
+
"context7"
|
|
201
|
+
], { stdio: "pipe" }).status === 0) console.log(" ✓ mcp2cli @context7 already baked");
|
|
202
|
+
else {
|
|
203
|
+
const bakeCreate = spawnSync("mcp2cli", [
|
|
204
|
+
"bake",
|
|
205
|
+
"create",
|
|
206
|
+
"context7",
|
|
207
|
+
"--mcp",
|
|
208
|
+
"https://mcp.context7.com/mcp"
|
|
209
|
+
], { stdio: "pipe" });
|
|
210
|
+
if (bakeCreate.status !== 0) {
|
|
211
|
+
console.warn(` ⚠ context7 bake failed: ${bakeCreate.stderr?.toString().trim()}`);
|
|
212
|
+
console.warn(" Install mcp2cli first: uv tool install mcp2cli");
|
|
213
|
+
} else console.log(" ✓ mcp2cli @context7 baked — agent can now call: mcp2cli @context7 resolve-library-id");
|
|
214
|
+
}
|
|
215
|
+
spawnSync("npx", [
|
|
216
|
+
"skills",
|
|
217
|
+
"add",
|
|
218
|
+
"knowsuchagency/mcp2cli",
|
|
219
|
+
"--skill",
|
|
220
|
+
"mcp2cli"
|
|
221
|
+
], { stdio: "pipe" });
|
|
222
|
+
if (spawnSync("claude", [
|
|
223
|
+
"plugin",
|
|
224
|
+
"marketplace",
|
|
225
|
+
"add",
|
|
226
|
+
"netresearch/claude-code-marketplace"
|
|
227
|
+
], { stdio: "pipe" }).status !== 0) {
|
|
228
|
+
console.warn(" ⚠ context7 skill install failed (netresearch marketplace unavailable)");
|
|
229
|
+
console.warn(" Install manually: claude plugin marketplace add netresearch/claude-code-marketplace");
|
|
230
|
+
} else console.log(" ✓ context7 Claude Code skill installed — use /context7 for library research");
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
export { runInit };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { appendFileSync } from "node:fs";
|
|
6
|
+
import { db } from "../state/db.js";
|
|
7
|
+
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";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const AGENTS_DIR = resolve(__dirname, "../agents");
|
|
18
|
+
const CHAIN_EVENTS_PATH = join(FB_DIR, CHAIN_EVENTS_FILE);
|
|
19
|
+
const PREV_ARTIFACT_MAX_CHARS = 4096;
|
|
20
|
+
|
|
21
|
+
interface ChainStep {
|
|
22
|
+
phase: string;
|
|
23
|
+
specialist: string;
|
|
24
|
+
artifactFile: string; // relative to artifactDir
|
|
25
|
+
tools: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const CHAIN_STEPS: ChainStep[] = [
|
|
29
|
+
{
|
|
30
|
+
phase: "requirements",
|
|
31
|
+
specialist: "frappe-ba",
|
|
32
|
+
artifactFile: "planning-artifacts/requirements.md",
|
|
33
|
+
tools: ["Read", "Write", "get_frappe_docs"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
phase: "architecture",
|
|
37
|
+
specialist: "frappe-architect",
|
|
38
|
+
artifactFile: "planning-artifacts/architecture.md",
|
|
39
|
+
tools: ["Read", "Write", "get_frappe_docs"],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
phase: "planning",
|
|
43
|
+
specialist: "frappe-planner",
|
|
44
|
+
artifactFile: "planning-artifacts/plan.md",
|
|
45
|
+
tools: ["Read", "Write", "create_component"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
phase: "implementation",
|
|
49
|
+
specialist: "frappe-dev",
|
|
50
|
+
artifactFile: "implementation-artifacts/sprint-status.yaml",
|
|
51
|
+
tools: ["Read", "Write", "Edit", "Bash", "create_component", "complete_component", "bench_execute"],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
phase: "testing",
|
|
55
|
+
specialist: "frappe-qa",
|
|
56
|
+
artifactFile: "implementation-artifacts/test-report.md",
|
|
57
|
+
tools: ["Read", "Write", "Bash", "bench_execute"],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
phase: "documentation",
|
|
61
|
+
specialist: "frappe-docs",
|
|
62
|
+
artifactFile: "implementation-artifacts/docs.md",
|
|
63
|
+
tools: ["Read", "Write"],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const PHASE_TASKS: Record<string, string> = {
|
|
68
|
+
requirements:
|
|
69
|
+
"Analyse the feature description and produce requirements.md covering DocTypes, roles, process flows, and acceptance criteria.",
|
|
70
|
+
architecture:
|
|
71
|
+
"Design the Frappe-native technical solution based on the requirements above. Produce architecture.md covering DocType schemas, relationships, server logic, and permissions.",
|
|
72
|
+
planning:
|
|
73
|
+
"Break the architecture into ordered implementation components. Produce plan.md, then call create_component for every component listed.",
|
|
74
|
+
implementation:
|
|
75
|
+
"Implement every component from the plan in order. Call complete_component after each one passes tests.",
|
|
76
|
+
testing:
|
|
77
|
+
"Run the full test suite, verify all acceptance criteria, check the permission matrix, and produce test-report.md with Result: PASS.",
|
|
78
|
+
documentation:
|
|
79
|
+
"Document the implemented feature — DocType fields, hooks, docstrings, and changelog entry — and produce docs.md.",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function appendChainEvent(entry: Record<string, unknown>): void {
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(FB_DIR, { recursive: true });
|
|
85
|
+
appendFileSync(CHAIN_EVENTS_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n", "utf-8");
|
|
86
|
+
} catch { /* non-fatal */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readArtifact(artifactDir: string, artifactFile: string): string | null {
|
|
90
|
+
try {
|
|
91
|
+
const content = readFileSync(join(artifactDir, artifactFile), "utf-8");
|
|
92
|
+
return content.slice(0, PREV_ARTIFACT_MAX_CHARS);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function verifyArtifact(artifactDir: string, artifactFile: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
const content = readFileSync(join(artifactDir, artifactFile), "utf-8");
|
|
101
|
+
return content.length >= ARTIFACT_MIN_BYTES;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildTaskPrompt(
|
|
108
|
+
step: ChainStep,
|
|
109
|
+
featureId: string,
|
|
110
|
+
featureName: string,
|
|
111
|
+
artifactDir: string,
|
|
112
|
+
prevArtifact: string | null,
|
|
113
|
+
): string {
|
|
114
|
+
const prevSection = prevArtifact
|
|
115
|
+
? `## Previous Phase Output\n\`\`\`\n${prevArtifact}\n\`\`\`\n\n`
|
|
116
|
+
: "";
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
`Feature: "${featureName}" (ID: ${featureId})`,
|
|
120
|
+
`Artifact directory: ${artifactDir}`,
|
|
121
|
+
"",
|
|
122
|
+
prevSection,
|
|
123
|
+
`## Your Task`,
|
|
124
|
+
PHASE_TASKS[step.phase],
|
|
125
|
+
"",
|
|
126
|
+
`Write your output to: ${join(artifactDir, step.artifactFile)}`,
|
|
127
|
+
`Do not stop until the artifact file exists with substantive content.`,
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function runSubprocess(
|
|
132
|
+
step: ChainStep,
|
|
133
|
+
taskPrompt: string,
|
|
134
|
+
): Promise<{ exitCode: number; stderr: string }> {
|
|
135
|
+
return new Promise((resolvePromise) => {
|
|
136
|
+
const agentFile = join(AGENTS_DIR, `${step.specialist}.md`);
|
|
137
|
+
let systemPrompt = "";
|
|
138
|
+
try {
|
|
139
|
+
systemPrompt = readFileSync(agentFile, "utf-8");
|
|
140
|
+
} catch {
|
|
141
|
+
systemPrompt = `You are ${step.specialist}, a Frappe specialist. ${PHASE_TASKS[step.phase]}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const args = [
|
|
145
|
+
"--mode", "json",
|
|
146
|
+
"-p",
|
|
147
|
+
"--no-extensions",
|
|
148
|
+
"--tools", step.tools.join(","),
|
|
149
|
+
"--append-system-prompt", systemPrompt,
|
|
150
|
+
taskPrompt,
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const proc = spawn("pi", args, {
|
|
154
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
155
|
+
env: { ...process.env },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const stderrChunks: string[] = [];
|
|
159
|
+
proc.stderr?.setEncoding("utf-8");
|
|
160
|
+
proc.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
|
|
161
|
+
|
|
162
|
+
// Hard timeout per step
|
|
163
|
+
const timer = setTimeout(() => proc.kill("SIGTERM"), CHAIN_STEP_TIMEOUT_MS);
|
|
164
|
+
|
|
165
|
+
proc.on("close", (code) => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
resolvePromise({
|
|
168
|
+
exitCode: code ?? 1,
|
|
169
|
+
stderr: stderrChunks.slice(-20).join("").slice(-2000),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function updateDb(featureId: string, phase: string, chainStep: string | null): void {
|
|
176
|
+
try {
|
|
177
|
+
db.transaction(() => {
|
|
178
|
+
db.prepare("UPDATE sessions SET chain_step = ? WHERE is_active = 1").run(chainStep);
|
|
179
|
+
db.prepare("UPDATE features SET current_phase = ? WHERE feature_id = ?").run(phase, featureId);
|
|
180
|
+
})();
|
|
181
|
+
} catch { /* non-fatal — chain continues */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Runs the full agent chain for a feature. Fire-and-forget: called via setImmediate from startFeature.
|
|
186
|
+
* Each phase spawns an isolated pi subprocess with a specialist system prompt.
|
|
187
|
+
* Artifacts are passed between phases via files in artifactDir.
|
|
188
|
+
*/
|
|
189
|
+
export async function spawnChain(
|
|
190
|
+
featureId: string,
|
|
191
|
+
featureName: string,
|
|
192
|
+
artifactDir: string,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
appendChainEvent({ featureId, featureName, status: "chain_started" });
|
|
195
|
+
|
|
196
|
+
let prevArtifact: string | null = null;
|
|
197
|
+
|
|
198
|
+
for (const step of CHAIN_STEPS) {
|
|
199
|
+
// Update state: chain is now on this phase
|
|
200
|
+
updateDb(featureId, step.phase, step.phase);
|
|
201
|
+
appendChainEvent({ featureId, phase: step.phase, status: "started" });
|
|
202
|
+
|
|
203
|
+
// Ensure artifact subdirs exist
|
|
204
|
+
const planningDir = join(artifactDir, PLANNING_ARTIFACTS_DIR);
|
|
205
|
+
const implDir = join(artifactDir, IMPL_ARTIFACTS_DIR);
|
|
206
|
+
mkdirSync(planningDir, { recursive: true });
|
|
207
|
+
mkdirSync(implDir, { recursive: true });
|
|
208
|
+
|
|
209
|
+
const taskPrompt = buildTaskPrompt(step, featureId, featureName, artifactDir, prevArtifact);
|
|
210
|
+
|
|
211
|
+
// Run subprocess — retry once if artifact missing despite exit 0
|
|
212
|
+
let result = await runSubprocess(step, taskPrompt);
|
|
213
|
+
const artifactOk = verifyArtifact(artifactDir, step.artifactFile);
|
|
214
|
+
|
|
215
|
+
if (result.exitCode === 0 && !artifactOk) {
|
|
216
|
+
// Retry with explicit nudge
|
|
217
|
+
const retryPrompt = taskPrompt +
|
|
218
|
+
`\n\nWARNING: Your artifact was not detected at ${join(artifactDir, step.artifactFile)}. ` +
|
|
219
|
+
`You MUST write this file before exiting.`;
|
|
220
|
+
result = await runSubprocess(step, retryPrompt);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (result.exitCode !== 0 || !verifyArtifact(artifactDir, step.artifactFile)) {
|
|
224
|
+
// Chain failed — write error file and update state
|
|
225
|
+
const errorContent = [
|
|
226
|
+
`# Chain Error`,
|
|
227
|
+
`Phase: ${step.phase}`,
|
|
228
|
+
`Exit code: ${result.exitCode}`,
|
|
229
|
+
`Artifact expected: ${join(artifactDir, step.artifactFile)}`,
|
|
230
|
+
`Artifact found: ${verifyArtifact(artifactDir, step.artifactFile)}`,
|
|
231
|
+
``,
|
|
232
|
+
`## Stderr (last 2000 chars)`,
|
|
233
|
+
result.stderr,
|
|
234
|
+
].join("\n");
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
writeFileSync(join(artifactDir, "chain_error.md"), errorContent, "utf-8");
|
|
238
|
+
} catch { /* non-fatal */ }
|
|
239
|
+
|
|
240
|
+
updateDb(featureId, "chain_failed", null);
|
|
241
|
+
appendChainEvent({ featureId, phase: step.phase, status: "failed", exitCode: result.exitCode });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
appendChainEvent({ featureId, phase: step.phase, status: "complete" });
|
|
246
|
+
|
|
247
|
+
// Pass this phase's artifact to the next phase
|
|
248
|
+
prevArtifact = readArtifact(artifactDir, step.artifactFile);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// All steps complete
|
|
252
|
+
updateDb(featureId, "done", null);
|
|
253
|
+
appendChainEvent({ featureId, status: "chain_complete" });
|
|
254
|
+
}
|