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.
@@ -2,9 +2,17 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { db } from "./db.js";
4
4
 
5
- /** Returns the artifact directory for a feature, rooted in the current working directory. */
5
+ /**
6
+ * Returns the artifact directory for a feature.
7
+ * When app_path is set: {app_path}/.frappe-builder/{featureId}/
8
+ * Fallback (no app_path): {cwd}/.frappe-builder/{featureId}/
9
+ */
6
10
  export function getArtifactDir(featureId: string): string {
7
- return join(process.cwd(), ".frappe-builder-artifacts", featureId);
11
+ const row = db
12
+ .prepare("SELECT app_path FROM sessions WHERE is_active = 1 LIMIT 1")
13
+ .get() as { app_path: string | null } | undefined;
14
+ const root = row?.app_path ?? process.cwd();
15
+ return join(root, ".frappe-builder", featureId);
8
16
  }
9
17
 
10
18
  /**
@@ -71,6 +79,7 @@ progress:
71
79
  `;
72
80
 
73
81
  const artifactDir = getArtifactDir(featureId);
74
- mkdirSync(artifactDir, { recursive: true });
75
- writeFileSync(join(artifactDir, "sprint-status.yaml"), yaml, "utf-8");
82
+ const implDir = join(artifactDir, "implementation-artifacts");
83
+ mkdirSync(implDir, { recursive: true });
84
+ writeFileSync(join(implDir, "sprint-status.yaml"), yaml, "utf-8");
76
85
  }
package/state/db.ts CHANGED
@@ -25,9 +25,9 @@ export function setDb(instance: DatabaseType): void {
25
25
  * a new session for newProjectId, restoring its last known phase if a prior
26
26
  * session exists. Writes a "state_transition" JSONL entry before the close.
27
27
  *
28
- * sitePath is optional so existing callers without a site path continue to work.
28
+ * sitePath and appPath are optional so existing callers without them continue to work.
29
29
  */
30
- export function switchProject(newProjectId: string, sitePath?: string): void {
30
+ export function switchProject(newProjectId: string, sitePath?: string, appPath?: string): void {
31
31
  db.transaction(() => {
32
32
  // 1. Read current active session before closing
33
33
  const current = db
@@ -63,12 +63,13 @@ export function switchProject(newProjectId: string, sitePath?: string): void {
63
63
 
64
64
  // 5. Create new session, restoring prior phase if available; feature_id defaults to NULL
65
65
  db.prepare(
66
- "INSERT INTO sessions (session_id, project_id, current_phase, site_path, started_at, is_active) VALUES (?, ?, ?, ?, ?, 1)"
66
+ "INSERT INTO sessions (session_id, project_id, current_phase, site_path, app_path, started_at, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)"
67
67
  ).run(
68
68
  crypto.randomUUID(),
69
69
  newProjectId,
70
70
  prior?.current_phase ?? "idle",
71
71
  sitePath ?? null,
72
+ appPath ?? null,
72
73
  new Date().toISOString()
73
74
  );
74
75
  })();
package/state/fsm.ts CHANGED
@@ -7,7 +7,8 @@ export type Phase =
7
7
  | "planning"
8
8
  | "implementation"
9
9
  | "testing"
10
- | "documentation";
10
+ | "documentation"
11
+ | "chain_running"; // sentinel: full-mode chain active externally, not an in-session FSM node
11
12
 
12
13
  export const ALL_PHASES: Phase[] = [
13
14
  "idle",
@@ -17,26 +18,34 @@ export const ALL_PHASES: Phase[] = [
17
18
  "implementation",
18
19
  "testing",
19
20
  "documentation",
21
+ "chain_running",
20
22
  ];
21
23
 
22
24
  function buildMachineStates() {
23
25
  return {
26
+ // Quick mode only: idle → implementation.
27
+ // Full mode phases (requirements, architecture, planning, testing, documentation)
28
+ // are handled by agent-chain.ts subprocesses — not in-session FSM transitions.
24
29
  idle: state(
25
- transition("start_full", "requirements"),
26
- transition("start_quick", "implementation") as never // quick mode bypass (Story 3.3)
30
+ transition("start_quick", "implementation")
27
31
  ),
28
- requirements: state(transition("approve", "architecture")),
29
- architecture: state(transition("approve", "planning")),
30
- planning: state(transition("approve", "implementation")),
31
32
  implementation: state(transition("approve", "testing")),
32
33
  testing: state(transition("approve", "documentation")),
33
34
  documentation: state(transition("complete", "idle")),
35
+ // chain_running is a storage sentinel — not a FSM node with transitions.
36
+ // The parent session cannot transition out of chain_running via FSM events;
37
+ // the chain runner updates the DB directly when the chain completes.
34
38
  };
35
39
  }
36
40
 
37
- /** Creates a Robot3 FSM starting at the given phase — fast-forward, no event replay. */
41
+ type FsmPhase = "idle" | "implementation" | "testing" | "documentation";
42
+ const FSM_PHASES = new Set<string>(["idle", "implementation", "testing", "documentation"]);
43
+
44
+ /** Creates a Robot3 FSM starting at the given phase — fast-forward, no event replay.
45
+ * Non-FSM phases (chain_running, requirements, architecture, planning) fall back to idle. */
38
46
  export function createFsmAtPhase(phase: Phase) {
39
- return createMachine(phase, buildMachineStates());
47
+ const fsmPhase: FsmPhase = FSM_PHASES.has(phase) ? (phase as FsmPhase) : "idle";
48
+ return createMachine(fsmPhase, buildMachineStates());
40
49
  }
41
50
 
42
51
  /**
@@ -45,18 +54,30 @@ export function createFsmAtPhase(phase: Phase) {
45
54
  */
46
55
  const TOOL_PHASE_MAP: Record<string, Phase | Phase[] | "any"> = {
47
56
  set_active_project: "any",
48
- start_feature: "idle",
57
+ start_feature: "idle", // blocked in chain_running to prevent double-start
49
58
  create_component: ["planning", "implementation"],
50
59
  complete_component: "implementation",
51
60
  scaffold_doctype: "implementation",
52
61
  run_tests: "testing",
53
62
  get_project_status: "any",
54
63
  frappe_query: "any",
55
- bench_execute: "any", // allowed in all phases — includes testing (bench run-tests) and implementation (bench migrate)
64
+ bench_execute: "any",
56
65
  };
57
66
 
67
+ /** Always-allowed tools that bypass phase guard entirely. */
68
+ const ALWAYS_ALLOWED_TOOLS = new Set([
69
+ "invoke_debugger",
70
+ "end_debug",
71
+ "spawn_agent",
72
+ "get_project_status",
73
+ "get_frappe_docs",
74
+ ]);
75
+
58
76
  /** Returns true if toolName is allowed to run in the given phase. Unknown tools are always allowed. */
59
77
  export function isToolAllowedInPhase(toolName: string, phase: Phase): boolean {
78
+ if (ALWAYS_ALLOWED_TOOLS.has(toolName)) return true;
79
+ // chain_running: only always-allowed tools pass; all others (including start_feature) are blocked
80
+ if (phase === "chain_running") return false;
60
81
  const validPhase = TOOL_PHASE_MAP[toolName];
61
82
  if (validPhase === undefined) return true; // unknown tool — do not block
62
83
  if (validPhase === "any") return true;
package/state/schema.ts CHANGED
@@ -35,6 +35,9 @@ export function initSchema(db: Database): void {
35
35
  project_id TEXT NOT NULL,
36
36
  current_phase TEXT NOT NULL DEFAULT 'idle',
37
37
  site_path TEXT,
38
+ app_path TEXT,
39
+ chain_step TEXT,
40
+ chain_pid INTEGER,
38
41
  feature_id TEXT,
39
42
  component_id TEXT,
40
43
  last_tool TEXT,
@@ -56,6 +59,9 @@ export function migrateSchema(db: Database): void {
56
59
  "ALTER TABLE components ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
57
60
  "ALTER TABLE components ADD COLUMN created_at TEXT",
58
61
  "ALTER TABLE sessions ADD COLUMN component_id TEXT",
62
+ "ALTER TABLE sessions ADD COLUMN app_path TEXT",
63
+ "ALTER TABLE sessions ADD COLUMN chain_step TEXT",
64
+ "ALTER TABLE sessions ADD COLUMN chain_pid INTEGER",
59
65
  ];
60
66
  for (const sql of alters) {
61
67
  try { db.exec(sql); } catch { /* column already exists — safe to ignore */ }
@@ -4,6 +4,7 @@ import { appendEntry } from "../state/journal.js";
4
4
  import { loadConfig } from "../config/loader.js";
5
5
  import { coverageCheck } from "../gates/coverage-check.js";
6
6
  import { getArtifactDir, regenerateSprintStatus } from "../state/artifacts.js";
7
+ import { spawnChain } from "../extensions/agent-chain.js";
7
8
 
8
9
  interface StartFeatureArgs {
9
10
  name: string;
@@ -15,11 +16,18 @@ function toFeatureId(name: string): string {
15
16
  }
16
17
 
17
18
  /**
18
- * Creates a new feature row in state.db and writes a state_transition JSONL entry.
19
- * Returns the feature data directly no wrapper object.
20
- * mode defaults to "full" — feature row always stores an explicit mode value.
19
+ * Creates a new feature row in state.db, applies the FSM phase transition,
20
+ * and links the feature to the active session.
21
+ *
22
+ * Phase transition is applied inside the tool (not deferred to afterToolCall) so
23
+ * the returned JSON reflects the actual post-transition phase. The agent reads
24
+ * this value directly — returning "idle" caused session confusion.
25
+ *
26
+ * quick mode → implementation immediately (in-session).
27
+ * full mode → chain_running (agent chain spawned as background process).
28
+ * Default mode is "quick".
21
29
  */
22
- export function startFeature({ name, mode = "full" }: StartFeatureArgs) {
30
+ export async function startFeature({ name, mode = "quick" }: StartFeatureArgs) {
23
31
  const featureId = toFeatureId(name);
24
32
  const createdAt = new Date().toISOString();
25
33
 
@@ -27,18 +35,56 @@ export function startFeature({ name, mode = "full" }: StartFeatureArgs) {
27
35
  .prepare("SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1")
28
36
  .get() as { session_id: string } | undefined;
29
37
 
38
+ if (mode === "quick") {
39
+ const newPhase = "implementation";
40
+ db.prepare(
41
+ "INSERT INTO features (feature_id, name, created_at, mode, current_phase) VALUES (?, ?, ?, ?, ?)"
42
+ ).run(featureId, name, createdAt, mode, newPhase);
43
+
44
+ db.transaction(() => {
45
+ db.prepare(
46
+ "UPDATE sessions SET feature_id = ?, current_phase = ? WHERE is_active = 1"
47
+ ).run(featureId, newPhase);
48
+ })();
49
+
50
+ appendEntry({
51
+ ts: createdAt,
52
+ sessionId: session?.session_id ?? "unknown",
53
+ type: "state_transition",
54
+ payload: { from: "idle", to: newPhase, feature: name, mode },
55
+ });
56
+
57
+ return { feature_id: featureId, name, mode, current_phase: newPhase, created_at: createdAt };
58
+ }
59
+
60
+ // Full mode: insert feature at "requirements", set session to chain_running, fire chain
30
61
  db.prepare(
31
- "INSERT INTO features (feature_id, name, created_at, mode, current_phase) VALUES (?, ?, ?, ?, 'idle')"
32
- ).run(featureId, name, createdAt, mode);
62
+ "INSERT INTO features (feature_id, name, created_at, mode, current_phase) VALUES (?, ?, ?, ?, ?)"
63
+ ).run(featureId, name, createdAt, mode, "requirements");
64
+
65
+ db.transaction(() => {
66
+ db.prepare(
67
+ "UPDATE sessions SET feature_id = ?, current_phase = ? WHERE is_active = 1"
68
+ ).run(featureId, "chain_running");
69
+ })();
33
70
 
34
71
  appendEntry({
35
72
  ts: createdAt,
36
73
  sessionId: session?.session_id ?? "unknown",
37
74
  type: "state_transition",
38
- payload: { from: "idle", to: "feature_started", feature: name },
75
+ payload: { from: "idle", to: "chain_running", feature: name, mode },
76
+ });
77
+
78
+ const artifactDir = getArtifactDir(featureId);
79
+
80
+ // Fire-and-forget: chain runs independently in the background
81
+ setImmediate(() => {
82
+ Promise.resolve(spawnChain(featureId, name, artifactDir)).catch((err: unknown) => {
83
+ console.error(`[agent-chain] chain failed for ${featureId}: ${err}`);
84
+ });
39
85
  });
40
86
 
41
- return { feature_id: featureId, name, mode, current_phase: "idle", created_at: createdAt };
87
+ return { feature_id: featureId, name, mode, current_phase: "chain_running", chain_started: true, created_at: createdAt };
42
88
  }
43
89
 
44
90
  interface CreateComponentArgs {
@@ -77,6 +77,7 @@ export function getProjectStatus(_args?: unknown): ProjectStatus {
77
77
  interface SetActiveProjectArgs {
78
78
  projectId: string;
79
79
  sitePath: string;
80
+ appPath?: string;
80
81
  }
81
82
 
82
83
  /**
@@ -86,9 +87,9 @@ interface SetActiveProjectArgs {
86
87
  * The state_transition JSONL entry is written inside switchProject() —
87
88
  * no second appendEntry() call here.
88
89
  */
89
- export async function setActiveProject({ projectId, sitePath }: SetActiveProjectArgs) {
90
+ export async function setActiveProject({ projectId, sitePath, appPath }: SetActiveProjectArgs) {
90
91
  // Flush current state + create new session (JSONL entry written internally)
91
- switchProject(projectId, sitePath);
92
+ switchProject(projectId, sitePath, appPath);
92
93
 
93
94
  // Reload system prompt with new project context
94
95
  await reloadSessionContext();
@@ -101,6 +102,7 @@ export async function setActiveProject({ projectId, sitePath }: SetActiveProject
101
102
  return {
102
103
  project_id: projectId,
103
104
  site_path: sitePath,
105
+ app_path: appPath ?? null,
104
106
  phase: session?.current_phase ?? "idle",
105
107
  context_reloaded: true,
106
108
  };