frappe-builder 1.1.0-dev.12 → 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/po-approval/implementation-artifacts/sprint-status.yaml +15 -0
- 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 +106 -7
- package/extensions/frappe-ui.ts +103 -38
- package/extensions/frappe-workflow.ts +88 -9
- package/package.json +1 -1
- package/state/artifacts.ts +85 -0
- package/state/db.ts +6 -4
- package/state/fsm.ts +35 -12
- package/state/schema.ts +31 -3
- package/tools/feature-tools.ts +125 -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. */
|
|
@@ -29,6 +51,29 @@ function getActiveSessionId(): string {
|
|
|
29
51
|
return row?.session_id ?? "unknown";
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
/** Type guard for createComponent() return value. */
|
|
55
|
+
function isCreateComponentResult(value: unknown): value is { feature_id: string; component_id: string; artifact_dir: string } {
|
|
56
|
+
return (
|
|
57
|
+
typeof value === "object" &&
|
|
58
|
+
value !== null &&
|
|
59
|
+
"feature_id" in value &&
|
|
60
|
+
"component_id" in value &&
|
|
61
|
+
"artifact_dir" in value
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Type guard for completeComponent() success return value. */
|
|
66
|
+
function isCompleteComponentResult(value: unknown): value is { feature_id: string; component_id: string; status: string } {
|
|
67
|
+
return (
|
|
68
|
+
typeof value === "object" &&
|
|
69
|
+
value !== null &&
|
|
70
|
+
"feature_id" in value &&
|
|
71
|
+
"component_id" in value &&
|
|
72
|
+
"status" in value &&
|
|
73
|
+
(value as Record<string, unknown>).status === "complete"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
32
77
|
/** Type guard for SpawnAgentResult from agent-tools.ts. */
|
|
33
78
|
function isSpawnAgentResult(value: unknown): value is { status: string; skill: string; trigger: string } {
|
|
34
79
|
return (
|
|
@@ -41,14 +86,16 @@ function isSpawnAgentResult(value: unknown): value is { status: string; skill: s
|
|
|
41
86
|
}
|
|
42
87
|
|
|
43
88
|
/** Type guard for the return value of startFeature(). */
|
|
44
|
-
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 } {
|
|
45
90
|
return (
|
|
46
91
|
typeof value === "object" &&
|
|
47
92
|
value !== null &&
|
|
48
93
|
"mode" in value &&
|
|
49
94
|
"feature_id" in value &&
|
|
95
|
+
"current_phase" in value &&
|
|
50
96
|
typeof (value as Record<string, unknown>).mode === "string" &&
|
|
51
|
-
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"
|
|
52
99
|
);
|
|
53
100
|
}
|
|
54
101
|
|
|
@@ -131,13 +178,21 @@ export async function handleAfterToolCall(
|
|
|
131
178
|
// non-JSON result — skip phase transition silently
|
|
132
179
|
}
|
|
133
180
|
if (isStartFeatureResult(parsed)) {
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
191
|
|
|
137
|
-
//
|
|
192
|
+
// Quick mode: specialist auto-load after phase transition (Story 3.4) — non-fatal
|
|
138
193
|
try {
|
|
139
194
|
const specCtx = buildStateContext(parsed.feature_id, toolName);
|
|
140
|
-
const specialistBlock = loadSpecialist(
|
|
195
|
+
const specialistBlock = loadSpecialist(parsed.current_phase, specCtx);
|
|
141
196
|
if (specialistBlock) {
|
|
142
197
|
const specialistContent: TextContent = {
|
|
143
198
|
type: "text",
|
|
@@ -147,7 +202,6 @@ export async function handleAfterToolCall(
|
|
|
147
202
|
}
|
|
148
203
|
} catch (specialistErr) {
|
|
149
204
|
console.error(`[frappe-state] specialist load failed: ${specialistErr}`);
|
|
150
|
-
// non-fatal — session continues without specialist block
|
|
151
205
|
}
|
|
152
206
|
}
|
|
153
207
|
}
|
|
@@ -204,6 +258,51 @@ export async function handleAfterToolCall(
|
|
|
204
258
|
}
|
|
205
259
|
}
|
|
206
260
|
|
|
261
|
+
// create_component JSONL logging — no phase transition, no specialist change
|
|
262
|
+
if (toolName === "create_component") {
|
|
263
|
+
const firstText = context.result.content.find((c) => c.type === "text") as TextContent | undefined;
|
|
264
|
+
if (firstText) {
|
|
265
|
+
let parsed: unknown = null;
|
|
266
|
+
try { parsed = JSON.parse(firstText.text); } catch { /* skip */ }
|
|
267
|
+
if (isCreateComponentResult(parsed)) {
|
|
268
|
+
appendEntry({
|
|
269
|
+
ts: new Date().toISOString(),
|
|
270
|
+
sessionId,
|
|
271
|
+
type: "tool_call",
|
|
272
|
+
payload: { tool: "create_component", feature: parsed.feature_id, component: parsed.component_id },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// complete_component — check if all components done and inject completion hint
|
|
279
|
+
if (toolName === "complete_component") {
|
|
280
|
+
const firstText = context.result.content.find((c) => c.type === "text") as TextContent | undefined;
|
|
281
|
+
if (firstText) {
|
|
282
|
+
let parsed: unknown = null;
|
|
283
|
+
try { parsed = JSON.parse(firstText.text); } catch { /* skip */ }
|
|
284
|
+
if (isCompleteComponentResult(parsed)) {
|
|
285
|
+
appendEntry({
|
|
286
|
+
ts: new Date().toISOString(),
|
|
287
|
+
sessionId,
|
|
288
|
+
type: "tool_call",
|
|
289
|
+
payload: { tool: "complete_component", feature: parsed.feature_id, component: parsed.component_id },
|
|
290
|
+
});
|
|
291
|
+
// Check if all components for this feature are now complete
|
|
292
|
+
const feature = db
|
|
293
|
+
.prepare("SELECT progress_done, progress_total FROM features WHERE feature_id = ?")
|
|
294
|
+
.get(parsed.feature_id) as { progress_done: number; progress_total: number } | undefined;
|
|
295
|
+
if (feature && feature.progress_total > 0 && feature.progress_done >= feature.progress_total) {
|
|
296
|
+
const hint: TextContent = {
|
|
297
|
+
type: "text",
|
|
298
|
+
text: `\n\n✓ All ${feature.progress_total} components complete for this feature. Ready to advance to the next phase.`,
|
|
299
|
+
};
|
|
300
|
+
return { content: [...context.result.content, hint] };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
207
306
|
// End debug — restore previous phase specialist (Story 3.5) — no phase transition
|
|
208
307
|
if (toolName === "end_debug") {
|
|
209
308
|
const { currentPhase, activeFeatureId } = readSessionState();
|
package/extensions/frappe-ui.ts
CHANGED
|
@@ -1,72 +1,126 @@
|
|
|
1
1
|
import type { AfterToolCallContext, AfterToolCallResult } from "@mariozechner/pi-agent-core";
|
|
2
2
|
import { db, getCurrentPhase } from "../state/db.js";
|
|
3
|
+
import { loadConfig } from "../config/loader.js";
|
|
4
|
+
import { DEFAULT_PERMISSION_MODE } from "../config/defaults.js";
|
|
3
5
|
|
|
4
|
-
interface
|
|
6
|
+
interface DashboardState {
|
|
5
7
|
projectId: string | null;
|
|
6
8
|
featureName: string | null;
|
|
9
|
+
featureMode: string | null;
|
|
10
|
+
componentId: string | null;
|
|
7
11
|
phase: string;
|
|
8
|
-
|
|
12
|
+
chainStep: string | null;
|
|
13
|
+
progressDone: number;
|
|
14
|
+
progressTotal: number;
|
|
15
|
+
permissionMode: string;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
// Keep FooterState alias so existing tests compile without changes.
|
|
19
|
+
type FooterState = Pick<DashboardState, "projectId" | "featureName" | "componentId">;
|
|
20
|
+
|
|
21
|
+
function readDashboardState(): DashboardState {
|
|
12
22
|
try {
|
|
13
23
|
const session = db
|
|
14
24
|
.prepare(
|
|
15
|
-
`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
|
|
16
28
|
FROM sessions s
|
|
17
29
|
LEFT JOIN features f ON f.feature_id = s.feature_id
|
|
18
30
|
WHERE s.is_active = 1 LIMIT 1`
|
|
19
31
|
)
|
|
20
32
|
.get() as
|
|
21
|
-
| {
|
|
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
|
+
}
|
|
22
43
|
| undefined;
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const tokenRow = db
|
|
28
|
-
.prepare(
|
|
29
|
-
`SELECT COUNT(*) as count FROM journal_entries
|
|
30
|
-
WHERE session_id = (SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1)`
|
|
31
|
-
)
|
|
32
|
-
.get() as { count: number } | undefined;
|
|
33
|
-
tokenCount = tokenRow?.count ?? 0;
|
|
34
|
-
} catch {
|
|
35
|
-
// journal_entries table not yet created — default to 0
|
|
36
|
-
}
|
|
45
|
+
const permissionMode = (() => {
|
|
46
|
+
try { return loadConfig().permissionMode ?? DEFAULT_PERMISSION_MODE; } catch { return DEFAULT_PERMISSION_MODE; }
|
|
47
|
+
})();
|
|
37
48
|
|
|
38
49
|
return {
|
|
39
50
|
projectId: session?.project_id ?? null,
|
|
40
51
|
featureName: session?.feature_name ?? null,
|
|
52
|
+
featureMode: session?.feature_mode ?? null,
|
|
53
|
+
componentId: session?.component_id ?? null,
|
|
41
54
|
phase: session?.current_phase ?? "idle",
|
|
42
|
-
|
|
55
|
+
chainStep: session?.chain_step ?? null,
|
|
56
|
+
progressDone: session?.progress_done ?? 0,
|
|
57
|
+
progressTotal: session?.progress_total ?? 0,
|
|
58
|
+
permissionMode,
|
|
43
59
|
};
|
|
44
60
|
} catch {
|
|
45
|
-
return { projectId: null, featureName: null, phase: "idle",
|
|
61
|
+
return { projectId: null, featureName: null, featureMode: null, componentId: null, phase: "idle", chainStep: null, progressDone: 0, progressTotal: 0, permissionMode: DEFAULT_PERMISSION_MODE };
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
/**
|
|
50
|
-
*
|
|
66
|
+
* Formats dashboard state as a string[] for ctx.ui.setWidget().
|
|
51
67
|
* Exported for unit testing.
|
|
52
68
|
*/
|
|
53
|
-
export function
|
|
69
|
+
export function formatDashboard(state: DashboardState): string[] {
|
|
54
70
|
if (!state.projectId) {
|
|
55
|
-
return
|
|
71
|
+
return ["frappe-builder | No active project"];
|
|
56
72
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (state.componentId) {
|
|
84
|
+
parts.push(`Component: ${state.componentId}`);
|
|
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`);
|
|
59
95
|
}
|
|
60
|
-
|
|
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}`;
|
|
110
|
+
return `Project: ${state.projectId} | Feature: ${state.featureName} | Component: ${state.componentId}`;
|
|
61
111
|
}
|
|
62
112
|
|
|
63
|
-
function
|
|
113
|
+
function renderWidget(ctx: unknown): void {
|
|
64
114
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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" });
|
|
68
118
|
} catch {
|
|
69
|
-
//
|
|
119
|
+
// Fallback: write to stderr if widget API unavailable
|
|
120
|
+
try {
|
|
121
|
+
const state = readDashboardState();
|
|
122
|
+
process.stderr.write(`\r\x1b[2K[frappe-builder] ${formatDashboard(state).join(" ")}\n`);
|
|
123
|
+
} catch { /* non-critical */ }
|
|
70
124
|
}
|
|
71
125
|
}
|
|
72
126
|
|
|
@@ -98,22 +152,33 @@ export async function handleAfterToolCall(
|
|
|
98
152
|
_context: AfterToolCallContext,
|
|
99
153
|
_signal?: AbortSignal
|
|
100
154
|
): Promise<AfterToolCallResult | undefined> {
|
|
101
|
-
renderFooter();
|
|
102
155
|
return undefined;
|
|
103
156
|
}
|
|
104
157
|
|
|
105
158
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
106
159
|
export default function (pi: any) {
|
|
107
|
-
|
|
108
|
-
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
161
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
162
|
+
renderWidget(ctx);
|
|
109
163
|
});
|
|
110
164
|
|
|
111
165
|
// Announcement hook — fires before FSM guard in frappe-workflow.ts (FR25)
|
|
112
|
-
|
|
113
|
-
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
167
|
+
pi.on("tool_call", (event: any, ctx: any) => {
|
|
168
|
+
try {
|
|
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" });
|
|
174
|
+
} catch {
|
|
175
|
+
handleBeforeToolCall(event.toolName ?? "");
|
|
176
|
+
}
|
|
114
177
|
});
|
|
115
178
|
|
|
116
|
-
|
|
117
|
-
|
|
179
|
+
// "tool_result" fires after each tool completes — update the persistent widget.
|
|
180
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
181
|
+
pi.on("tool_result", (_event: any, ctx: any) => {
|
|
182
|
+
renderWidget(ctx);
|
|
118
183
|
});
|
|
119
184
|
}
|