frappe-builder 1.1.0-dev.11 → 1.1.0-dev.13

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 CHANGED
Binary file
@@ -0,0 +1,15 @@
1
+ feature_id: po-approval
2
+ feature_name: "PO Approval"
3
+ mode: full
4
+ phase: testing
5
+ updated_at: 2026-03-28T07:56:22.695Z
6
+
7
+ components:
8
+ - id: final-comp
9
+ sort_order: 0
10
+ status: complete
11
+ completed_at: 2026-03-28T07:56:22.695Z
12
+
13
+ progress:
14
+ done: 1
15
+ total: 1
@@ -5,6 +5,11 @@ import { appendEntry } from "../state/journal.js";
5
5
  import { buildStateContext, loadSpecialist, loadDebuggerSpecialist } from "./frappe-session.js";
6
6
  import { loadCredentials } from "../config/loader.js";
7
7
 
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ export default function (pi: any) {
10
+ pi.on("after_tool_call", handleAfterToolCall);
11
+ }
12
+
8
13
  /** Reads current_phase and feature_id from the active session. */
9
14
  function readSessionState(): { currentPhase: string; activeFeatureId: string | null } {
10
15
  const row = db
@@ -24,6 +29,29 @@ function getActiveSessionId(): string {
24
29
  return row?.session_id ?? "unknown";
25
30
  }
26
31
 
32
+ /** Type guard for createComponent() return value. */
33
+ function isCreateComponentResult(value: unknown): value is { feature_id: string; component_id: string; artifact_dir: string } {
34
+ return (
35
+ typeof value === "object" &&
36
+ value !== null &&
37
+ "feature_id" in value &&
38
+ "component_id" in value &&
39
+ "artifact_dir" in value
40
+ );
41
+ }
42
+
43
+ /** Type guard for completeComponent() success return value. */
44
+ function isCompleteComponentResult(value: unknown): value is { feature_id: string; component_id: string; status: string } {
45
+ return (
46
+ typeof value === "object" &&
47
+ value !== null &&
48
+ "feature_id" in value &&
49
+ "component_id" in value &&
50
+ "status" in value &&
51
+ (value as Record<string, unknown>).status === "complete"
52
+ );
53
+ }
54
+
27
55
  /** Type guard for SpawnAgentResult from agent-tools.ts. */
28
56
  function isSpawnAgentResult(value: unknown): value is { status: string; skill: string; trigger: string } {
29
57
  return (
@@ -199,6 +227,51 @@ export async function handleAfterToolCall(
199
227
  }
200
228
  }
201
229
 
230
+ // create_component JSONL logging — no phase transition, no specialist change
231
+ if (toolName === "create_component") {
232
+ const firstText = context.result.content.find((c) => c.type === "text") as TextContent | undefined;
233
+ if (firstText) {
234
+ let parsed: unknown = null;
235
+ try { parsed = JSON.parse(firstText.text); } catch { /* skip */ }
236
+ if (isCreateComponentResult(parsed)) {
237
+ appendEntry({
238
+ ts: new Date().toISOString(),
239
+ sessionId,
240
+ type: "tool_call",
241
+ payload: { tool: "create_component", feature: parsed.feature_id, component: parsed.component_id },
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ // complete_component — check if all components done and inject completion hint
248
+ if (toolName === "complete_component") {
249
+ const firstText = context.result.content.find((c) => c.type === "text") as TextContent | undefined;
250
+ if (firstText) {
251
+ let parsed: unknown = null;
252
+ try { parsed = JSON.parse(firstText.text); } catch { /* skip */ }
253
+ if (isCompleteComponentResult(parsed)) {
254
+ appendEntry({
255
+ ts: new Date().toISOString(),
256
+ sessionId,
257
+ type: "tool_call",
258
+ payload: { tool: "complete_component", feature: parsed.feature_id, component: parsed.component_id },
259
+ });
260
+ // Check if all components for this feature are now complete
261
+ const feature = db
262
+ .prepare("SELECT progress_done, progress_total FROM features WHERE feature_id = ?")
263
+ .get(parsed.feature_id) as { progress_done: number; progress_total: number } | undefined;
264
+ if (feature && feature.progress_total > 0 && feature.progress_done >= feature.progress_total) {
265
+ const hint: TextContent = {
266
+ type: "text",
267
+ text: `\n\n✓ All ${feature.progress_total} components complete for this feature. Ready to advance to the next phase.`,
268
+ };
269
+ return { content: [...context.result.content, hint] };
270
+ }
271
+ }
272
+ }
273
+ }
274
+
202
275
  // End debug — restore previous phase specialist (Story 3.5) — no phase transition
203
276
  if (toolName === "end_debug") {
204
277
  const { currentPhase, activeFeatureId } = readSessionState();
@@ -4,45 +4,29 @@ import { db, getCurrentPhase } from "../state/db.js";
4
4
  interface FooterState {
5
5
  projectId: string | null;
6
6
  featureName: string | null;
7
- phase: string;
8
- tokenCount: number;
7
+ componentId: string | null;
9
8
  }
10
9
 
11
10
  function readFooterState(): FooterState {
12
11
  try {
13
12
  const session = db
14
13
  .prepare(
15
- `SELECT s.project_id, f.name AS feature_name, s.current_phase
14
+ `SELECT s.project_id, f.name AS feature_name, s.component_id
16
15
  FROM sessions s
17
16
  LEFT JOIN features f ON f.feature_id = s.feature_id
18
17
  WHERE s.is_active = 1 LIMIT 1`
19
18
  )
20
19
  .get() as
21
- | { project_id: string | null; feature_name: string | null; current_phase: string }
20
+ | { project_id: string | null; feature_name: string | null; component_id: string | null }
22
21
  | undefined;
23
22
 
24
- // Token count: count journal_entries for active session; table may not exist yet
25
- let tokenCount = 0;
26
- try {
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
- }
37
-
38
23
  return {
39
24
  projectId: session?.project_id ?? null,
40
25
  featureName: session?.feature_name ?? null,
41
- phase: session?.current_phase ?? "idle",
42
- tokenCount,
26
+ componentId: session?.component_id ?? null,
43
27
  };
44
28
  } catch {
45
- return { projectId: null, featureName: null, phase: "idle", tokenCount: 0 };
29
+ return { projectId: null, featureName: null, componentId: null };
46
30
  }
47
31
  }
48
32
 
@@ -52,12 +36,15 @@ function readFooterState(): FooterState {
52
36
  */
53
37
  export function formatFooter(state: FooterState): string {
54
38
  if (!state.projectId) {
55
- return `No active project | Phase: ${state.phase}`;
39
+ return `No active project`;
56
40
  }
57
41
  if (!state.featureName) {
58
- return `Project: ${state.projectId} | No active feature | Phase: ${state.phase}`;
42
+ return `Project: ${state.projectId} | No active feature`;
59
43
  }
60
- return `Project: ${state.projectId} | Feature: ${state.featureName} | Phase: ${state.phase} | Tokens: ${state.tokenCount}`;
44
+ if (!state.componentId) {
45
+ return `Project: ${state.projectId} | Feature: ${state.featureName}`;
46
+ }
47
+ return `Project: ${state.projectId} | Feature: ${state.featureName} | Component: ${state.componentId}`;
61
48
  }
62
49
 
63
50
  function renderFooter(): void {
@@ -104,16 +91,35 @@ export async function handleAfterToolCall(
104
91
 
105
92
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
93
  export default function (pi: any) {
107
- pi.on("session_start", () => {
108
- renderFooter();
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ pi.on("session_start", (_event: any, ctx: any) => {
96
+ try {
97
+ ctx.ui.setStatus("frappe-builder", formatFooter(readFooterState()));
98
+ } catch {
99
+ renderFooter();
100
+ }
109
101
  });
110
102
 
111
103
  // Announcement hook — fires before FSM guard in frappe-workflow.ts (FR25)
112
- pi.on("tool_call", (event: { toolName?: string }) => {
113
- return handleBeforeToolCall(event.toolName ?? "");
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ pi.on("tool_call", (event: any, ctx: any) => {
106
+ try {
107
+ const phase = getCurrentPhase();
108
+ ctx.ui.setStatus("frappe-builder", `→ Calling ${event.toolName ?? ""} [${phase}]`);
109
+ } catch {
110
+ handleBeforeToolCall(event.toolName ?? "");
111
+ }
114
112
  });
115
113
 
116
- pi.on("after_tool_call", async (context: AfterToolCallContext) => {
117
- return await handleAfterToolCall(context);
114
+ // "tool_result" is the pi-coding-agent ExtensionAPI event name for after-tool hooks.
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).
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
+ pi.on("tool_result", (_event: any, ctx: any) => {
119
+ try {
120
+ ctx.ui.setStatus("frappe-builder", formatFooter(readFooterState()));
121
+ } catch {
122
+ renderFooter();
123
+ }
118
124
  });
119
125
  }
@@ -6,21 +6,22 @@ interface BlockedResponse {
6
6
  blocked: true;
7
7
  tool: string;
8
8
  current_phase: Phase;
9
- valid_phase: Phase | "any";
9
+ valid_phase: string;
10
10
  message: string;
11
11
  }
12
12
 
13
13
  function buildBlockedResponse(
14
14
  tool: string,
15
15
  currentPhase: Phase,
16
- validPhase: Phase | "any"
16
+ validPhase: Phase | Phase[] | "any"
17
17
  ): BlockedResponse {
18
+ const validLabel = Array.isArray(validPhase) ? validPhase.join(", ") : validPhase;
18
19
  return {
19
20
  blocked: true,
20
21
  tool,
21
22
  current_phase: currentPhase,
22
- valid_phase: validPhase,
23
- message: `${tool} is not available in ${currentPhase} phase. Available in: ${validPhase}`,
23
+ valid_phase: validLabel,
24
+ message: `${tool} is not available in ${currentPhase} phase. Available in: ${validLabel}`,
24
25
  };
25
26
  }
26
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-builder",
3
- "version": "1.1.0-dev.11",
3
+ "version": "1.1.0-dev.13",
4
4
  "description": "Frappe-native AI co-pilot for building and customising Frappe/ERPNext applications",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,76 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { db } from "./db.js";
4
+
5
+ /** Returns the artifact directory for a feature, rooted in the current working directory. */
6
+ export function getArtifactDir(featureId: string): string {
7
+ return join(process.cwd(), ".frappe-builder-artifacts", featureId);
8
+ }
9
+
10
+ /**
11
+ * Regenerates sprint-status.yaml for the given feature from current DB state.
12
+ * Called after every create_component and complete_component.
13
+ * Creates the artifact directory if it does not exist.
14
+ * Non-fatal — caller should catch any errors.
15
+ */
16
+ export function regenerateSprintStatus(featureId: string): void {
17
+ const feature = db
18
+ .prepare("SELECT feature_id, name, mode, current_phase FROM features WHERE feature_id = ?")
19
+ .get(featureId) as
20
+ | { feature_id: string; name: string; mode: string; current_phase: string }
21
+ | undefined;
22
+
23
+ if (!feature) return;
24
+
25
+ const components = db
26
+ .prepare(
27
+ `SELECT component_id, description, sort_order, status, completed_at
28
+ FROM components WHERE feature_id = ?
29
+ ORDER BY sort_order ASC, component_id ASC`
30
+ )
31
+ .all(featureId) as Array<{
32
+ component_id: string;
33
+ description: string | null;
34
+ sort_order: number;
35
+ status: string;
36
+ completed_at: string | null;
37
+ }>;
38
+
39
+ const done = components.filter((c) => c.status === "complete").length;
40
+ const total = components.length;
41
+
42
+ const componentLines = components
43
+ .map((c) => {
44
+ const descLine = c.description
45
+ ? ` description: "${c.description.replace(/"/g, '\\"')}"`
46
+ : "";
47
+ return [
48
+ ` - id: ${c.component_id}`,
49
+ descLine,
50
+ ` sort_order: ${c.sort_order}`,
51
+ ` status: ${c.status}`,
52
+ ` completed_at: ${c.completed_at ?? "null"}`,
53
+ ]
54
+ .filter(Boolean)
55
+ .join("\n");
56
+ })
57
+ .join("\n");
58
+
59
+ const yaml = `feature_id: ${feature.feature_id}
60
+ feature_name: "${feature.name.replace(/"/g, '\\"')}"
61
+ mode: ${feature.mode}
62
+ phase: ${feature.current_phase}
63
+ updated_at: ${new Date().toISOString()}
64
+
65
+ components:
66
+ ${componentLines || " []"}
67
+
68
+ progress:
69
+ done: ${done}
70
+ total: ${total}
71
+ `;
72
+
73
+ const artifactDir = getArtifactDir(featureId);
74
+ mkdirSync(artifactDir, { recursive: true });
75
+ writeFileSync(join(artifactDir, "sprint-status.yaml"), yaml, "utf-8");
76
+ }
package/state/db.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import Database from "better-sqlite3";
2
2
  import type { Database as DatabaseType } from "better-sqlite3";
3
3
  import { mkdirSync } from "node:fs";
4
- import { initSchema } from "./schema.js";
4
+ import { initSchema, migrateSchema } from "./schema.js";
5
5
  import { appendEntry } from "./journal.js";
6
6
 
7
7
  mkdirSync(".fb", { recursive: true });
@@ -10,6 +10,7 @@ mkdirSync(".fb", { recursive: true });
10
10
  export let db: DatabaseType = new Database(".fb/state.db");
11
11
 
12
12
  initSchema(db);
13
+ migrateSchema(db);
13
14
 
14
15
  /**
15
16
  * Replaces the db singleton — intended for test use only.
package/state/fsm.ts CHANGED
@@ -43,9 +43,10 @@ export function createFsmAtPhase(phase: Phase) {
43
43
  * Maps each tool to its valid phase (or 'any').
44
44
  * Tools not listed here are allowed in any phase — do not block unregistered tools.
45
45
  */
46
- const TOOL_PHASE_MAP: Record<string, Phase | "any"> = {
46
+ const TOOL_PHASE_MAP: Record<string, Phase | Phase[] | "any"> = {
47
47
  set_active_project: "any",
48
48
  start_feature: "idle",
49
+ create_component: ["planning", "implementation"],
49
50
  complete_component: "implementation",
50
51
  scaffold_doctype: "implementation",
51
52
  run_tests: "testing",
@@ -59,10 +60,11 @@ export function isToolAllowedInPhase(toolName: string, phase: Phase): boolean {
59
60
  const validPhase = TOOL_PHASE_MAP[toolName];
60
61
  if (validPhase === undefined) return true; // unknown tool — do not block
61
62
  if (validPhase === "any") return true;
63
+ if (Array.isArray(validPhase)) return validPhase.includes(phase);
62
64
  return validPhase === phase;
63
65
  }
64
66
 
65
67
  /** Returns the phase where toolName is valid, or 'any' for unrestricted/unknown tools. */
66
- export function getValidPhase(toolName: string): Phase | "any" {
68
+ export function getValidPhase(toolName: string): Phase | Phase[] | "any" {
67
69
  return TOOL_PHASE_MAP[toolName] ?? "any";
68
70
  }
package/state/schema.ts CHANGED
@@ -18,9 +18,13 @@ export function initSchema(db: Database): void {
18
18
  );
19
19
 
20
20
  CREATE TABLE IF NOT EXISTS components (
21
- component_id TEXT NOT NULL,
22
- feature_id TEXT NOT NULL,
23
- status TEXT NOT NULL DEFAULT 'in-progress',
21
+ component_id TEXT NOT NULL,
22
+ feature_id TEXT NOT NULL,
23
+ status TEXT NOT NULL DEFAULT 'in-progress'
24
+ CHECK (status IN ('in-progress', 'complete')),
25
+ description TEXT,
26
+ sort_order INTEGER NOT NULL DEFAULT 0,
27
+ created_at TEXT,
24
28
  completed_at TEXT,
25
29
  PRIMARY KEY (feature_id, component_id),
26
30
  FOREIGN KEY (feature_id) REFERENCES features(feature_id)
@@ -32,6 +36,7 @@ export function initSchema(db: Database): void {
32
36
  current_phase TEXT NOT NULL DEFAULT 'idle',
33
37
  site_path TEXT,
34
38
  feature_id TEXT,
39
+ component_id TEXT,
35
40
  last_tool TEXT,
36
41
  started_at TEXT NOT NULL,
37
42
  ended_at TEXT,
@@ -39,3 +44,20 @@ export function initSchema(db: Database): void {
39
44
  );
40
45
  `);
41
46
  }
47
+
48
+ /**
49
+ * Adds new columns to existing databases without dropping data.
50
+ * Safe to call on any DB version — ignores "duplicate column" errors.
51
+ * Call this after initSchema() in db.ts.
52
+ */
53
+ export function migrateSchema(db: Database): void {
54
+ const alters = [
55
+ "ALTER TABLE components ADD COLUMN description TEXT",
56
+ "ALTER TABLE components ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
57
+ "ALTER TABLE components ADD COLUMN created_at TEXT",
58
+ "ALTER TABLE sessions ADD COLUMN component_id TEXT",
59
+ ];
60
+ for (const sql of alters) {
61
+ try { db.exec(sql); } catch { /* column already exists — safe to ignore */ }
62
+ }
63
+ }
@@ -3,6 +3,7 @@ import { db } from "../state/db.js";
3
3
  import { appendEntry } from "../state/journal.js";
4
4
  import { loadConfig } from "../config/loader.js";
5
5
  import { coverageCheck } from "../gates/coverage-check.js";
6
+ import { getArtifactDir, regenerateSprintStatus } from "../state/artifacts.js";
6
7
 
7
8
  interface StartFeatureArgs {
8
9
  name: string;
@@ -40,6 +41,72 @@ export function startFeature({ name, mode = "full" }: StartFeatureArgs) {
40
41
  return { feature_id: featureId, name, mode, current_phase: "idle", created_at: createdAt };
41
42
  }
42
43
 
44
+ interface CreateComponentArgs {
45
+ featureId: string;
46
+ componentId: string;
47
+ description?: string;
48
+ sortOrder?: number;
49
+ }
50
+
51
+ /**
52
+ * Explicitly creates a component for a feature with status 'in-progress'.
53
+ * Sets this component as the active component on the session (for footer display).
54
+ * Regenerates sprint-status.yaml in .frappe-builder-artifacts/{featureId}/.
55
+ *
56
+ * Call this during the planning phase (full mode) or at the start of implementation
57
+ * (quick mode) — before work begins, not at completion time.
58
+ */
59
+ export function createComponent({ featureId, componentId, description, sortOrder = 0 }: CreateComponentArgs) {
60
+ const createdAt = new Date().toISOString();
61
+
62
+ const feature = db
63
+ .prepare("SELECT feature_id FROM features WHERE feature_id = ?")
64
+ .get(featureId) as { feature_id: string } | undefined;
65
+
66
+ if (!feature) {
67
+ return { error: `Feature '${featureId}' not found. Call start_feature first.` };
68
+ }
69
+
70
+ const session = db
71
+ .prepare("SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1")
72
+ .get() as { session_id: string } | undefined;
73
+
74
+ const existing = db
75
+ .prepare("SELECT component_id FROM components WHERE feature_id = ? AND component_id = ?")
76
+ .get(featureId, componentId) as { component_id: string } | undefined;
77
+
78
+ db.prepare(`
79
+ INSERT INTO components (feature_id, component_id, status, description, sort_order, created_at)
80
+ VALUES (?, ?, 'in-progress', ?, ?, ?)
81
+ ON CONFLICT(feature_id, component_id)
82
+ DO UPDATE SET description = excluded.description, sort_order = excluded.sort_order
83
+ `).run(featureId, componentId, description ?? null, sortOrder, createdAt);
84
+
85
+ if (!existing) {
86
+ db.prepare("UPDATE features SET progress_total = progress_total + 1 WHERE feature_id = ?")
87
+ .run(featureId);
88
+ }
89
+
90
+ db.prepare("UPDATE sessions SET component_id = ? WHERE is_active = 1")
91
+ .run(componentId);
92
+
93
+ regenerateSprintStatus(featureId);
94
+
95
+ appendEntry({
96
+ ts: createdAt,
97
+ sessionId: session?.session_id ?? "unknown",
98
+ type: "state_transition",
99
+ payload: { feature: featureId, component: componentId, action: "create_component", status: "in-progress" },
100
+ });
101
+
102
+ return {
103
+ feature_id: featureId,
104
+ component_id: componentId,
105
+ status: "in-progress",
106
+ artifact_dir: getArtifactDir(featureId),
107
+ };
108
+ }
109
+
43
110
  interface CompleteComponentArgs {
44
111
  featureId: string;
45
112
  componentId: string;
@@ -105,6 +172,10 @@ export async function completeComponent({ featureId, componentId }: CompleteComp
105
172
  DO UPDATE SET status = 'complete', completed_at = excluded.completed_at
106
173
  `).run(featureId, componentId, completedAt);
107
174
 
175
+ db.prepare("UPDATE sessions SET component_id = NULL WHERE is_active = 1").run();
176
+ db.prepare("UPDATE features SET progress_done = progress_done + 1 WHERE feature_id = ?").run(featureId);
177
+ regenerateSprintStatus(featureId);
178
+
108
179
  appendEntry({
109
180
  ts: completedAt,
110
181
  sessionId,