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