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
|
@@ -1,26 +1,43 @@
|
|
|
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;
|
|
7
23
|
tool: string;
|
|
8
24
|
current_phase: Phase;
|
|
9
|
-
valid_phase:
|
|
25
|
+
valid_phase: string;
|
|
10
26
|
message: string;
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
function buildBlockedResponse(
|
|
14
30
|
tool: string,
|
|
15
31
|
currentPhase: Phase,
|
|
16
|
-
validPhase: Phase | "any"
|
|
32
|
+
validPhase: Phase | Phase[] | "any"
|
|
17
33
|
): BlockedResponse {
|
|
34
|
+
const validLabel = Array.isArray(validPhase) ? validPhase.join(", ") : validPhase;
|
|
18
35
|
return {
|
|
19
36
|
blocked: true,
|
|
20
37
|
tool,
|
|
21
38
|
current_phase: currentPhase,
|
|
22
|
-
valid_phase:
|
|
23
|
-
message: `${tool} is not available in ${currentPhase} phase. Available in: ${
|
|
39
|
+
valid_phase: validLabel,
|
|
40
|
+
message: `${tool} is not available in ${currentPhase} phase. Available in: ${validLabel}`,
|
|
24
41
|
};
|
|
25
42
|
}
|
|
26
43
|
|
|
@@ -34,7 +51,7 @@ function buildBlockedResponse(
|
|
|
34
51
|
* Never throws — always returns a value or undefined.
|
|
35
52
|
*/
|
|
36
53
|
// Tools valid in all phases — never blocked by the phase guard (FR34)
|
|
37
|
-
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"];
|
|
38
55
|
|
|
39
56
|
export function beforeToolCall(
|
|
40
57
|
toolName: string,
|
|
@@ -73,13 +90,75 @@ export function beforeToolCall(
|
|
|
73
90
|
return undefined; // allow
|
|
74
91
|
}
|
|
75
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
|
+
|
|
76
142
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
143
|
export default function (pi: any) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 };
|
|
82
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
|
+
|
|
83
162
|
return undefined;
|
|
84
163
|
});
|
|
85
164
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
|
|
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
|
+
*/
|
|
10
|
+
export function getArtifactDir(featureId: string): string {
|
|
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);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Regenerates sprint-status.yaml for the given feature from current DB state.
|
|
20
|
+
* Called after every create_component and complete_component.
|
|
21
|
+
* Creates the artifact directory if it does not exist.
|
|
22
|
+
* Non-fatal — caller should catch any errors.
|
|
23
|
+
*/
|
|
24
|
+
export function regenerateSprintStatus(featureId: string): void {
|
|
25
|
+
const feature = db
|
|
26
|
+
.prepare("SELECT feature_id, name, mode, current_phase FROM features WHERE feature_id = ?")
|
|
27
|
+
.get(featureId) as
|
|
28
|
+
| { feature_id: string; name: string; mode: string; current_phase: string }
|
|
29
|
+
| undefined;
|
|
30
|
+
|
|
31
|
+
if (!feature) return;
|
|
32
|
+
|
|
33
|
+
const components = db
|
|
34
|
+
.prepare(
|
|
35
|
+
`SELECT component_id, description, sort_order, status, completed_at
|
|
36
|
+
FROM components WHERE feature_id = ?
|
|
37
|
+
ORDER BY sort_order ASC, component_id ASC`
|
|
38
|
+
)
|
|
39
|
+
.all(featureId) as Array<{
|
|
40
|
+
component_id: string;
|
|
41
|
+
description: string | null;
|
|
42
|
+
sort_order: number;
|
|
43
|
+
status: string;
|
|
44
|
+
completed_at: string | null;
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
const done = components.filter((c) => c.status === "complete").length;
|
|
48
|
+
const total = components.length;
|
|
49
|
+
|
|
50
|
+
const componentLines = components
|
|
51
|
+
.map((c) => {
|
|
52
|
+
const descLine = c.description
|
|
53
|
+
? ` description: "${c.description.replace(/"/g, '\\"')}"`
|
|
54
|
+
: "";
|
|
55
|
+
return [
|
|
56
|
+
` - id: ${c.component_id}`,
|
|
57
|
+
descLine,
|
|
58
|
+
` sort_order: ${c.sort_order}`,
|
|
59
|
+
` status: ${c.status}`,
|
|
60
|
+
` completed_at: ${c.completed_at ?? "null"}`,
|
|
61
|
+
]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join("\n");
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
|
|
67
|
+
const yaml = `feature_id: ${feature.feature_id}
|
|
68
|
+
feature_name: "${feature.name.replace(/"/g, '\\"')}"
|
|
69
|
+
mode: ${feature.mode}
|
|
70
|
+
phase: ${feature.current_phase}
|
|
71
|
+
updated_at: ${new Date().toISOString()}
|
|
72
|
+
|
|
73
|
+
components:
|
|
74
|
+
${componentLines || " []"}
|
|
75
|
+
|
|
76
|
+
progress:
|
|
77
|
+
done: ${done}
|
|
78
|
+
total: ${total}
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
const artifactDir = getArtifactDir(featureId);
|
|
82
|
+
const implDir = join(artifactDir, "implementation-artifacts");
|
|
83
|
+
mkdirSync(implDir, { recursive: true });
|
|
84
|
+
writeFileSync(join(implDir, "sprint-status.yaml"), yaml, "utf-8");
|
|
85
|
+
}
|
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.
|
|
@@ -24,9 +25,9 @@ export function setDb(instance: DatabaseType): void {
|
|
|
24
25
|
* a new session for newProjectId, restoring its last known phase if a prior
|
|
25
26
|
* session exists. Writes a "state_transition" JSONL entry before the close.
|
|
26
27
|
*
|
|
27
|
-
* sitePath
|
|
28
|
+
* sitePath and appPath are optional so existing callers without them continue to work.
|
|
28
29
|
*/
|
|
29
|
-
export function switchProject(newProjectId: string, sitePath?: string): void {
|
|
30
|
+
export function switchProject(newProjectId: string, sitePath?: string, appPath?: string): void {
|
|
30
31
|
db.transaction(() => {
|
|
31
32
|
// 1. Read current active session before closing
|
|
32
33
|
const current = db
|
|
@@ -62,12 +63,13 @@ export function switchProject(newProjectId: string, sitePath?: string): void {
|
|
|
62
63
|
|
|
63
64
|
// 5. Create new session, restoring prior phase if available; feature_id defaults to NULL
|
|
64
65
|
db.prepare(
|
|
65
|
-
"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)"
|
|
66
67
|
).run(
|
|
67
68
|
crypto.randomUUID(),
|
|
68
69
|
newProjectId,
|
|
69
70
|
prior?.current_phase ?? "idle",
|
|
70
71
|
sitePath ?? null,
|
|
72
|
+
appPath ?? null,
|
|
71
73
|
new Date().toISOString()
|
|
72
74
|
);
|
|
73
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,52 +18,74 @@ 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("
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
const fsmPhase: FsmPhase = FSM_PHASES.has(phase) ? (phase as FsmPhase) : "idle";
|
|
48
|
+
return createMachine(fsmPhase, buildMachineStates());
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* Maps each tool to its valid phase (or 'any').
|
|
44
53
|
* Tools not listed here are allowed in any phase — do not block unregistered tools.
|
|
45
54
|
*/
|
|
46
|
-
const TOOL_PHASE_MAP: Record<string, Phase | "any"> = {
|
|
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
|
|
58
|
+
create_component: ["planning", "implementation"],
|
|
49
59
|
complete_component: "implementation",
|
|
50
60
|
scaffold_doctype: "implementation",
|
|
51
61
|
run_tests: "testing",
|
|
52
62
|
get_project_status: "any",
|
|
53
63
|
frappe_query: "any",
|
|
54
|
-
bench_execute: "any",
|
|
64
|
+
bench_execute: "any",
|
|
55
65
|
};
|
|
56
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
|
+
|
|
57
76
|
/** Returns true if toolName is allowed to run in the given phase. Unknown tools are always allowed. */
|
|
58
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;
|
|
59
81
|
const validPhase = TOOL_PHASE_MAP[toolName];
|
|
60
82
|
if (validPhase === undefined) return true; // unknown tool — do not block
|
|
61
83
|
if (validPhase === "any") return true;
|
|
84
|
+
if (Array.isArray(validPhase)) return validPhase.includes(phase);
|
|
62
85
|
return validPhase === phase;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/** Returns the phase where toolName is valid, or 'any' for unrestricted/unknown tools. */
|
|
66
|
-
export function getValidPhase(toolName: string): Phase | "any" {
|
|
89
|
+
export function getValidPhase(toolName: string): Phase | Phase[] | "any" {
|
|
67
90
|
return TOOL_PHASE_MAP[toolName] ?? "any";
|
|
68
91
|
}
|
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
|
|
22
|
-
feature_id TEXT
|
|
23
|
-
status TEXT
|
|
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)
|
|
@@ -31,7 +35,11 @@ export function initSchema(db: Database): void {
|
|
|
31
35
|
project_id TEXT NOT NULL,
|
|
32
36
|
current_phase TEXT NOT NULL DEFAULT 'idle',
|
|
33
37
|
site_path TEXT,
|
|
38
|
+
app_path TEXT,
|
|
39
|
+
chain_step TEXT,
|
|
40
|
+
chain_pid INTEGER,
|
|
34
41
|
feature_id TEXT,
|
|
42
|
+
component_id TEXT,
|
|
35
43
|
last_tool TEXT,
|
|
36
44
|
started_at TEXT NOT NULL,
|
|
37
45
|
ended_at TEXT,
|
|
@@ -39,3 +47,23 @@ export function initSchema(db: Database): void {
|
|
|
39
47
|
);
|
|
40
48
|
`);
|
|
41
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Adds new columns to existing databases without dropping data.
|
|
53
|
+
* Safe to call on any DB version — ignores "duplicate column" errors.
|
|
54
|
+
* Call this after initSchema() in db.ts.
|
|
55
|
+
*/
|
|
56
|
+
export function migrateSchema(db: Database): void {
|
|
57
|
+
const alters = [
|
|
58
|
+
"ALTER TABLE components ADD COLUMN description TEXT",
|
|
59
|
+
"ALTER TABLE components ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
|
|
60
|
+
"ALTER TABLE components ADD COLUMN created_at TEXT",
|
|
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",
|
|
65
|
+
];
|
|
66
|
+
for (const sql of alters) {
|
|
67
|
+
try { db.exec(sql); } catch { /* column already exists — safe to ignore */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
package/tools/feature-tools.ts
CHANGED
|
@@ -3,6 +3,8 @@ 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";
|
|
7
|
+
import { spawnChain } from "../extensions/agent-chain.js";
|
|
6
8
|
|
|
7
9
|
interface StartFeatureArgs {
|
|
8
10
|
name: string;
|
|
@@ -14,11 +16,18 @@ function toFeatureId(name: string): string {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
|
-
* Creates a new feature row in state.db
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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".
|
|
20
29
|
*/
|
|
21
|
-
export function startFeature({ name, mode = "
|
|
30
|
+
export async function startFeature({ name, mode = "quick" }: StartFeatureArgs) {
|
|
22
31
|
const featureId = toFeatureId(name);
|
|
23
32
|
const createdAt = new Date().toISOString();
|
|
24
33
|
|
|
@@ -26,18 +35,122 @@ export function startFeature({ name, mode = "full" }: StartFeatureArgs) {
|
|
|
26
35
|
.prepare("SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1")
|
|
27
36
|
.get() as { session_id: string } | undefined;
|
|
28
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
|
|
29
61
|
db.prepare(
|
|
30
|
-
"INSERT INTO features (feature_id, name, created_at, mode, current_phase) VALUES (?, ?, ?, ?,
|
|
31
|
-
).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
|
+
})();
|
|
70
|
+
|
|
71
|
+
appendEntry({
|
|
72
|
+
ts: createdAt,
|
|
73
|
+
sessionId: session?.session_id ?? "unknown",
|
|
74
|
+
type: "state_transition",
|
|
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
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return { feature_id: featureId, name, mode, current_phase: "chain_running", chain_started: true, created_at: createdAt };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface CreateComponentArgs {
|
|
91
|
+
featureId: string;
|
|
92
|
+
componentId: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
sortOrder?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Explicitly creates a component for a feature with status 'in-progress'.
|
|
99
|
+
* Sets this component as the active component on the session (for footer display).
|
|
100
|
+
* Regenerates sprint-status.yaml in .frappe-builder-artifacts/{featureId}/.
|
|
101
|
+
*
|
|
102
|
+
* Call this during the planning phase (full mode) or at the start of implementation
|
|
103
|
+
* (quick mode) — before work begins, not at completion time.
|
|
104
|
+
*/
|
|
105
|
+
export function createComponent({ featureId, componentId, description, sortOrder = 0 }: CreateComponentArgs) {
|
|
106
|
+
const createdAt = new Date().toISOString();
|
|
107
|
+
|
|
108
|
+
const feature = db
|
|
109
|
+
.prepare("SELECT feature_id FROM features WHERE feature_id = ?")
|
|
110
|
+
.get(featureId) as { feature_id: string } | undefined;
|
|
111
|
+
|
|
112
|
+
if (!feature) {
|
|
113
|
+
return { error: `Feature '${featureId}' not found. Call start_feature first.` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session = db
|
|
117
|
+
.prepare("SELECT session_id FROM sessions WHERE is_active = 1 LIMIT 1")
|
|
118
|
+
.get() as { session_id: string } | undefined;
|
|
119
|
+
|
|
120
|
+
const existing = db
|
|
121
|
+
.prepare("SELECT component_id FROM components WHERE feature_id = ? AND component_id = ?")
|
|
122
|
+
.get(featureId, componentId) as { component_id: string } | undefined;
|
|
123
|
+
|
|
124
|
+
db.prepare(`
|
|
125
|
+
INSERT INTO components (feature_id, component_id, status, description, sort_order, created_at)
|
|
126
|
+
VALUES (?, ?, 'in-progress', ?, ?, ?)
|
|
127
|
+
ON CONFLICT(feature_id, component_id)
|
|
128
|
+
DO UPDATE SET description = excluded.description, sort_order = excluded.sort_order
|
|
129
|
+
`).run(featureId, componentId, description ?? null, sortOrder, createdAt);
|
|
130
|
+
|
|
131
|
+
if (!existing) {
|
|
132
|
+
db.prepare("UPDATE features SET progress_total = progress_total + 1 WHERE feature_id = ?")
|
|
133
|
+
.run(featureId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
db.prepare("UPDATE sessions SET component_id = ? WHERE is_active = 1")
|
|
137
|
+
.run(componentId);
|
|
138
|
+
|
|
139
|
+
regenerateSprintStatus(featureId);
|
|
32
140
|
|
|
33
141
|
appendEntry({
|
|
34
142
|
ts: createdAt,
|
|
35
143
|
sessionId: session?.session_id ?? "unknown",
|
|
36
144
|
type: "state_transition",
|
|
37
|
-
payload: {
|
|
145
|
+
payload: { feature: featureId, component: componentId, action: "create_component", status: "in-progress" },
|
|
38
146
|
});
|
|
39
147
|
|
|
40
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
feature_id: featureId,
|
|
150
|
+
component_id: componentId,
|
|
151
|
+
status: "in-progress",
|
|
152
|
+
artifact_dir: getArtifactDir(featureId),
|
|
153
|
+
};
|
|
41
154
|
}
|
|
42
155
|
|
|
43
156
|
interface CompleteComponentArgs {
|
|
@@ -105,6 +218,10 @@ export async function completeComponent({ featureId, componentId }: CompleteComp
|
|
|
105
218
|
DO UPDATE SET status = 'complete', completed_at = excluded.completed_at
|
|
106
219
|
`).run(featureId, componentId, completedAt);
|
|
107
220
|
|
|
221
|
+
db.prepare("UPDATE sessions SET component_id = NULL WHERE is_active = 1").run();
|
|
222
|
+
db.prepare("UPDATE features SET progress_done = progress_done + 1 WHERE feature_id = ?").run(featureId);
|
|
223
|
+
regenerateSprintStatus(featureId);
|
|
224
|
+
|
|
108
225
|
appendEntry({
|
|
109
226
|
ts: completedAt,
|
|
110
227
|
sessionId,
|
package/tools/project-tools.ts
CHANGED
|
@@ -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
|
};
|