@dv.nghiem/flowdeck 0.4.8 → 0.4.10

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
- import { readFileSync as readFileSync24, readdirSync as readdirSync3, existsSync as existsSync25 } from "fs";
3
- import { join as join24, basename as basename2 } from "path";
2
+ import { readFileSync as readFileSync25, readdirSync as readdirSync3, existsSync as existsSync27 } from "fs";
3
+ import { join as join26, basename as basename2 } from "path";
4
4
  import { dirname as dirname3 } from "path";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
6
 
@@ -1994,9 +1994,484 @@ var rtkSetupTool = tool12({
1994
1994
  }
1995
1995
  });
1996
1996
 
1997
- // src/hooks/guard-rails.ts
1998
- import { existsSync as existsSync16, readFileSync as readFileSync16 } from "fs";
1997
+ // src/tools/merge-assist.ts
1998
+ import { tool as tool13 } from "@opencode-ai/plugin";
1999
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync11, existsSync as existsSync16, mkdirSync as mkdirSync10 } from "fs";
1999
2000
  import { join as join16 } from "path";
2001
+ import { spawnSync as spawnSync3 } from "child_process";
2002
+
2003
+ // src/lib/logger.ts
2004
+ import { appendFileSync as appendFileSync3, existsSync as existsSync15, mkdirSync as mkdirSync9 } from "fs";
2005
+ import { join as join15 } from "path";
2006
+ var LOG_DIR = ".opencode";
2007
+ var LOG_FILE = "flowdeck.log";
2008
+ function ensureLogDir(logDir) {
2009
+ if (!existsSync15(logDir)) {
2010
+ mkdirSync9(logDir, { recursive: true });
2011
+ }
2012
+ }
2013
+ function logWrite(directory, level, source, message) {
2014
+ const logDir = join15(directory, LOG_DIR);
2015
+ const logFile = join15(logDir, LOG_FILE);
2016
+ try {
2017
+ ensureLogDir(logDir);
2018
+ const entry = {
2019
+ timestamp: new Date().toISOString(),
2020
+ level,
2021
+ source,
2022
+ message
2023
+ };
2024
+ appendFileSync3(logFile, JSON.stringify(entry) + `
2025
+ `, "utf-8");
2026
+ } catch {}
2027
+ }
2028
+
2029
+ // src/tools/merge-assist.ts
2030
+ var MERGE_ASSIST_FILE = "MERGE_ASSIST.json";
2031
+ function statePath2(directory) {
2032
+ return join16(codebaseDir(directory), MERGE_ASSIST_FILE);
2033
+ }
2034
+ function emptyState() {
2035
+ return { version: "1.0", lastUpdated: new Date().toISOString(), sessions: {} };
2036
+ }
2037
+ function readState(directory) {
2038
+ const p = statePath2(directory);
2039
+ if (!existsSync16(p))
2040
+ return emptyState();
2041
+ try {
2042
+ return JSON.parse(readFileSync15(p, "utf-8"));
2043
+ } catch {
2044
+ return emptyState();
2045
+ }
2046
+ }
2047
+ function writeState(directory, state) {
2048
+ try {
2049
+ const base = codebaseDir(directory);
2050
+ if (!existsSync16(base))
2051
+ mkdirSync10(base, { recursive: true });
2052
+ const newState = { ...state, lastUpdated: new Date().toISOString() };
2053
+ writeFileSync11(statePath2(directory), JSON.stringify(newState, null, 2), "utf-8");
2054
+ return { success: true };
2055
+ } catch (error) {
2056
+ return { error: error instanceof Error ? error.message : String(error) };
2057
+ }
2058
+ }
2059
+ function timestamp2() {
2060
+ return new Date().toISOString();
2061
+ }
2062
+ function generateId() {
2063
+ return `ma-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2064
+ }
2065
+ function safeGit(cwd, args) {
2066
+ const result = spawnSync3("git", args, { cwd, encoding: "utf-8" });
2067
+ return {
2068
+ stdout: result.stdout?.trim() ?? "",
2069
+ stderr: result.stderr?.trim() ?? "",
2070
+ status: result.status ?? null
2071
+ };
2072
+ }
2073
+ function isGitRepo(cwd) {
2074
+ return existsSync16(join16(cwd, ".git")) && safeGit(cwd, ["rev-parse", "--git-dir"]).status === 0;
2075
+ }
2076
+ function branchExists(cwd, branch) {
2077
+ return safeGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).status === 0;
2078
+ }
2079
+ function getMergeBase(cwd, target, source) {
2080
+ const result = safeGit(cwd, ["merge-base", target, source]);
2081
+ if (result.status !== 0)
2082
+ return null;
2083
+ return result.stdout.trim();
2084
+ }
2085
+ function getFeatureCommits(cwd, mergeBase, source) {
2086
+ const result = safeGit(cwd, ["log", "--oneline", "--ancestry-path", `${mergeBase}..${source}`]);
2087
+ if (result.status !== 0)
2088
+ return [];
2089
+ return result.stdout.split(`
2090
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.split(" ")[0]).filter(Boolean);
2091
+ }
2092
+ function getCommitMetadata(cwd, sha) {
2093
+ const result = safeGit(cwd, ["log", "-1", "--format=%H%x00%s%x00%an%x00%ad", "--date=iso", sha]);
2094
+ if (result.status !== 0)
2095
+ return null;
2096
+ const parts = result.stdout.split("\x00");
2097
+ if (parts.length < 4)
2098
+ return null;
2099
+ return { sha: parts[0], subject: parts[1], author: parts[2], date: parts[3] };
2100
+ }
2101
+ function getCommitFiles(cwd, sha) {
2102
+ const result = safeGit(cwd, ["diff", "--name-only", `${sha}^..${sha}`]);
2103
+ if (result.status !== 0)
2104
+ return [];
2105
+ return result.stdout.split(`
2106
+ `).map((f) => f.trim()).filter(Boolean);
2107
+ }
2108
+ function isLikelyFeatureCommit(subject, files) {
2109
+ const lower = subject.toLowerCase();
2110
+ const featKeywords = ["feat", "feature", "add", "implement", "introduce", "support", "enable"];
2111
+ const depKeywords = ["refactor", "prep", "prepare", "fix", "setup", "wip", "draft", "revert"];
2112
+ const testOnly = files.length > 0 && files.every((f) => f.includes("test") || f.includes("spec"));
2113
+ if (featKeywords.some((k) => lower.includes(k)) && !testOnly) {
2114
+ return { isLikelyFeature: true, confidence: "high" };
2115
+ }
2116
+ if (depKeywords.some((k) => lower.includes(k))) {
2117
+ return { isLikelyFeature: false, confidence: "medium" };
2118
+ }
2119
+ if (testOnly) {
2120
+ return { isLikelyFeature: false, confidence: "medium" };
2121
+ }
2122
+ return { isLikelyFeature: true, confidence: "low" };
2123
+ }
2124
+ function findCandidateCommits(cwd, sourceBranch, targetBranch) {
2125
+ const mergeBase = getMergeBase(cwd, targetBranch, sourceBranch);
2126
+ if (!mergeBase)
2127
+ return [];
2128
+ const shas = getFeatureCommits(cwd, mergeBase, sourceBranch);
2129
+ const candidates = [];
2130
+ for (const sha of shas) {
2131
+ const meta = getCommitMetadata(cwd, sha);
2132
+ if (!meta)
2133
+ continue;
2134
+ const files = getCommitFiles(cwd, sha);
2135
+ const { isLikelyFeature, confidence } = isLikelyFeatureCommit(meta.subject, files);
2136
+ candidates.push({
2137
+ sha: meta.sha,
2138
+ subject: meta.subject,
2139
+ author: meta.author,
2140
+ date: meta.date,
2141
+ files,
2142
+ isLikelyFeature,
2143
+ confidence
2144
+ });
2145
+ }
2146
+ return candidates;
2147
+ }
2148
+ function detectDependencies(candidateCommits) {
2149
+ const dependentShas = new Set;
2150
+ const fileToCommits = {};
2151
+ for (const commit of candidateCommits) {
2152
+ for (const file of commit.files) {
2153
+ if (!fileToCommits[file])
2154
+ fileToCommits[file] = [];
2155
+ fileToCommits[file].push(commit.sha);
2156
+ }
2157
+ }
2158
+ for (const [, shas] of Object.entries(fileToCommits)) {
2159
+ if (shas.length > 1) {
2160
+ for (const sha of shas)
2161
+ dependentShas.add(sha);
2162
+ }
2163
+ }
2164
+ const depKeywords = ["refactor", "prep", "prepare", "fix", "setup", "wip", "depends on", "prerequisite"];
2165
+ for (const commit of candidateCommits) {
2166
+ const lower = commit.subject.toLowerCase();
2167
+ if (depKeywords.some((k) => lower.includes(k))) {
2168
+ dependentShas.add(commit.sha);
2169
+ }
2170
+ }
2171
+ return Array.from(dependentShas);
2172
+ }
2173
+ function recommendMethod(candidateCommits, selectedCommits) {
2174
+ if (selectedCommits.length === 0)
2175
+ return "abort";
2176
+ const ordered = candidateCommits.filter((c) => selectedCommits.includes(c.sha));
2177
+ if (ordered.length === 0)
2178
+ return "abort";
2179
+ const idxs = ordered.map((c) => candidateCommits.findIndex((x) => x.sha === c.sha));
2180
+ let contiguous = true;
2181
+ for (let i = 1;i < idxs.length; i++) {
2182
+ if (idxs[i] !== idxs[i - 1] + 1) {
2183
+ contiguous = false;
2184
+ break;
2185
+ }
2186
+ }
2187
+ if (selectedCommits.length === 1)
2188
+ return "cherry-pick";
2189
+ if (contiguous)
2190
+ return "cherry-pick-range";
2191
+ return "manual-port";
2192
+ }
2193
+ function generateRecommendedCommands(plan) {
2194
+ const localCmds = [];
2195
+ const remoteCmds = [];
2196
+ localCmds.push(`git checkout -b ${plan.integrationBranch} ${plan.targetBranch}`);
2197
+ if (plan.method === "cherry-pick") {
2198
+ for (const sha of plan.selectedCommits) {
2199
+ localCmds.push(`git cherry-pick ${sha}`);
2200
+ }
2201
+ } else if (plan.method === "cherry-pick-range") {
2202
+ const first = plan.selectedCommits[0];
2203
+ const last = plan.selectedCommits[plan.selectedCommits.length - 1];
2204
+ localCmds.push(`git cherry-pick ${first}^..${last}`);
2205
+ } else if (plan.method === "manual-port") {
2206
+ localCmds.push(`# Manual port required — review each commit and apply changes manually`);
2207
+ for (const sha of plan.selectedCommits) {
2208
+ localCmds.push(`# Review: git show ${sha}`);
2209
+ }
2210
+ } else if (plan.method === "abort") {
2211
+ localCmds.push(`# No commits selected — aborting merge-assist workflow`);
2212
+ return localCmds;
2213
+ }
2214
+ remoteCmds.push(`git push -u origin ${plan.integrationBranch}`);
2215
+ remoteCmds.push(`gh pr create --base ${plan.targetBranch} --head ${plan.integrationBranch} --title "Merge-assist: ${plan.sourceBranch} → ${plan.targetBranch}"`);
2216
+ return [
2217
+ "# --- Local commands (no auth required) ---",
2218
+ ...localCmds,
2219
+ "",
2220
+ "# --- Remote commands (GitHub auth required) ---",
2221
+ ...remoteCmds,
2222
+ "",
2223
+ "# NOTE: The agent will NEVER ask for your GitHub token, password, or SSH key.",
2224
+ "# If you need to push or create a PR, run the remote commands manually or defer this step."
2225
+ ];
2226
+ }
2227
+ function buildRisks(candidateCommits, selectedCommits, dependentCommits) {
2228
+ const risks = [];
2229
+ const selectedSet = new Set(selectedCommits);
2230
+ const missingDeps = dependentCommits.filter((d) => !selectedSet.has(d));
2231
+ if (missingDeps.length > 0) {
2232
+ risks.push(`Potentially missing dependent commits: ${missingDeps.join(", ")}`);
2233
+ }
2234
+ if (selectedCommits.length > 10) {
2235
+ risks.push("Large number of commits — high chance of conflicts");
2236
+ }
2237
+ if (candidateCommits.some((c) => selectedSet.has(c.sha) && c.files.some((f) => f.includes("package-lock") || f.includes("yarn.lock") || f.includes("bun.lockb")))) {
2238
+ risks.push("Lockfile changes detected — verify dependency compatibility");
2239
+ }
2240
+ if (candidateCommits.some((c) => selectedSet.has(c.sha) && c.files.some((f) => f.includes("migration") || f.includes("schema") || f.includes(".sql")))) {
2241
+ risks.push("Database/schema changes detected — verify migration order");
2242
+ }
2243
+ return risks;
2244
+ }
2245
+ function makeConfirmation(step, prompt) {
2246
+ return { step, prompt, status: "pending", requestedAt: timestamp2() };
2247
+ }
2248
+ function updateConfirmation(session, step, approved) {
2249
+ return {
2250
+ ...session,
2251
+ confirmations: session.confirmations.map((c) => c.step === step ? { ...c, status: approved ? "approved" : "rejected", resolvedAt: timestamp2() } : c)
2252
+ };
2253
+ }
2254
+ function isStepApproved(session, step) {
2255
+ return session.confirmations.some((c) => c.step === step && c.status === "approved");
2256
+ }
2257
+ var mergeAssistTool = tool13({
2258
+ description: "Human-in-the-loop selective branch integration. Provides structured analysis and confirmation state management for cherry-pick or manual port workflows. Never executes state-changing git commands.",
2259
+ args: {
2260
+ action: tool13.schema.enum(["start", "inspect", "plan", "confirm", "abort", "status", "list"]),
2261
+ targetBranch: tool13.schema.string().optional(),
2262
+ sourceBranch: tool13.schema.string().optional(),
2263
+ featureDescription: tool13.schema.string().optional(),
2264
+ sessionId: tool13.schema.string().optional(),
2265
+ selectedCommits: tool13.schema.array(tool13.schema.string()).optional(),
2266
+ step: tool13.schema.string().optional(),
2267
+ approved: tool13.schema.boolean().optional(),
2268
+ integrationBranch: tool13.schema.string().optional()
2269
+ },
2270
+ async execute(args, context) {
2271
+ const dir = context.directory ?? process.cwd();
2272
+ const state = readState(dir);
2273
+ if (!isGitRepo(dir)) {
2274
+ return JSON.stringify({ error: "Not a git repository" });
2275
+ }
2276
+ const log = (msg) => logWrite(dir, "info", "merge-assist", msg);
2277
+ switch (args.action) {
2278
+ case "start": {
2279
+ if (!args.targetBranch || !args.sourceBranch || !args.featureDescription) {
2280
+ return JSON.stringify({ error: "targetBranch, sourceBranch, and featureDescription are required for start" });
2281
+ }
2282
+ if (!branchExists(dir, args.targetBranch)) {
2283
+ return JSON.stringify({ error: `Target branch '${args.targetBranch}' does not exist` });
2284
+ }
2285
+ if (!branchExists(dir, args.sourceBranch)) {
2286
+ return JSON.stringify({ error: `Source branch '${args.sourceBranch}' does not exist` });
2287
+ }
2288
+ const id = generateId();
2289
+ const now = timestamp2();
2290
+ const session = {
2291
+ id,
2292
+ targetBranch: args.targetBranch,
2293
+ sourceBranch: args.sourceBranch,
2294
+ featureDescription: args.featureDescription,
2295
+ status: "clarifying",
2296
+ candidateCommits: [],
2297
+ selectedCommits: [],
2298
+ dependentCommits: [],
2299
+ confirmations: [
2300
+ makeConfirmation("branch_selection", `Confirm integrating from '${args.sourceBranch}' into '${args.targetBranch}' for: ${args.featureDescription}`)
2301
+ ],
2302
+ createdAt: now,
2303
+ updatedAt: now
2304
+ };
2305
+ const newState = { ...state, sessions: { ...state.sessions, [id]: session } };
2306
+ const writeResult = writeState(dir, newState);
2307
+ if ("error" in writeResult)
2308
+ return JSON.stringify({ error: writeResult.error });
2309
+ log(`Session ${id} started: ${args.sourceBranch} → ${args.targetBranch}`);
2310
+ return JSON.stringify({ success: true, session });
2311
+ }
2312
+ case "inspect": {
2313
+ if (!args.sessionId)
2314
+ return JSON.stringify({ error: "sessionId is required for inspect" });
2315
+ const session = state.sessions[args.sessionId];
2316
+ if (!session)
2317
+ return JSON.stringify({ error: `Session not found: ${args.sessionId}` });
2318
+ const candidates = findCandidateCommits(dir, session.sourceBranch, session.targetBranch);
2319
+ const hasCommitSelection = session.confirmations.some((c) => c.step === "commit_selection");
2320
+ const newSession = {
2321
+ ...session,
2322
+ candidateCommits: candidates,
2323
+ dependentCommits: detectDependencies(candidates),
2324
+ status: "inspecting",
2325
+ confirmations: hasCommitSelection ? session.confirmations : [...session.confirmations, makeConfirmation("commit_selection", `Select commits that represent the feature '${session.featureDescription}' from ${candidates.length} candidate(s)`)],
2326
+ updatedAt: timestamp2()
2327
+ };
2328
+ const newState = { ...state, sessions: { ...state.sessions, [args.sessionId]: newSession } };
2329
+ const writeResult = writeState(dir, newState);
2330
+ if ("error" in writeResult)
2331
+ return JSON.stringify({ error: writeResult.error });
2332
+ log(`Session ${session.id}: inspected ${candidates.length} candidate commits`);
2333
+ return JSON.stringify({ success: true, session: newSession, candidates, dependentCommits: newSession.dependentCommits });
2334
+ }
2335
+ case "plan": {
2336
+ if (!args.sessionId)
2337
+ return JSON.stringify({ error: "sessionId is required for plan" });
2338
+ const session = state.sessions[args.sessionId];
2339
+ if (!session)
2340
+ return JSON.stringify({ error: `Session not found: ${args.sessionId}` });
2341
+ const selected = args.selectedCommits ?? session.selectedCommits;
2342
+ if (!selected || selected.length === 0) {
2343
+ return JSON.stringify({ error: "selectedCommits required for plan" });
2344
+ }
2345
+ const integrationBranch = args.integrationBranch ?? `merge-assist/${session.sourceBranch}-to-${session.targetBranch}`;
2346
+ const method = recommendMethod(session.candidateCommits, selected);
2347
+ const risks = buildRisks(session.candidateCommits, selected, session.dependentCommits);
2348
+ const plan = {
2349
+ targetBranch: session.targetBranch,
2350
+ sourceBranch: session.sourceBranch,
2351
+ integrationBranch,
2352
+ selectedCommits: selected,
2353
+ method,
2354
+ risks,
2355
+ recommendedCommands: [],
2356
+ dryRun: true
2357
+ };
2358
+ const planWithCommands = { ...plan, recommendedCommands: generateRecommendedCommands(plan) };
2359
+ let newConfirmations = session.confirmations;
2360
+ const planSteps = ["integration_branch", "method_selection", "dependency_inclusion"];
2361
+ for (const step of planSteps) {
2362
+ if (!newConfirmations.find((c) => c.step === step)) {
2363
+ let prompt = "";
2364
+ if (step === "integration_branch")
2365
+ prompt = `Use integration branch '${integrationBranch}'?`;
2366
+ if (step === "method_selection")
2367
+ prompt = `Use merge method '${method}'?`;
2368
+ if (step === "dependency_inclusion")
2369
+ prompt = `Include dependent commits ${session.dependentCommits.length > 0 ? `(${session.dependentCommits.join(", ")})` : "(none detected)"}?`;
2370
+ newConfirmations = [...newConfirmations, makeConfirmation(step, prompt)];
2371
+ }
2372
+ }
2373
+ let newStatus = "planning";
2374
+ if (planSteps.every((s) => isStepApproved({ ...session, confirmations: newConfirmations }, s))) {
2375
+ newStatus = "awaiting_confirmation";
2376
+ if (!newConfirmations.find((c) => c.step === "execute_plan")) {
2377
+ newConfirmations = [...newConfirmations, makeConfirmation("execute_plan", "Execute the recommended commands?")];
2378
+ }
2379
+ }
2380
+ const newSession = {
2381
+ ...session,
2382
+ selectedCommits: selected,
2383
+ integrationBranch,
2384
+ mergePlan: planWithCommands,
2385
+ status: newStatus,
2386
+ confirmations: newConfirmations,
2387
+ updatedAt: timestamp2()
2388
+ };
2389
+ const newState = { ...state, sessions: { ...state.sessions, [args.sessionId]: newSession } };
2390
+ const writeResult = writeState(dir, newState);
2391
+ if ("error" in writeResult)
2392
+ return JSON.stringify({ error: writeResult.error });
2393
+ log(`Session ${session.id}: plan created with method ${method}, ${selected.length} commit(s)`);
2394
+ return JSON.stringify({ success: true, session: newSession, plan: planWithCommands });
2395
+ }
2396
+ case "confirm": {
2397
+ if (!args.sessionId)
2398
+ return JSON.stringify({ error: "sessionId is required for confirm" });
2399
+ if (!args.step)
2400
+ return JSON.stringify({ error: "step is required for confirm" });
2401
+ if (args.approved === undefined)
2402
+ return JSON.stringify({ error: "approved boolean is required for confirm" });
2403
+ let session = state.sessions[args.sessionId];
2404
+ if (!session)
2405
+ return JSON.stringify({ error: `Session not found: ${args.sessionId}` });
2406
+ session = updateConfirmation(session, args.step, args.approved);
2407
+ log(`Session ${session.id}: step '${args.step}' ${args.approved ? "approved" : "rejected"}`);
2408
+ let newStatus = session.status;
2409
+ let newConfirmations = session.confirmations;
2410
+ if (args.approved) {
2411
+ if (args.step === "branch_selection" && session.status === "clarifying") {
2412
+ newStatus = "inspecting";
2413
+ } else if (args.step === "commit_selection" && session.status === "inspecting") {
2414
+ newStatus = "planning";
2415
+ } else if (["integration_branch", "method_selection", "dependency_inclusion"].includes(args.step) && session.status === "planning") {
2416
+ const planSteps = ["integration_branch", "method_selection", "dependency_inclusion"];
2417
+ if (planSteps.every((s) => isStepApproved(session, s))) {
2418
+ newStatus = "awaiting_confirmation";
2419
+ if (!newConfirmations.find((c) => c.step === "execute_plan")) {
2420
+ newConfirmations = [...newConfirmations, makeConfirmation("execute_plan", "Execute the recommended commands?")];
2421
+ }
2422
+ }
2423
+ } else if (args.step === "execute_plan" && session.status === "awaiting_confirmation") {
2424
+ newStatus = "executing";
2425
+ if (!newConfirmations.find((c) => c.step === "push_pr")) {
2426
+ newConfirmations = [...newConfirmations, makeConfirmation("push_pr", "Push the integration branch and open a PR?")];
2427
+ }
2428
+ } else if (args.step === "push_pr" && session.status === "executing") {
2429
+ newStatus = "completed";
2430
+ }
2431
+ }
2432
+ const newSession = { ...session, status: newStatus, confirmations: newConfirmations, updatedAt: timestamp2() };
2433
+ const newState = { ...state, sessions: { ...state.sessions, [args.sessionId]: newSession } };
2434
+ const writeResult = writeState(dir, newState);
2435
+ if ("error" in writeResult)
2436
+ return JSON.stringify({ error: writeResult.error });
2437
+ return JSON.stringify({ success: true, session: newSession, step: args.step, approved: args.approved });
2438
+ }
2439
+ case "abort": {
2440
+ if (!args.sessionId)
2441
+ return JSON.stringify({ error: "sessionId is required for abort" });
2442
+ const session = state.sessions[args.sessionId];
2443
+ if (!session)
2444
+ return JSON.stringify({ error: `Session not found: ${args.sessionId}` });
2445
+ const newSession = { ...session, status: "aborted", updatedAt: timestamp2() };
2446
+ const newState = { ...state, sessions: { ...state.sessions, [args.sessionId]: newSession } };
2447
+ const writeResult = writeState(dir, newState);
2448
+ if ("error" in writeResult)
2449
+ return JSON.stringify({ error: writeResult.error });
2450
+ log(`Session ${session.id}: aborted`);
2451
+ return JSON.stringify({ success: true, session: newSession, message: "Session aborted" });
2452
+ }
2453
+ case "status": {
2454
+ if (!args.sessionId)
2455
+ return JSON.stringify({ error: "sessionId is required for status" });
2456
+ const session = state.sessions[args.sessionId];
2457
+ if (!session)
2458
+ return JSON.stringify({ error: `Session not found: ${args.sessionId}` });
2459
+ return JSON.stringify({ success: true, session });
2460
+ }
2461
+ case "list": {
2462
+ const sessions = Object.values(state.sessions);
2463
+ return JSON.stringify({ success: true, count: sessions.length, sessions });
2464
+ }
2465
+ default: {
2466
+ return JSON.stringify({ error: `Unknown action: ${args.action}` });
2467
+ }
2468
+ }
2469
+ }
2470
+ });
2471
+
2472
+ // src/hooks/guard-rails.ts
2473
+ import { existsSync as existsSync18, readFileSync as readFileSync17 } from "fs";
2474
+ import { join as join18 } from "path";
2000
2475
 
2001
2476
  // src/lib/task-routing.ts
2002
2477
  var UI_HEAVY_KEYWORDS = [
@@ -2042,23 +2517,23 @@ function isUiHeavyTask(input) {
2042
2517
  }
2043
2518
 
2044
2519
  // src/config/loader.ts
2045
- import { existsSync as existsSync15, readFileSync as readFileSync15 } from "fs";
2046
- import { join as join15 } from "path";
2520
+ import { existsSync as existsSync17, readFileSync as readFileSync16 } from "fs";
2521
+ import { join as join17 } from "path";
2047
2522
  import { homedir as homedir2 } from "os";
2048
2523
  var CONFIG_FILENAME = "flowdeck.json";
2049
2524
  function getGlobalConfigDir() {
2050
- return process.env.OPENCODE_CONFIG_DIR || (process.env.XDG_CONFIG_HOME ? join15(process.env.XDG_CONFIG_HOME, "opencode") : join15(homedir2(), ".config", "opencode"));
2525
+ return process.env.OPENCODE_CONFIG_DIR || (process.env.XDG_CONFIG_HOME ? join17(process.env.XDG_CONFIG_HOME, "opencode") : join17(homedir2(), ".config", "opencode"));
2051
2526
  }
2052
2527
  function loadFlowDeckConfig(directory) {
2053
2528
  const candidates = [];
2054
2529
  if (directory) {
2055
- candidates.push(join15(directory, ".opencode", CONFIG_FILENAME));
2530
+ candidates.push(join17(directory, ".opencode", CONFIG_FILENAME));
2056
2531
  }
2057
- candidates.push(join15(getGlobalConfigDir(), CONFIG_FILENAME));
2532
+ candidates.push(join17(getGlobalConfigDir(), CONFIG_FILENAME));
2058
2533
  for (const configPath of candidates) {
2059
- if (existsSync15(configPath)) {
2534
+ if (existsSync17(configPath)) {
2060
2535
  try {
2061
- const content = readFileSync15(configPath, "utf-8");
2536
+ const content = readFileSync16(configPath, "utf-8");
2062
2537
  return JSON.parse(content);
2063
2538
  } catch {}
2064
2539
  }
@@ -2085,9 +2560,9 @@ var PLANNING_DIR2 = ".planning";
2085
2560
  var CONFIG_FILE = "config.json";
2086
2561
  var STATE_FILE2 = "STATE.md";
2087
2562
  function resolveExecutionMode(configPath, trustScore, volatility) {
2088
- if (existsSync16(configPath)) {
2563
+ if (existsSync18(configPath)) {
2089
2564
  try {
2090
- const config = JSON.parse(readFileSync16(configPath, "utf-8"));
2565
+ const config = JSON.parse(readFileSync17(configPath, "utf-8"));
2091
2566
  if (config.execution_mode === "review-only")
2092
2567
  return "review-only";
2093
2568
  if (config.execution_mode === "guarded")
@@ -2141,22 +2616,22 @@ async function guardRailsHook(ctx, input, _output) {
2141
2616
  if (!ENABLED)
2142
2617
  return;
2143
2618
  const dir = ctx.directory;
2144
- const planningDirPath = join16(dir, PLANNING_DIR2);
2619
+ const planningDirPath = join18(dir, PLANNING_DIR2);
2145
2620
  const codebaseDirectory = codebaseDir(dir);
2146
- const configPath = join16(planningDirPath, CONFIG_FILE);
2147
- const statePath2 = join16(planningDirPath, STATE_FILE2);
2621
+ const configPath = join18(planningDirPath, CONFIG_FILE);
2622
+ const statePath3 = join18(planningDirPath, STATE_FILE2);
2148
2623
  const workspaceRoot = findWorkspaceRoot(dir);
2149
2624
  if (workspaceRoot && dir !== workspaceRoot) {
2150
2625
  const config = getWorkspaceConfig(dir);
2151
- if (config && config.workspace_mode === "shared" && !existsSync16(planningDirPath)) {
2626
+ if (config && config.workspace_mode === "shared" && !existsSync18(planningDirPath)) {
2152
2627
  const msg = `No .planning/ in this sub-repo. Switch to workspace root: cd ${workspaceRoot}`;
2153
2628
  throw new Error(`[flowdeck] BLOCK: ${msg}`);
2154
2629
  }
2155
2630
  }
2156
2631
  if (input.tool === "write" || input.tool === "edit") {
2157
- if (!existsSync16(planningDirPath))
2632
+ if (!existsSync18(planningDirPath))
2158
2633
  return;
2159
- if (!existsSync16(codebaseDirectory)) {
2634
+ if (!existsSync18(codebaseDirectory)) {
2160
2635
  throw new Error(`[flowdeck] WARNING: .codebase/ not found. Run /fd-map-codebase to map the codebase.`);
2161
2636
  }
2162
2637
  const execMode = resolveExecutionMode(configPath, null);
@@ -2170,7 +2645,7 @@ async function guardRailsHook(ctx, input, _output) {
2170
2645
  if (designGateMessage) {
2171
2646
  throw new Error(designGateMessage);
2172
2647
  }
2173
- const effectiveSeverity = getEffectiveSeverity(configPath, statePath2);
2648
+ const effectiveSeverity = getEffectiveSeverity(configPath, statePath3);
2174
2649
  if (effectiveSeverity === null)
2175
2650
  return;
2176
2651
  if (effectiveSeverity === "warn") {
@@ -2184,7 +2659,7 @@ async function guardRailsHook(ctx, input, _output) {
2184
2659
  const cmd = _output?.args?.command || "";
2185
2660
  for (const pattern of BUILD_DEPLOY_PATTERNS) {
2186
2661
  if (cmd.includes(pattern)) {
2187
- if (!getPlanConfirmed(statePath2)) {
2662
+ if (!getPlanConfirmed(statePath3)) {
2188
2663
  throw new Error(`[flowdeck] WARNING: Build/deploy command detected but plan is not confirmed. Run /fd-plan first.`);
2189
2664
  }
2190
2665
  break;
@@ -2212,15 +2687,15 @@ function getDesignGateMessage(dir) {
2212
2687
  }
2213
2688
  function planSuggestsUiHeavy(dir, phase) {
2214
2689
  const planPath = phasePlanPath(dir, phase);
2215
- if (!existsSync16(planPath))
2690
+ if (!existsSync18(planPath))
2216
2691
  return false;
2217
- const planContent = readFileSync16(planPath, "utf-8");
2692
+ const planContent = readFileSync17(planPath, "utf-8");
2218
2693
  return isUiHeavyTask(planContent);
2219
2694
  }
2220
- function effectiveSeverity(configPath, statePath2) {
2221
- if (existsSync16(configPath)) {
2695
+ function effectiveSeverity(configPath, statePath3) {
2696
+ if (existsSync18(configPath)) {
2222
2697
  try {
2223
- const configContent = readFileSync16(configPath, "utf-8");
2698
+ const configContent = readFileSync17(configPath, "utf-8");
2224
2699
  const config = JSON.parse(configContent);
2225
2700
  if (config.guard_enforcement === "warn")
2226
2701
  return "warn";
@@ -2230,16 +2705,16 @@ function effectiveSeverity(configPath, statePath2) {
2230
2705
  return null;
2231
2706
  } catch {}
2232
2707
  }
2233
- return getPlanConfirmed(statePath2) ? "block" : "warn";
2708
+ return getPlanConfirmed(statePath3) ? "block" : "warn";
2234
2709
  }
2235
- function getEffectiveSeverity(configPath, statePath2) {
2236
- return effectiveSeverity(configPath, statePath2);
2710
+ function getEffectiveSeverity(configPath, statePath3) {
2711
+ return effectiveSeverity(configPath, statePath3);
2237
2712
  }
2238
- function getPlanConfirmed(statePath2) {
2239
- if (!existsSync16(statePath2))
2713
+ function getPlanConfirmed(statePath3) {
2714
+ if (!existsSync18(statePath3))
2240
2715
  return false;
2241
2716
  try {
2242
- const content = readFileSync16(statePath2, "utf-8");
2717
+ const content = readFileSync17(statePath3, "utf-8");
2243
2718
  const match = content.match(/plan_confirmed:\s*(true|false)/i);
2244
2719
  return match ? match[1].toLowerCase() === "true" : false;
2245
2720
  } catch {
@@ -2247,32 +2722,32 @@ function getPlanConfirmed(statePath2) {
2247
2722
  }
2248
2723
  }
2249
2724
  function getWarningMessage(planningDir2) {
2250
- if (!existsSync16(join16(planningDir2, STATE_FILE2))) {
2725
+ if (!existsSync18(join18(planningDir2, STATE_FILE2))) {
2251
2726
  return "No STATE.md found. Run /fd-map-codebase then /fd-new-feature to start a feature.";
2252
2727
  }
2253
2728
  return "Plan not confirmed. Run /fd-plan and confirm to enable execution.";
2254
2729
  }
2255
2730
  function getBlockMessage(planningDir2) {
2256
- if (!existsSync16(join16(planningDir2, STATE_FILE2))) {
2731
+ if (!existsSync18(join18(planningDir2, STATE_FILE2))) {
2257
2732
  return "No STATE.md found. Run /fd-map-codebase then /fd-new-feature to start a feature.";
2258
2733
  }
2259
2734
  return "Plan not confirmed. Run /fd-plan and confirm to enable execution.";
2260
2735
  }
2261
2736
 
2262
2737
  // src/hooks/tool-guard.ts
2263
- import { existsSync as existsSync17, readFileSync as readFileSync17 } from "fs";
2264
- import { join as join17 } from "path";
2738
+ import { existsSync as existsSync19, readFileSync as readFileSync18 } from "fs";
2739
+ import { join as join19 } from "path";
2265
2740
  var IS_ENABLED = () => process.env.FLOWDECK_TOOL_GUARD_ENABLED === "on";
2266
2741
  var BLOCKED_PATTERNS = {
2267
2742
  read: [".env", ".pem", ".key", ".secret"],
2268
2743
  write: ["node_modules"],
2269
2744
  bash: ["rm -rf"]
2270
2745
  };
2271
- function isBlocked(tool13, args) {
2272
- const patterns = BLOCKED_PATTERNS[tool13];
2746
+ function isBlocked(tool14, args) {
2747
+ const patterns = BLOCKED_PATTERNS[tool14];
2273
2748
  if (!patterns)
2274
2749
  return null;
2275
- if (tool13 === "bash") {
2750
+ if (tool14 === "bash") {
2276
2751
  const cmd = args.command;
2277
2752
  if (!cmd)
2278
2753
  return null;
@@ -2283,7 +2758,7 @@ function isBlocked(tool13, args) {
2283
2758
  }
2284
2759
  return null;
2285
2760
  }
2286
- if (tool13 === "read") {
2761
+ if (tool14 === "read") {
2287
2762
  const filePath = args.filePath;
2288
2763
  if (!filePath)
2289
2764
  return null;
@@ -2294,7 +2769,7 @@ function isBlocked(tool13, args) {
2294
2769
  }
2295
2770
  return null;
2296
2771
  }
2297
- if (tool13 === "write") {
2772
+ if (tool14 === "write") {
2298
2773
  const filePath = args.filePath;
2299
2774
  if (!filePath)
2300
2775
  return null;
@@ -2308,11 +2783,11 @@ function isBlocked(tool13, args) {
2308
2783
  return null;
2309
2784
  }
2310
2785
  function checkArchConstraint(directory, filePath) {
2311
- const constraintsPath = join17(codebaseDir(directory), "CONSTRAINTS.md");
2312
- if (!existsSync17(constraintsPath))
2786
+ const constraintsPath = join19(codebaseDir(directory), "CONSTRAINTS.md");
2787
+ if (!existsSync19(constraintsPath))
2313
2788
  return null;
2314
2789
  try {
2315
- const content = readFileSync17(constraintsPath, "utf-8");
2790
+ const content = readFileSync18(constraintsPath, "utf-8");
2316
2791
  const match = content.match(/## Forbidden Paths\n([\s\S]*?)(?:\n##|$)/);
2317
2792
  if (!match)
2318
2793
  return null;
@@ -2353,9 +2828,9 @@ function isUiDesignApprovalRequired(directory) {
2353
2828
  return !(state.design_stage === "handoff_complete" && state.design_approved);
2354
2829
  }
2355
2830
  const planPath = phasePlanPath(directory, state.phase || 1);
2356
- if (!existsSync17(planPath))
2831
+ if (!existsSync19(planPath))
2357
2832
  return false;
2358
- const planContent = readFileSync17(planPath, "utf-8");
2833
+ const planContent = readFileSync18(planPath, "utf-8");
2359
2834
  if (!isUiHeavyTask(planContent))
2360
2835
  return false;
2361
2836
  return !(state.design_stage === "handoff_complete" && state.design_approved);
@@ -2384,18 +2859,18 @@ async function toolGuardHook(ctx, input, output) {
2384
2859
  }
2385
2860
 
2386
2861
  // src/hooks/session-start.ts
2387
- import { existsSync as existsSync18, readFileSync as readFileSync18 } from "fs";
2862
+ import { existsSync as existsSync20, readFileSync as readFileSync19 } from "fs";
2388
2863
  async function sessionStartHook(ctx) {
2389
2864
  const planningDir2 = ctx.directory + "/.planning";
2390
2865
  const codebaseDirectory = codebaseDir(ctx.directory);
2391
2866
  const workspaceRoot = findWorkspaceRoot(ctx.directory);
2392
2867
  const config = workspaceRoot ? getWorkspaceConfig(ctx.directory) : null;
2393
- if (!existsSync18(planningDir2)) {
2868
+ if (!existsSync20(planningDir2)) {
2394
2869
  return {
2395
2870
  flowdeck_phase: null,
2396
2871
  flowdeck_status: "no_plan",
2397
2872
  flowdeck_warning: "Run /fd-map-codebase to index the codebase, then /fd-new-feature to start a feature.",
2398
- flowdeck_has_codebase: existsSync18(codebaseDirectory),
2873
+ flowdeck_has_codebase: existsSync20(codebaseDirectory),
2399
2874
  ...workspaceRoot && config?.sub_repos ? {
2400
2875
  flowdeck_workspace_root: workspaceRoot,
2401
2876
  flowdeck_sub_repos: config.sub_repos,
@@ -2406,7 +2881,7 @@ async function sessionStartHook(ctx) {
2406
2881
  }
2407
2882
  try {
2408
2883
  const stateFilePath = statePath(ctx.directory);
2409
- const content = readFileSync18(stateFilePath, "utf-8");
2884
+ const content = readFileSync19(stateFilePath, "utf-8");
2410
2885
  const state = parseState(content);
2411
2886
  const currentPhase = state["current_phase"] || {};
2412
2887
  const result = {
@@ -2414,7 +2889,7 @@ async function sessionStartHook(ctx) {
2414
2889
  flowdeck_status: currentPhase["status"] ?? null,
2415
2890
  flowdeck_steps_pending: currentPhase["steps_pending"] ?? null,
2416
2891
  flowdeck_last_action: currentPhase["last_action"] ?? null,
2417
- flowdeck_has_codebase: existsSync18(codebaseDirectory)
2892
+ flowdeck_has_codebase: existsSync20(codebaseDirectory)
2418
2893
  };
2419
2894
  if (workspaceRoot && config?.sub_repos && config.sub_repos.length > 0) {
2420
2895
  result.flowdeck_workspace_root = workspaceRoot;
@@ -2428,7 +2903,7 @@ async function sessionStartHook(ctx) {
2428
2903
  flowdeck_phase: null,
2429
2904
  flowdeck_status: "error",
2430
2905
  flowdeck_warning: "State file unreadable. Continuing without flowdeck context.",
2431
- flowdeck_has_codebase: existsSync18(codebaseDirectory)
2906
+ flowdeck_has_codebase: existsSync20(codebaseDirectory)
2432
2907
  };
2433
2908
  if (workspaceRoot && config?.sub_repos && config.sub_repos.length > 0) {
2434
2909
  result.flowdeck_workspace_root = workspaceRoot;
@@ -2560,13 +3035,13 @@ class NotificationController {
2560
3035
  return this.lastNotifiedKey;
2561
3036
  }
2562
3037
  }
2563
- function notifyPermissionNeeded(tool13) {
2564
- notify("FlowDeck Permission Required", `Agent needs approval to use tool: ${tool13}`, "critical");
3038
+ function notifyPermissionNeeded(tool14) {
3039
+ notify("FlowDeck Permission Required", `Agent needs approval to use tool: ${tool14}`, "critical");
2565
3040
  }
2566
3041
 
2567
3042
  // src/hooks/patch-trust.ts
2568
- import { existsSync as existsSync19, readFileSync as readFileSync19 } from "fs";
2569
- import { join as join18 } from "path";
3043
+ import { existsSync as existsSync21, readFileSync as readFileSync20 } from "fs";
3044
+ import { join as join20 } from "path";
2570
3045
  var HIGH_RISK_KEYWORDS = [
2571
3046
  "password",
2572
3047
  "secret",
@@ -2588,11 +3063,11 @@ var HIGH_RISK_KEYWORDS = [
2588
3063
  "privilege"
2589
3064
  ];
2590
3065
  function loadFailedPaths(directory) {
2591
- const p = join18(codebaseDir(directory), "FAILURES.json");
2592
- if (!existsSync19(p))
3066
+ const p = join20(codebaseDir(directory), "FAILURES.json");
3067
+ if (!existsSync21(p))
2593
3068
  return [];
2594
3069
  try {
2595
- const data = JSON.parse(readFileSync19(p, "utf-8"));
3070
+ const data = JSON.parse(readFileSync20(p, "utf-8"));
2596
3071
  return (data.entries ?? []).flatMap((e) => e.affected_paths ?? []);
2597
3072
  } catch {
2598
3073
  return [];
@@ -2645,8 +3120,8 @@ async function patchTrustHook(ctx, input, output) {
2645
3120
  }
2646
3121
 
2647
3122
  // src/hooks/decision-trace-hook.ts
2648
- import { existsSync as existsSync20, mkdirSync as mkdirSync9, appendFileSync as appendFileSync3 } from "fs";
2649
- import { join as join19 } from "path";
3123
+ import { existsSync as existsSync22, mkdirSync as mkdirSync11, appendFileSync as appendFileSync4 } from "fs";
3124
+ import { join as join21 } from "path";
2650
3125
  async function decisionTraceHook(ctx, input, output) {
2651
3126
  if (input.tool !== "write" && input.tool !== "edit")
2652
3127
  return;
@@ -2655,8 +3130,8 @@ async function decisionTraceHook(ctx, input, output) {
2655
3130
  return;
2656
3131
  const base = codebaseDir(ctx.directory);
2657
3132
  try {
2658
- if (!existsSync20(base))
2659
- mkdirSync9(base, { recursive: true });
3133
+ if (!existsSync22(base))
3134
+ mkdirSync11(base, { recursive: true });
2660
3135
  const entry = {
2661
3136
  timestamp: new Date().toISOString(),
2662
3137
  file_path: filePath,
@@ -2668,14 +3143,14 @@ async function decisionTraceHook(ctx, input, output) {
2668
3143
  risk_level: "unknown",
2669
3144
  auto_recorded: true
2670
3145
  };
2671
- appendFileSync3(join19(base, "DECISIONS.jsonl"), JSON.stringify(entry) + `
3146
+ appendFileSync4(join21(base, "DECISIONS.jsonl"), JSON.stringify(entry) + `
2672
3147
  `, "utf-8");
2673
3148
  } catch {}
2674
3149
  }
2675
3150
 
2676
3151
  // src/services/approval-manager.ts
2677
- import { existsSync as existsSync21, readFileSync as readFileSync20, writeFileSync as writeFileSync11, mkdirSync as mkdirSync10 } from "fs";
2678
- import { join as join20 } from "path";
3152
+ import { existsSync as existsSync23, readFileSync as readFileSync21, writeFileSync as writeFileSync12, mkdirSync as mkdirSync12 } from "fs";
3153
+ import { join as join22 } from "path";
2679
3154
  var APPROVAL_TTL_MS = 30 * 60 * 1000;
2680
3155
  var SENSITIVE_PATTERNS = [
2681
3156
  /auth/i,
@@ -2712,14 +3187,14 @@ function isSensitivePath(filePath) {
2712
3187
  return SENSITIVE_PATTERNS.some((p) => p.test(filePath));
2713
3188
  }
2714
3189
  function approvalsPath(dir) {
2715
- return join20(codebaseDir(dir), "APPROVALS.json");
3190
+ return join22(codebaseDir(dir), "APPROVALS.json");
2716
3191
  }
2717
3192
  function loadStore(dir) {
2718
3193
  const p = approvalsPath(dir);
2719
- if (!existsSync21(p))
3194
+ if (!existsSync23(p))
2720
3195
  return { requests: [] };
2721
3196
  try {
2722
- return JSON.parse(readFileSync20(p, "utf-8"));
3197
+ return JSON.parse(readFileSync21(p, "utf-8"));
2723
3198
  } catch {
2724
3199
  return { requests: [] };
2725
3200
  }
@@ -2737,8 +3212,8 @@ async function approvalHook(context, toolInput, output) {
2737
3212
  if (!ENABLED2)
2738
3213
  return;
2739
3214
  const dir = context.directory ?? process.cwd();
2740
- const tool13 = toolInput.name ?? toolInput.tool ?? "";
2741
- if (!WRITE_TOOLS.has(tool13))
3215
+ const tool14 = toolInput.name ?? toolInput.tool ?? "";
3216
+ if (!WRITE_TOOLS.has(tool14))
2742
3217
  return;
2743
3218
  const args = output.args ?? {};
2744
3219
  const filePath = String(args.path ?? args.file_path ?? args.filename ?? "");
@@ -2755,8 +3230,8 @@ async function approvalHook(context, toolInput, output) {
2755
3230
  }
2756
3231
 
2757
3232
  // src/services/event-logger.ts
2758
- import { existsSync as existsSync22, mkdirSync as mkdirSync11, appendFileSync as appendFileSync4, readFileSync as readFileSync21, writeFileSync as writeFileSync12, renameSync, unlinkSync, statSync } from "fs";
2759
- import { join as join21, resolve as resolve2, sep } from "path";
3233
+ import { existsSync as existsSync24, mkdirSync as mkdirSync13, appendFileSync as appendFileSync5, readFileSync as readFileSync22, writeFileSync as writeFileSync13, renameSync, unlinkSync, statSync } from "fs";
3234
+ import { join as join23, resolve as resolve2, sep } from "path";
2760
3235
  var SENSITIVE_KEYS = [
2761
3236
  "password",
2762
3237
  "token",
@@ -2775,6 +3250,8 @@ var SENSITIVE_KEYS = [
2775
3250
  "refresh_token"
2776
3251
  ];
2777
3252
  var currentAgent = null;
3253
+ var persistenceFailed = false;
3254
+ var lastPersistenceError = null;
2778
3255
  function getCurrentAgent() {
2779
3256
  return currentAgent;
2780
3257
  }
@@ -2818,43 +3295,59 @@ function isValidDirectory(directory) {
2818
3295
  }
2819
3296
  function logEvent(directory, event, log) {
2820
3297
  if (process.env.FLOWDECK_EVENT_LOG === "off")
2821
- return;
2822
- if (!isValidDirectory(directory))
2823
- return;
2824
- const logDir = join21(directory, ".opencode");
2825
- const logPath = join21(logDir, "flowdeck-events.jsonl");
3298
+ return true;
3299
+ if (!isValidDirectory(directory)) {
3300
+ persistenceFailed = true;
3301
+ lastPersistenceError = "Invalid directory";
3302
+ return false;
3303
+ }
3304
+ const logDir = join23(directory, ".opencode");
3305
+ const logPath = join23(logDir, "flowdeck-events.jsonl");
2826
3306
  try {
2827
- if (!existsSync22(logDir)) {
2828
- mkdirSync11(logDir, { recursive: true });
3307
+ if (!existsSync24(logDir)) {
3308
+ mkdirSync13(logDir, { recursive: true });
2829
3309
  }
2830
- appendFileSync4(logPath, JSON.stringify(event) + `
3310
+ appendFileSync5(logPath, JSON.stringify(event) + `
2831
3311
  `, "utf-8");
2832
- rotateLogFile(logPath);
3312
+ rotateLogFile(logPath, log);
2833
3313
  if (log) {
2834
3314
  log(formatEventForStderr(event));
2835
3315
  }
2836
- } catch {}
3316
+ return true;
3317
+ } catch (error) {
3318
+ persistenceFailed = true;
3319
+ lastPersistenceError = error instanceof Error ? error.message : String(error);
3320
+ if (log) {
3321
+ log(`[event-logger] failed to write event: ${lastPersistenceError}`);
3322
+ }
3323
+ return false;
3324
+ }
2837
3325
  }
2838
- function rotateLogFile(logPath) {
3326
+ function rotateLogFile(logPath, log) {
2839
3327
  try {
2840
3328
  const stats = statSync(logPath);
2841
3329
  if (stats.size < 5000)
2842
3330
  return;
2843
- const content = readFileSync21(logPath, "utf-8");
3331
+ const content = readFileSync22(logPath, "utf-8");
2844
3332
  const lines = content.split(`
2845
3333
  `).filter((l) => l.trim());
2846
3334
  if (lines.length > 1000) {
2847
3335
  const backupPath = logPath + ".backup";
2848
3336
  renameSync(logPath, backupPath);
2849
3337
  const keep = lines.slice(-1000);
2850
- writeFileSync12(logPath, keep.join(`
3338
+ writeFileSync13(logPath, keep.join(`
2851
3339
  `) + `
2852
3340
  `, "utf-8");
2853
3341
  try {
2854
3342
  unlinkSync(backupPath);
2855
3343
  } catch {}
2856
3344
  }
2857
- } catch {}
3345
+ } catch (error) {
3346
+ if (log) {
3347
+ const message = error instanceof Error ? error.message : String(error);
3348
+ log(`[event-logger] log rotation failed: ${message}`);
3349
+ }
3350
+ }
2858
3351
  }
2859
3352
  function formatEventForStderr(event) {
2860
3353
  const time = event.timestamp.slice(11, 23);
@@ -2935,7 +3428,7 @@ function cleanupStaleToolStartTimes() {
2935
3428
  }
2936
3429
  }
2937
3430
  }
2938
- function createEventLogHooks(appLog) {
3431
+ function createEventLogHooks(appLog, onToolAfter) {
2939
3432
  return {
2940
3433
  async before(ctx, toolInput, toolOutput) {
2941
3434
  const toolName = toolInput.tool ?? toolInput.name ?? "unknown";
@@ -2988,7 +3481,10 @@ function createEventLogHooks(appLog) {
2988
3481
  error,
2989
3482
  session_id: sessionId
2990
3483
  };
2991
- logEvent(ctx.directory, event, appLog);
3484
+ if (onToolAfter) {
3485
+ onToolAfter(toolName, args, toolOutput, sessionId, status);
3486
+ }
3487
+ return logEvent(ctx.directory, event, appLog);
2992
3488
  },
2993
3489
  async session(ctx, event) {
2994
3490
  const type = event?.type ?? "";
@@ -3039,6 +3535,324 @@ function extractAgentFromEvent(props) {
3039
3535
  return "unknown";
3040
3536
  }
3041
3537
 
3538
+ // src/services/loop-detector.ts
3539
+ import { resolve as resolve3 } from "path";
3540
+ var NON_MUTATING_TOOLS = new Set([
3541
+ "read",
3542
+ "view",
3543
+ "bash",
3544
+ "shell",
3545
+ "grep",
3546
+ "glob",
3547
+ "search"
3548
+ ]);
3549
+ var TRANSIENT_ERROR_KEYWORDS = [
3550
+ "timeout",
3551
+ "econnrefused",
3552
+ "econnreset",
3553
+ "etimedout",
3554
+ "locked",
3555
+ "busy",
3556
+ "temporarily unavailable"
3557
+ ];
3558
+ var DEFAULT_CONFIG = {
3559
+ enabled: true,
3560
+ maxRepeats: 2,
3561
+ similarityThreshold: 0.9,
3562
+ historySize: 20
3563
+ };
3564
+ function djb2Hash(input) {
3565
+ let hash = 5381;
3566
+ for (let i = 0;i < input.length; i++) {
3567
+ hash = hash * 33 ^ input.charCodeAt(i);
3568
+ }
3569
+ return (hash >>> 0).toString(16);
3570
+ }
3571
+ function hashOutput(output) {
3572
+ if (typeof output === "string") {
3573
+ const truncated2 = output.length > 10240 ? output.slice(0, 10240) : output;
3574
+ return djb2Hash(truncated2);
3575
+ }
3576
+ let serialized;
3577
+ try {
3578
+ serialized = JSON.stringify(output);
3579
+ } catch {
3580
+ serialized = String(output);
3581
+ }
3582
+ const truncated = serialized.length > 10240 ? serialized.slice(0, 10240) : serialized;
3583
+ return djb2Hash(truncated);
3584
+ }
3585
+ function lineSimilarity(a, b) {
3586
+ const linesA = new Set(a.split(`
3587
+ `));
3588
+ const linesB = new Set(b.split(`
3589
+ `));
3590
+ const intersection = new Set([...linesA].filter((x) => linesB.has(x)));
3591
+ const union = new Set([...linesA, ...linesB]);
3592
+ return union.size === 0 ? 1 : intersection.size / union.size;
3593
+ }
3594
+ function getOutputPreview(output) {
3595
+ if (output === null || output === undefined)
3596
+ return "";
3597
+ if (typeof output === "string")
3598
+ return output.slice(0, 200);
3599
+ try {
3600
+ return JSON.stringify(output).slice(0, 200);
3601
+ } catch {
3602
+ return String(output).slice(0, 200);
3603
+ }
3604
+ }
3605
+ function stableStringify(obj) {
3606
+ if (obj === null || typeof obj !== "object")
3607
+ return JSON.stringify(obj);
3608
+ if (Array.isArray(obj)) {
3609
+ return `[${obj.map(stableStringify).join(",")}]`;
3610
+ }
3611
+ const record = obj;
3612
+ const keys = Object.keys(record).sort();
3613
+ const pairs = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`);
3614
+ return `{${pairs.join(",")}}`;
3615
+ }
3616
+ function resolveEnvVars(command) {
3617
+ return command.replace(/\$RTK_BIN\b/gi, "rtk").replace(/\$HOME\b/gi, "~").replace(/\$USER\b/gi, "user");
3618
+ }
3619
+ function collapseWhitespace(input) {
3620
+ return input.replace(/\s+/g, " ").trim();
3621
+ }
3622
+ function normalizeAction(toolName, args) {
3623
+ const tool14 = toolName.toLowerCase();
3624
+ if (tool14 === "bash" || tool14 === "shell") {
3625
+ const command = typeof args.command === "string" ? args.command : "";
3626
+ const normalized = collapseWhitespace(resolveEnvVars(command)).toLowerCase();
3627
+ return `shell:${normalized}`;
3628
+ }
3629
+ if (tool14 === "read" || tool14 === "view") {
3630
+ const filePath = typeof args.filePath === "string" ? args.filePath : "";
3631
+ try {
3632
+ return `${tool14}:${resolve3(filePath || "")}`;
3633
+ } catch {
3634
+ return `${tool14}:${filePath}`;
3635
+ }
3636
+ }
3637
+ if (tool14 === "write" || tool14 === "edit") {
3638
+ const filePath = typeof args.filePath === "string" ? args.filePath : "";
3639
+ try {
3640
+ return `${tool14}:${resolve3(filePath || "")}`;
3641
+ } catch {
3642
+ return `${tool14}:${filePath}`;
3643
+ }
3644
+ }
3645
+ if (tool14 === "grep" || tool14 === "glob" || tool14 === "search") {
3646
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
3647
+ const path = typeof args.path === "string" ? args.path : "";
3648
+ return `${tool14}:${pattern}:${resolve3(path || ".")}`;
3649
+ }
3650
+ const sorted = stableStringify(args);
3651
+ return `${tool14}:${sorted}`;
3652
+ }
3653
+ function classifyObservation(toolName, previous, output, status, similarityThreshold) {
3654
+ const outputPreview = getOutputPreview(output);
3655
+ const tool14 = toolName.toLowerCase();
3656
+ if (status === "blocked") {
3657
+ return { observation: "same_result", outputHash: hashOutput(output), outputPreview };
3658
+ }
3659
+ if (status === "error") {
3660
+ const errorMessage = output && typeof output === "object" && "error" in output ? String(output.error) : typeof output === "string" ? output : "";
3661
+ const lower = errorMessage.toLowerCase();
3662
+ const isTransient = TRANSIENT_ERROR_KEYWORDS.some((k) => lower.includes(k));
3663
+ return {
3664
+ observation: isTransient ? "transient_failure" : "same_result",
3665
+ outputHash: djb2Hash(errorMessage),
3666
+ outputPreview: errorMessage.slice(0, 200)
3667
+ };
3668
+ }
3669
+ if (tool14 === "write" || tool14 === "edit") {
3670
+ const contentHash = hashOutput(output);
3671
+ return { observation: "new_information", outputHash: contentHash, outputPreview };
3672
+ }
3673
+ const outputHash = hashOutput(output);
3674
+ if (!previous) {
3675
+ return { observation: "new_information", outputHash, outputPreview };
3676
+ }
3677
+ if (outputHash === previous.outputHash) {
3678
+ return { observation: "same_result", outputHash, outputPreview };
3679
+ }
3680
+ if (NON_MUTATING_TOOLS.has(tool14)) {
3681
+ const similarity = lineSimilarity(outputPreview, previous.outputPreview);
3682
+ if (similarity >= similarityThreshold) {
3683
+ return { observation: "no_progress", outputHash, outputPreview };
3684
+ }
3685
+ }
3686
+ return { observation: "new_information", outputHash, outputPreview };
3687
+ }
3688
+ function redactForDisplay(toolName, normalizedKey) {
3689
+ const tool14 = toolName.toLowerCase();
3690
+ if (tool14 === "bash" || tool14 === "shell") {
3691
+ const idx2 = normalizedKey.indexOf(":");
3692
+ const cmd = idx2 >= 0 ? normalizedKey.slice(idx2 + 1) : normalizedKey;
3693
+ const preview = cmd.slice(0, 30);
3694
+ const hash = djb2Hash(cmd);
3695
+ return `${tool14}:"${preview}" (hash: ${hash})`;
3696
+ }
3697
+ const idx = normalizedKey.indexOf(":");
3698
+ if (idx >= 0) {
3699
+ const body = normalizedKey.slice(idx + 1);
3700
+ if (body.startsWith("/") || body.startsWith(".") || body.includes("/")) {
3701
+ return `${tool14}:"${body}"`;
3702
+ }
3703
+ const preview = body.slice(0, 30);
3704
+ const hash = djb2Hash(body);
3705
+ return `${tool14}:"${preview}" (hash: ${hash})`;
3706
+ }
3707
+ return `${tool14}:"${normalizedKey}"`;
3708
+ }
3709
+
3710
+ class LoopDetector {
3711
+ config;
3712
+ appLog;
3713
+ history = new Map;
3714
+ persistenceHealthy = true;
3715
+ persistenceWarningLogged = new Set;
3716
+ constructor(config, appLog) {
3717
+ this.config = { ...DEFAULT_CONFIG, ...config };
3718
+ this.appLog = appLog;
3719
+ }
3720
+ setPersistenceHealthy(healthy) {
3721
+ if (this.persistenceHealthy === healthy)
3722
+ return;
3723
+ this.persistenceHealthy = healthy;
3724
+ if (!healthy && this.appLog) {
3725
+ this.appLog("[loop-guard] Event log persistence failed — loop detection running in-memory only. History will be lost on restart.");
3726
+ }
3727
+ }
3728
+ getHistory(sessionId) {
3729
+ const sessionHistory = this.history.get(sessionId);
3730
+ if (!sessionHistory)
3731
+ return [];
3732
+ return Array.from(sessionHistory.values()).sort((a, b) => a.timestamp - b.timestamp);
3733
+ }
3734
+ clearSession(sessionId) {
3735
+ this.history.delete(sessionId);
3736
+ }
3737
+ checkBefore(toolName, args, sessionId) {
3738
+ if (!this.config.enabled) {
3739
+ return { action: "allow" };
3740
+ }
3741
+ if (!this.persistenceHealthy && !this.persistenceWarningLogged.has(sessionId)) {
3742
+ if (this.persistenceWarningLogged.size >= 1000) {
3743
+ this.persistenceWarningLogged.clear();
3744
+ }
3745
+ this.persistenceWarningLogged.add(sessionId);
3746
+ if (this.appLog) {
3747
+ this.appLog("[loop-guard] Event log persistence failed — loop detection running in-memory only. History will be lost on restart.");
3748
+ }
3749
+ }
3750
+ const normalizedKey = normalizeAction(toolName, args);
3751
+ const record = this.getSessionRecord(sessionId, normalizedKey);
3752
+ if (!record) {
3753
+ return { action: "allow" };
3754
+ }
3755
+ const maxRepeats = this.config.maxRepeats;
3756
+ if (record.consecutiveSameResultCount >= maxRepeats) {
3757
+ const reason = "same_result";
3758
+ const escalationMessage = this.buildEscalationMessage(toolName, normalizedKey, record.status, record.consecutiveSameResultCount, reason);
3759
+ if (this.appLog) {
3760
+ this.appLog(`[loop-guard] blocked repeat of "${redactForDisplay(toolName, normalizedKey)}" — already executed ${record.consecutiveSameResultCount} times with same result`);
3761
+ }
3762
+ return { action: "block", reason, escalationMessage };
3763
+ }
3764
+ if (record.callCount >= 2 && this.isNoProgressMarker(record)) {
3765
+ const reason = "no_progress";
3766
+ const escalationMessage = this.buildEscalationMessage(toolName, normalizedKey, record.status, record.callCount, reason);
3767
+ if (this.appLog) {
3768
+ this.appLog(`[loop-guard] blocked repeat of "${redactForDisplay(toolName, normalizedKey)}" — already executed ${record.callCount} times with no progress`);
3769
+ }
3770
+ return { action: "block", reason, escalationMessage };
3771
+ }
3772
+ return { action: "allow" };
3773
+ }
3774
+ recordAfter(toolName, args, output, sessionId, status = "success") {
3775
+ if (!this.config.enabled)
3776
+ return;
3777
+ const normalizedKey = normalizeAction(toolName, args);
3778
+ const previous = this.getSessionRecord(sessionId, normalizedKey);
3779
+ const { observation, outputHash, outputPreview } = classifyObservation(toolName, previous, output, status, this.config.similarityThreshold);
3780
+ let record;
3781
+ if (!previous) {
3782
+ record = {
3783
+ toolName,
3784
+ normalizedKey,
3785
+ args,
3786
+ outputHash,
3787
+ outputPreview,
3788
+ status,
3789
+ timestamp: Date.now(),
3790
+ callCount: 1,
3791
+ consecutiveSameResultCount: observation === "transient_failure" ? 1 : 0
3792
+ };
3793
+ } else {
3794
+ let nextConsecutive = previous.consecutiveSameResultCount;
3795
+ if (observation === "same_result" || observation === "transient_failure" || observation === "no_progress") {
3796
+ nextConsecutive = previous.consecutiveSameResultCount + 1;
3797
+ } else {
3798
+ nextConsecutive = 0;
3799
+ }
3800
+ record = {
3801
+ toolName,
3802
+ normalizedKey,
3803
+ args,
3804
+ outputHash,
3805
+ outputPreview,
3806
+ status,
3807
+ timestamp: Date.now(),
3808
+ callCount: previous.callCount + 1,
3809
+ consecutiveSameResultCount: nextConsecutive
3810
+ };
3811
+ }
3812
+ if (observation === "transient_failure" && this.appLog) {
3813
+ const transientCount = record.consecutiveSameResultCount;
3814
+ if (transientCount <= 3) {
3815
+ this.appLog(`[loop-guard] transient failure detected for "${toolName}" — allowing retry ${transientCount}/3`);
3816
+ }
3817
+ }
3818
+ this.setSessionRecord(sessionId, normalizedKey, record);
3819
+ }
3820
+ getSessionRecord(sessionId, normalizedKey) {
3821
+ return this.history.get(sessionId)?.get(normalizedKey);
3822
+ }
3823
+ setSessionRecord(sessionId, normalizedKey, record) {
3824
+ let sessionHistory = this.history.get(sessionId);
3825
+ if (!sessionHistory) {
3826
+ sessionHistory = new Map;
3827
+ this.history.set(sessionId, sessionHistory);
3828
+ }
3829
+ sessionHistory.set(normalizedKey, record);
3830
+ if (sessionHistory.size > this.config.historySize) {
3831
+ this.evictOldest(sessionHistory);
3832
+ }
3833
+ }
3834
+ evictOldest(sessionHistory) {
3835
+ let oldestKey;
3836
+ let oldestTime = Infinity;
3837
+ for (const [key, value] of sessionHistory.entries()) {
3838
+ if (value.timestamp < oldestTime) {
3839
+ oldestTime = value.timestamp;
3840
+ oldestKey = key;
3841
+ }
3842
+ }
3843
+ if (oldestKey !== undefined) {
3844
+ sessionHistory.delete(oldestKey);
3845
+ }
3846
+ }
3847
+ isNoProgressMarker(record) {
3848
+ return record.consecutiveSameResultCount >= 1 && record.callCount >= 2;
3849
+ }
3850
+ buildEscalationMessage(toolName, normalizedKey, status, count, _reason) {
3851
+ const normalizedPreview = redactForDisplay(toolName, normalizedKey);
3852
+ return `[FlowDeck Loop Guard] You already ran \`${normalizedPreview}\` and got the same result (status: ${status}, repeats: ${count}). Do NOT repeat it. Choose a different approach, inspect the tool behavior, or ask the human for guidance.`;
3853
+ }
3854
+ }
3855
+
3042
3856
  // src/hooks/context-window-monitor.ts
3043
3857
  var CONTEXT_WARNING_THRESHOLD = 0.7;
3044
3858
  var DEFAULT_CONTEXT_LIMIT = Number(process.env.FLOWDECK_CONTEXT_LIMIT) || 200000;
@@ -3090,8 +3904,8 @@ function createContextWindowMonitorHook() {
3090
3904
  }
3091
3905
 
3092
3906
  // src/hooks/shell-env-hook.ts
3093
- import { existsSync as existsSync23, readFileSync as readFileSync22 } from "fs";
3094
- import { join as join22 } from "path";
3907
+ import { existsSync as existsSync25, readFileSync as readFileSync23 } from "fs";
3908
+ import { join as join24 } from "path";
3095
3909
  import { createRequire } from "module";
3096
3910
  var _version;
3097
3911
  function getVersion() {
@@ -3127,7 +3941,7 @@ var MARKER_TO_LANG = {
3127
3941
  };
3128
3942
  function detectPackageManager(root) {
3129
3943
  for (const [lockfile, pm] of Object.entries(LOCKFILE_TO_PM)) {
3130
- if (existsSync23(join22(root, lockfile)))
3944
+ if (existsSync25(join24(root, lockfile)))
3131
3945
  return pm;
3132
3946
  }
3133
3947
  return;
@@ -3136,7 +3950,7 @@ function detectLanguages(root) {
3136
3950
  const langs = [];
3137
3951
  const seen = new Set;
3138
3952
  for (const [marker, lang] of Object.entries(MARKER_TO_LANG)) {
3139
- if (!seen.has(lang) && existsSync23(join22(root, marker))) {
3953
+ if (!seen.has(lang) && existsSync25(join24(root, marker))) {
3140
3954
  langs.push(lang);
3141
3955
  seen.add(lang);
3142
3956
  }
@@ -3144,11 +3958,11 @@ function detectLanguages(root) {
3144
3958
  return langs;
3145
3959
  }
3146
3960
  function readCurrentPhase(root) {
3147
- const statePath2 = join22(root, ".planning", "STATE.md");
3148
- if (!existsSync23(statePath2))
3961
+ const statePath3 = join24(root, ".planning", "STATE.md");
3962
+ if (!existsSync25(statePath3))
3149
3963
  return;
3150
3964
  try {
3151
- const content = readFileSync22(statePath2, "utf-8");
3965
+ const content = readFileSync23(statePath3, "utf-8");
3152
3966
  const match = content.match(/phase:\s*(\S+)/i);
3153
3967
  return match?.[1];
3154
3968
  } catch {
@@ -3273,8 +4087,8 @@ function createSessionIdleHook(client, tracker) {
3273
4087
  }
3274
4088
 
3275
4089
  // src/hooks/compaction-hook.ts
3276
- import { existsSync as existsSync24, readFileSync as readFileSync23 } from "fs";
3277
- import { join as join23 } from "path";
4090
+ import { existsSync as existsSync26, readFileSync as readFileSync24 } from "fs";
4091
+ import { join as join25 } from "path";
3278
4092
  var STRUCTURED_SUMMARY_PROMPT = `
3279
4093
  When summarizing this session, you MUST include the following sections:
3280
4094
 
@@ -3315,10 +4129,10 @@ For each: agent name, status, description, session_id.
3315
4129
  var _lastInjected = new Map;
3316
4130
  function readPlanningState2(directory) {
3317
4131
  const sp = statePath(directory);
3318
- if (!existsSync24(sp))
4132
+ if (!existsSync26(sp))
3319
4133
  return null;
3320
4134
  try {
3321
- const content = readFileSync23(sp, "utf-8");
4135
+ const content = readFileSync24(sp, "utf-8");
3322
4136
  const parsed = parseState(content);
3323
4137
  const version = typeof parsed.summaryVersion === "number" ? parsed.summaryVersion : 0;
3324
4138
  return { content: content.slice(0, 1500), version };
@@ -3347,15 +4161,15 @@ function createCompactionHook(ctx, tracker) {
3347
4161
  sections.push(`_State unchanged since last compaction. summaryVersion=${currentStateVersion}_`);
3348
4162
  sections.push("");
3349
4163
  }
3350
- const indexPath2 = join23(ctx.directory, ".planning", "CODEBASE_INDEX.md");
3351
- if (indexChanged && existsSync24(indexPath2)) {
4164
+ const indexPath2 = join25(ctx.directory, ".planning", "CODEBASE_INDEX.md");
4165
+ if (indexChanged && existsSync26(indexPath2)) {
3352
4166
  try {
3353
- const indexContent = readFileSync23(indexPath2, "utf-8");
4167
+ const indexContent = readFileSync24(indexPath2, "utf-8");
3354
4168
  const indexSummary = "\n## Codebase Index\n```\n" + indexContent.slice(0, 800) + "\n```\n";
3355
4169
  sections.push(indexSummary);
3356
4170
  sections.push("");
3357
4171
  } catch {}
3358
- } else if (existsSync24(indexPath2)) {
4172
+ } else if (existsSync26(indexPath2)) {
3359
4173
  sections.push(`## Codebase Index (unchanged, v${currentIndexVersion})`);
3360
4174
  sections.push(`_Index unchanged since last compaction. summaryVersion=${currentIndexVersion}_`);
3361
4175
  sections.push("");
@@ -3381,7 +4195,7 @@ function createCompactionHook(ctx, tracker) {
3381
4195
  }
3382
4196
 
3383
4197
  // src/hooks/orchestrator-guard-hook.ts
3384
- var DISABLED = process.env.FLOWDECK_ORCHESTRATOR_GUARD !== "on";
4198
+ var DISABLED = process.env.FLOWDECK_ORCHESTRATOR_GUARD === "off";
3385
4199
  var BLOCKED_TOOLS = new Set([
3386
4200
  "write_file",
3387
4201
  "write",
@@ -3398,20 +4212,63 @@ var BLOCKED_TOOLS = new Set([
3398
4212
  "execute",
3399
4213
  "run_command",
3400
4214
  "terminal",
3401
- "shell"
4215
+ "shell",
4216
+ "python",
4217
+ "run_python",
4218
+ "js",
4219
+ "run_js",
4220
+ "npm",
4221
+ "pnpm",
4222
+ "yarn",
4223
+ "bun",
4224
+ "cargo",
4225
+ "go",
4226
+ "make",
4227
+ "cmake",
4228
+ "docker",
4229
+ "kubectl",
4230
+ "terraform",
4231
+ "pulumi"
3402
4232
  ]);
3403
4233
  var ALWAYS_ALLOWED = new Set([
4234
+ "read",
4235
+ "read_file",
4236
+ "view",
4237
+ "search",
4238
+ "grep",
4239
+ "glob",
3404
4240
  "planning-state",
3405
4241
  "codebase-state",
3406
4242
  "repo-memory",
3407
4243
  "decision-trace",
3408
4244
  "policy-engine",
3409
- "reflect"
4245
+ "reflect",
4246
+ "codegraph",
4247
+ "codegraph-search",
4248
+ "codegraph-node",
4249
+ "codegraph-explore",
4250
+ "load-rules",
4251
+ "list-rules",
4252
+ "council",
4253
+ "rtk-setup",
4254
+ "hash-edit",
4255
+ "failure-replay"
3410
4256
  ]);
4257
+ function normalizeToolName(name) {
4258
+ return name.toLowerCase().replace(/[-_]/g, "");
4259
+ }
3411
4260
  function isBlocked2(name) {
3412
- const norm = name.toLowerCase().replace(/[-_]/g, "");
4261
+ const norm = normalizeToolName(name);
3413
4262
  for (const b of BLOCKED_TOOLS) {
3414
- if (norm === b.replace(/[-_]/g, "") || norm === b.replace(/_/g, ""))
4263
+ if (norm === normalizeToolName(b))
4264
+ return true;
4265
+ }
4266
+ return false;
4267
+ }
4268
+ function isAlwaysAllowed(name) {
4269
+ const norm = normalizeToolName(name);
4270
+ for (const a of ALWAYS_ALLOWED) {
4271
+ if (norm === normalizeToolName(a))
3415
4272
  return true;
3416
4273
  }
3417
4274
  return false;
@@ -3419,17 +4276,21 @@ function isBlocked2(name) {
3419
4276
  function blockMessage(toolName) {
3420
4277
  return `[Orchestrator Guard] The orchestrator cannot use \`${toolName}\` directly.
3421
4278
 
3422
- ` + `Use built-in read/search tools for lightweight inspection, then route execution with OpenCode's native @agent invocation.
4279
+ ` + `The orchestrator is a coordinator, not an executor.
4280
+
4281
+ ` + `Routing options:
4282
+ ` + ` @default-executor — simple direct tasks (rename, typo fix, quick edit)
4283
+ ` + ` @backend-coder — backend code writing and editing
4284
+ ` + ` @frontend-coder — frontend code writing and editing
4285
+ ` + ` @devops — CI/CD, deploy, and infrastructure changes
4286
+ ` + ` @mapper — codebase mapping
4287
+ ` + ` @researcher — focused research and file analysis
4288
+ ` + ` @tester — tests, builds, and shell-heavy verification
4289
+ ` + ` @writer — documentation writing
3423
4290
 
3424
- ` + `Recommended handoffs:
3425
- ` + ` @backend-coder — backend code writing and editing
3426
- ` + ` @frontend-coder — frontend code writing and editing
3427
- ` + ` @devops — CI/CD, deploy, and infrastructure changes
3428
- ` + ` @mapper — codebase mapping
3429
- ` + ` @researcher — focused research and file analysis
3430
- ` + ` @tester — tests, builds, and shell-heavy verification
4291
+ ` + `Allowed tools for orchestrator: read, search, planning-state, codebase-state, repo-memory, decision-trace, policy-engine, reflect, codegraph, load-rules, council, rtk-setup, hash-edit, failure-replay.
3431
4292
 
3432
- ` + `To enable this guard: set FLOWDECK_ORCHESTRATOR_GUARD=on`;
4293
+ ` + `To disable this guard: set FLOWDECK_ORCHESTRATOR_GUARD=off`;
3433
4294
  }
3434
4295
 
3435
4296
  class OrchestratorGuard {
@@ -3461,12 +4322,21 @@ class OrchestratorGuard {
3461
4322
  return;
3462
4323
  if (sessionId !== this.primarySessionId)
3463
4324
  return;
3464
- if (ALWAYS_ALLOWED.has(toolName))
4325
+ if (isAlwaysAllowed(toolName))
3465
4326
  return;
3466
4327
  if (isBlocked2(toolName)) {
3467
4328
  throw new Error(blockMessage(toolName));
3468
4329
  }
3469
4330
  }
4331
+ _isBlockedForTest(name) {
4332
+ return isBlocked2(name);
4333
+ }
4334
+ _isAllowedForTest(name) {
4335
+ return isAlwaysAllowed(name);
4336
+ }
4337
+ _setPrimarySessionIdForTest(id) {
4338
+ this.primarySessionId = id;
4339
+ }
3470
4340
  }
3471
4341
  function extractSessionId(event) {
3472
4342
  const props = event.properties;
@@ -3533,10 +4403,23 @@ async function runAutoLearner(client, directory, appLog) {
3533
4403
  }
3534
4404
 
3535
4405
  // src/mcp/index.ts
4406
+ import { spawnSync as spawnSync4 } from "child_process";
3536
4407
  function getDisabledMcps() {
3537
4408
  const raw = process.env.FLOWDECK_DISABLE_MCP ?? "";
3538
4409
  return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
3539
4410
  }
4411
+ function isLauncherAvailable(launcher) {
4412
+ try {
4413
+ const result = spawnSync4(launcher, ["--version"], {
4414
+ encoding: "utf-8",
4415
+ timeout: 5000,
4416
+ stdio: "pipe"
4417
+ });
4418
+ return result.status === 0;
4419
+ } catch {
4420
+ return false;
4421
+ }
4422
+ }
3540
4423
  function createFlowDeckMcps() {
3541
4424
  const disabled = getDisabledMcps();
3542
4425
  const mcps = {};
@@ -3583,6 +4466,48 @@ function createFlowDeckMcps() {
3583
4466
  enabled: true
3584
4467
  };
3585
4468
  }
4469
+ if (!disabled.has("memory") && isLauncherAvailable("npx")) {
4470
+ mcps.memory = {
4471
+ type: "local",
4472
+ command: ["npx", "-y", "@modelcontextprotocol/server-memory"],
4473
+ enabled: true
4474
+ };
4475
+ }
4476
+ if (!disabled.has("omega-memory") && isLauncherAvailable("uvx")) {
4477
+ mcps.omegaMemory = {
4478
+ type: "local",
4479
+ command: ["uvx", "omega-memory", "serve"],
4480
+ enabled: true
4481
+ };
4482
+ }
4483
+ if (!disabled.has("sequential-thinking") && isLauncherAvailable("npx")) {
4484
+ mcps.sequentialThinking = {
4485
+ type: "local",
4486
+ command: ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
4487
+ enabled: true
4488
+ };
4489
+ }
4490
+ if (!disabled.has("magic") && isLauncherAvailable("npx")) {
4491
+ mcps.magic = {
4492
+ type: "local",
4493
+ command: ["npx", "-y", "@magicuidesign/mcp@latest"],
4494
+ enabled: true
4495
+ };
4496
+ }
4497
+ if (!disabled.has("playwright") && isLauncherAvailable("npx")) {
4498
+ mcps.playwright = {
4499
+ type: "local",
4500
+ command: ["npx", "-y", "@playwright/mcp", "--browser", "chrome"],
4501
+ enabled: true
4502
+ };
4503
+ }
4504
+ if (!disabled.has("token-optimizer") && isLauncherAvailable("npx")) {
4505
+ mcps.tokenOptimizer = {
4506
+ type: "local",
4507
+ command: ["npx", "-y", "token-optimizer-mcp"],
4508
+ enabled: true
4509
+ };
4510
+ }
3586
4511
  return mcps;
3587
4512
  }
3588
4513
 
@@ -3597,14 +4522,143 @@ ${customAppendPrompt}`;
3597
4522
  return base;
3598
4523
  }
3599
4524
  // src/agents/orchestrator.ts
3600
- var ORCHESTRATOR_PROMPT = `You coordinate multi-agent execution. Read planning state, inspect the codebase with built-in tools when needed, and route specialized work to the right agent using OpenCode's native agent invocation.
4525
+ var ORCHESTRATOR_PROMPT = `You are the FlowDeck Orchestrator. You coordinate multi-agent execution. You do NOT execute tasks yourself.
4526
+
4527
+ ## Core Rule: You Are a Router, Not a Worker
4528
+
4529
+ **NEVER** perform the following directly:
4530
+ - Write or edit files
4531
+ - Run shell commands, bash scripts, or terminal operations
4532
+ - Run tests or builds
4533
+ - Implement code
4534
+ - Do full investigations
4535
+ - Run the entire coding workflow yourself
4536
+
4537
+ Your ONLY job is to:
4538
+ 1. **Analyze** the request
4539
+ 2. **Classify** the task type and estimate complexity/risk/ambiguity
4540
+ 3. **Choose** the appropriate workflow and execution path
4541
+ 4. **Route** work to the correct agent or execution path
4542
+ 5. **Supervise** progress
4543
+ 6. **Collect** results
4544
+ 7. **Return** the final coordinated outcome
4545
+
4546
+ ## Routing-First Protocol
4547
+
4548
+ For EVERY user request, you MUST follow this exact sequence BEFORE any execution begins:
4549
+
4550
+ ### Step 1: Analyze
4551
+ - Read STATE.md if it exists
4552
+ - Identify current phase and workflow class
4553
+ - Understand what the user is asking for
4554
+
4555
+ ### Step 2: Classify
4556
+ Estimate:
4557
+ - Simplicity: Is this a rename, typo fix, config update, or simple question?
4558
+ - Confidence: How well does the request match known patterns?
4559
+ - Risk: Blast radius (files touched) and sensitivity (auth, security, data)
4560
+ - Codebase familiarity: Is the codebase mapping fresh?
4561
+ - Complexity: Cheap (classify, validate, summarize) vs expensive (architect, refactor)
4562
+
4563
+ ### Step 3: Choose Workflow
4564
+ Select ONE of these workflow classes:
4565
+
4566
+ | Workflow Class | Execution Path | When to Select |
4567
+ |----------------|---------------|----------------|
4568
+ | \`quick\` | Route to @default-executor with \`direct-stock-tools\` mode | Simple, low-risk tasks (< 5 files, no ambiguity) |
4569
+ | \`standard\` | Plan with @planner → Execute with specialists → Verify with @reviewer | Normal implementation tasks |
4570
+ | \`explore\` | Discuss with @discusser → Plan with @planner → Execute with specialists | Ambiguous or unfamiliar tasks |
4571
+ | \`ui-heavy\` | Discuss with @discusser → Design with @design → Plan with @planner → Execute with specialists | UI/UX-heavy tasks |
4572
+ | \`bugfix\` | Discuss with @discusser → Fix with @debug-specialist / @backend-coder → Verify with @tester | Bug fixes |
4573
+ | \`docs-only\` | Route to @default-executor with \`inspect-only\` or \`simple-edit\` mode, or @writer for large docs | Documentation-only changes |
4574
+ | \`verify-heavy\` | Plan with @planner (enhanced checks) → Execute with specialists → Verify with @reviewer + @security-auditor | High blast radius or sensitive paths |
4575
+
4576
+ ### Step 4: Log the Decision
4577
+ Before routing, you MUST emit a routing decision in this exact format:
3601
4578
 
3602
- ## Operating Model
4579
+ \`\`\`
4580
+ ## Routing Decision
4581
+
4582
+ **Request:** <brief summary of user request>
4583
+ **Classification:** <task type> | Confidence: <0.0-1.0>
4584
+ **Workflow Selected:** <workflow class>
4585
+ **Reason:** <why this workflow was chosen>
4586
+ **Execution Path:** <which agent(s) will execute>
4587
+ **Estimated Blast Radius:** <number of files or "unknown">
4588
+ \`\`\`
3603
4589
 
3604
- - Start by reading STATE.md and the active PLAN.md.
3605
- - Use built-in read/search tools directly for lightweight inspection and progress tracking.
3606
- - Use native agent routing for implementation, testing, deep research, reviews, and other specialist work.
3607
- - Do not rely on the removed FlowDeck-specific delegation tools.
4590
+ ### Step 5: Route and Supervise
4591
+ - Invoke the selected agent(s) using OpenCode's native @agent invocation
4592
+ - Provide clear, focused context
4593
+ - Wait for completion
4594
+ - Collect results
4595
+ - If escalation is needed, log the escalation and re-route
4596
+
4597
+ ## What You MAY Do Directly
4598
+
4599
+ You may ONLY use these tools directly:
4600
+ - **read** — Read files for lightweight inspection
4601
+ - **search/grep** — Search codebase for patterns
4602
+ - **planning-state** — Read/update planning state
4603
+ - **codebase-state** — Read codebase documentation
4604
+ - **repo-memory** — Query architecture graph
4605
+ - **decision-trace** — Record decisions
4606
+ - **policy-engine** — Check policies
4607
+ - **reflect** — Gather session artifacts
4608
+
4609
+ You may NEVER use:
4610
+ - write, write_file, create, create_file
4611
+ - edit, edit_file, patch, apply_patch, str_replace_editor, str_replace
4612
+ - bash, run_bash, execute, run_command, terminal, shell
4613
+ - Any tool that modifies the filesystem or executes commands
4614
+
4615
+ ## Execution Paths After Routing
4616
+
4617
+ ### Direct Execution Path (via @default-executor)
4618
+ When workflow class is \`quick\` or \`docs-only\` (simple):
4619
+ - Route to @default-executor with an explicit mode:
4620
+ - \`direct-stock-tools\` — for simple file changes
4621
+ - \`quick-answer\` — for questions
4622
+ - \`inspect-only\` — for analysis/reporting
4623
+ - \`simple-edit\` — for surgical changes
4624
+ - The @default-executor is the worker; you are the coordinator
4625
+
4626
+ ### Specialist Execution Path
4627
+ When workflow class is \`standard\`, \`explore\`, \`ui-heavy\`, \`bugfix\`, or \`verify-heavy\`:
4628
+ - Route implementation to role-specific specialists:
4629
+ - @backend-coder — server, API, business logic, database
4630
+ - @frontend-coder — UI components, client state, styling
4631
+ - @devops — CI/CD, deployment, infrastructure
4632
+ - @tester — tests, builds, verification
4633
+ - @researcher — API docs, library research
4634
+ - @reviewer — code quality review
4635
+ - @security-auditor — security review
4636
+ - @debug-specialist — root cause analysis
4637
+
4638
+ ### Parallel Execution Patterns
4639
+
4640
+ Wave 1 (parallel):
4641
+ @researcher — research the library API
4642
+ @backend-coder — implement the model and types
4643
+ @tester — write test cases
4644
+
4645
+ Wave 2 (after Wave 1):
4646
+ @backend-coder — implement service using Wave 1 research
4647
+ @reviewer — review Wave 1 implementation
4648
+
4649
+ ## Adaptive Routing and Escalation
4650
+
4651
+ If you discover during supervision that the initial workflow class is insufficient:
4652
+ 1. Log the escalation with reason
4653
+ 2. Select the richer workflow class
4654
+ 3. Re-route the remaining work to appropriate agents
4655
+ 4. You STILL do not execute the work yourself
4656
+
4657
+ Escalation paths:
4658
+ - quick → standard: when blast radius exceeds 3 files
4659
+ - standard → verify-heavy: when sensitive paths are touched
4660
+ - standard → ui-heavy: when design requirements emerge
4661
+ - explore → standard: when confidence improves after discussion
3608
4662
 
3609
4663
  ## Startup Behavior
3610
4664
 
@@ -3620,18 +4674,14 @@ If STATE.md does not exist, tell the user: No STATE.md found. Run /fd-map-codeba
3620
4674
  Read STATE.md to determine the current phase and workflow class.
3621
4675
 
3622
4676
  The orchestrator may run in any phase, but should respect the workflow class:
3623
- - For \`quick\` workflows: run directly in execute phase, skip discuss/plan.
4677
+ - For \`quick\` workflows: route to @default-executor, skip discuss/plan.
3624
4678
  - For \`standard\` workflows: plan → execute → verify.
3625
4679
  - For \`explore\` workflows: discuss → plan → execute → verify.
3626
4680
  - For \`ui-heavy\` workflows: discuss → design → plan → execute → verify.
3627
4681
  - For \`bugfix\` workflows: discuss → fix-bug → verify.
3628
- - For \`docs-only\` workflows: write-docs verify.
4682
+ - For \`docs-only\` workflows: route to @default-executor or @writer.
3629
4683
  - For \`verify-heavy\` workflows: plan → execute → verify (with enhanced checks).
3630
4684
 
3631
- If the project is in a different phase than expected:
3632
- - Suggest the correct next command but allow override for adaptive workflows.
3633
- - Log any phase skips with reasons.
3634
-
3635
4685
  ## State-First Read Strategy
3636
4686
 
3637
4687
  Before invoking an agent that needs codebase context:
@@ -3649,60 +4699,6 @@ For each incomplete step in PLAN.md:
3649
4699
  4. Wait for completion, then update and re-read STATE.md.
3650
4700
  5. Move to the next incomplete step.
3651
4701
 
3652
- ## Implementation Routing
3653
-
3654
- When a plan step requires implementation, route to a role-specific agent:
3655
- - Use @backend-coder for server, API, business logic, database, and non-UI application code.
3656
- - Use @frontend-coder for UI components, client state, styling, and interaction behavior.
3657
- - Use @devops for CI/CD workflows, deployment, infrastructure, runtime config, and operations scripts.
3658
- - Split mixed-domain steps into smaller specialist handoffs when that reduces risk.
3659
-
3660
- ## Agent Team
3661
-
3662
- - @design: discovery, UX planning, wireframes, visual system, implementation handoff, design fidelity review
3663
- - @backend-coder: backend code implementation
3664
- - @frontend-coder: frontend code implementation
3665
- - @devops: CI/CD and infrastructure implementation
3666
- - @researcher: API docs and library usage
3667
- - @tester: writing and running tests
3668
- - @reviewer: code quality review
3669
- - @writer: documentation
3670
- - @mapper: codebase mapping to .codebase/
3671
- - @architect: system design and ADRs
3672
- - @security-auditor: security review
3673
- - @code-explorer: reading unfamiliar code
3674
- - @debug-specialist: root cause analysis
3675
- - @build-error-resolver: build and compile failures
3676
- - @doc-updater: updating existing docs
3677
- - @task-splitter: decomposing complex tasks
3678
- - @discusser: requirements extraction
3679
- - @plan-checker: plan quality review
3680
- - @planner: feature planning
3681
- - @performance-optimizer: performance analysis
3682
- - @refactor-guide: safe refactoring
3683
-
3684
- ## Adaptive Workflow Routing
3685
-
3686
- The orchestrator reads the workflow class from STATE.md and adapts its behavior:
3687
-
3688
- | Workflow Class | Stages | When Used |
3689
- |----------------|--------|-----------|
3690
- | \`quick\` | execute → verify | Simple, low-risk tasks (< 5 files, no ambiguity) |
3691
- | \`standard\` | plan → execute → verify | Normal implementation tasks |
3692
- | \`explore\` | discuss → plan → execute → verify | Ambiguous or unfamiliar tasks |
3693
- | \`ui-heavy\` | discuss → design → plan → execute → verify | UI/UX-heavy tasks |
3694
- | \`bugfix\` | discuss → fix-bug → verify | Bug fixes |
3695
- | \`docs-only\` | write-docs → verify | Documentation-only changes |
3696
- | \`verify-heavy\` | plan → execute → verify | High blast radius or sensitive paths |
3697
-
3698
- - discuss: requirements extraction with @discusser (only for explore/bugfix/ui-heavy)
3699
- - plan: plan creation with @planner, review with @plan-checker (skip for quick/docs-only)
3700
- - design: UX structure with @design (only for ui-heavy)
3701
- - execute: implementation with appropriate specialists
3702
- - verify: review with @reviewer and @security-auditor (always run for edited code)
3703
-
3704
- The workflow class is chosen by scoring task complexity, confidence, risk, and codebase familiarity. Prefer the lightest workflow that is sufficient. Escalate to a richer workflow only when evidence shows the current path is insufficient.
3705
-
3706
4702
  ## Tracking
3707
4703
 
3708
4704
  After each step completes:
@@ -3730,6 +4726,11 @@ When a task required unusual human guidance, a novel solution strategy, or expos
3730
4726
 
3731
4727
  Do NOT create a skill for routine tasks. Only capture genuinely novel or reusable patterns.`;
3732
4728
  var AGENT_DESCRIPTIONS = {
4729
+ "default-executor": `@default-executor
4730
+ - Role: Default execution worker for simple, direct tasks
4731
+ - Permissions: Read/write files, shell execution
4732
+ - Best for: Quick answers, simple edits, inspect-only analysis, direct stock-tool usage
4733
+ - Use when: Workflow class is \`quick\` or \`docs-only\`, or a single focused task needs direct execution`,
3733
4734
  design: `@design
3734
4735
  - Role: Runs design-first workflow for user-facing tasks
3735
4736
  - Permissions: Read/write files
@@ -3858,8 +4859,9 @@ ${enabledAgents}
3858
4859
  - Review available agents before acting
3859
4860
  - Reference paths and line numbers instead of pasting full files
3860
4861
  - Provide context summaries, then let specialists inspect what they need
3861
- - Use direct built-in tools yourself for lightweight reading and status tracking
3862
- - Use native agent routing when specialist work or deeper execution is the better fit
4862
+ - Use direct built-in tools ONLY for lightweight reading and status tracking
4863
+ - NEVER use write/edit/bash tools yourself always route execution to agents
4864
+ - Log every routing decision before handing off work
3863
4865
 
3864
4866
  </Delegation>`;
3865
4867
  }
@@ -3868,7 +4870,7 @@ function createOrchestratorAgent(model, customPrompt, customAppendPrompt, disabl
3868
4870
  const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
3869
4871
  const definition = {
3870
4872
  name: "orchestrator",
3871
- description: "AI coding orchestrator that coordinates specialist agents and built-in tools for execution",
4873
+ description: "AI coding orchestrator that coordinates specialist agents. Routes all work to appropriate agents and workflows. Does not execute tasks directly.",
3872
4874
  config: {
3873
4875
  temperature: 0.1,
3874
4876
  prompt
@@ -4598,6 +5600,19 @@ var RESEARCHER_PROMPT = `You find accurate, cited information. You do not guess.
4598
5600
 
4599
5601
  Never cite StackOverflow as a primary source. Always verify against official docs.
4600
5602
 
5603
+ ## MCP Tool Guidance
5604
+
5605
+ Use the following MCP tools when relevant to the research task:
5606
+
5607
+ - **context7** — library documentation lookup (always try first for API/docs questions)
5608
+ - **sequential-thinking** — stepwise investigation and planning for complex research tasks
5609
+ - **memory / omega-memory** — retrieve prior context from previous research sessions when relevant
5610
+ - **magic** — UI/design system research (component libraries, design tokens, theming)
5611
+ - **playwright** — verify browser behavior, test interactive examples, or research runtime DOM/API behavior
5612
+ - **token-optimizer** — compress or reduce large context before presenting findings
5613
+
5614
+ Maintain Context7-first priority. Use other MCPs to supplement, not replace, authoritative docs.
5615
+
4601
5616
  ## Source Citation
4602
5617
 
4603
5618
  Every fact must include its source:
@@ -6553,9 +7568,71 @@ function createSupervisorAgent(model, customPrompt, customAppendPrompt) {
6553
7568
  return definition;
6554
7569
  }
6555
7570
 
7571
+ // src/agents/default-executor.ts
7572
+ var DEFAULT_EXECUTOR_PROMPT = `You are the Default Execution Agent — the worker that handles simple, direct tasks when the orchestrator has explicitly routed work to you through a chosen direct workflow.
7573
+
7574
+ ## Your Role
7575
+
7576
+ You execute. You do NOT route, plan, or orchestrate.
7577
+ You receive a specific task from the orchestrator with a chosen execution mode, and you carry it out using the full set of available tools.
7578
+
7579
+ ## Execution Modes
7580
+
7581
+ The orchestrator selects one of these modes when routing to you:
7582
+
7583
+ - **direct-stock-tools** — Use OpenCode's built-in read/search/write/edit/bash tools directly to complete a focused task that fits in < 5 files and has no ambiguity.
7584
+ - **quick-answer** — Answer a question or provide information using read/search tools only. No file modifications.
7585
+ - **inspect-only** — Read and analyze code to answer questions or produce reports. No modifications.
7586
+ - **simple-edit** — Make a small, surgical change (rename, typo fix, constant update, config change). Must be reversible and low-risk.
7587
+
7588
+ ## Rules
7589
+
7590
+ 1. **Execute exactly what was routed to you.** Do not expand scope.
7591
+ 2. **Do not invent new workflows.** If the task is bigger than expected, report back to the orchestrator — do not silently absorb it.
7592
+ 3. **Use the simplest tool for the job.** Prefer read/search for investigation, write/edit for changes, bash for verification.
7593
+ 4. **Report completion clearly.** Summarize what was done and any issues encountered.
7594
+ 5. **Escalate if complexity emerges.** If you discover the task touches > 5 files, requires architectural decisions, or involves security-sensitive paths, stop and report to the orchestrator for re-routing.
7595
+
7596
+ ## Anti-Patterns
7597
+
7598
+ - Do NOT act as an orchestrator yourself.
7599
+ - Do NOT route work to other agents.
7600
+ - Do NOT silently expand a "simple edit" into a full refactor.
7601
+ - Do NOT bypass the orchestrator's routing decision.
7602
+
7603
+ ## Completion Format
7604
+
7605
+ When done, respond with:
7606
+
7607
+ \`\`\`
7608
+ ## Execution Complete
7609
+
7610
+ **Mode:** <the mode you were given>
7611
+ **Files touched:** <list or "none">
7612
+ **Summary:** <what was done>
7613
+ **Verification:** <how you confirmed it works>
7614
+ **Issues:** <any problems found, or "none">
7615
+ \`\`\``;
7616
+ function createDefaultExecutorAgent(model, customPrompt, customAppendPrompt) {
7617
+ const prompt = resolvePrompt(DEFAULT_EXECUTOR_PROMPT, customPrompt, customAppendPrompt);
7618
+ const definition = {
7619
+ name: "default-executor",
7620
+ description: "Default execution worker for direct, simple tasks routed by the orchestrator. Handles quick-answer, inspect-only, simple-edit, and direct-stock-tools workflows.",
7621
+ config: {
7622
+ temperature: 0.1,
7623
+ prompt
7624
+ }
7625
+ };
7626
+ if (typeof model === "string" && model) {
7627
+ definition.config.model = model;
7628
+ }
7629
+ return definition;
7630
+ }
7631
+
6556
7632
  // src/agents/index.ts
6557
7633
  var AGENT_NAMES = [
6558
7634
  "orchestrator",
7635
+ "default-executor",
6559
7636
  "planner",
6560
7637
  "backend-coder",
6561
7638
  "frontend-coder",
@@ -6598,6 +7675,8 @@ function createAgent(name, model, customPrompt, customAppendPrompt) {
6598
7675
  switch (name) {
6599
7676
  case "orchestrator":
6600
7677
  return createOrchestratorAgent(model, customPrompt, customAppendPrompt);
7678
+ case "default-executor":
7679
+ return createDefaultExecutorAgent(model, customPrompt, customAppendPrompt);
6601
7680
  case "planner":
6602
7681
  return createPlannerAgent(model, customPrompt, customAppendPrompt);
6603
7682
  case "backend-coder":
@@ -6687,8 +7766,8 @@ function getAgentConfigs(agentModels) {
6687
7766
  // src/index.ts
6688
7767
  function lazyLoadRulePaths(projectRoot) {
6689
7768
  const __dir = dirname3(fileURLToPath2(import.meta.url));
6690
- const rulesDir = join24(__dir, "..", "src", "rules");
6691
- if (!existsSync25(rulesDir))
7769
+ const rulesDir = join26(__dir, "..", "src", "rules");
7770
+ if (!existsSync27(rulesDir))
6692
7771
  return { paths: [], diagnostics: "[LazyRuleLoader] rules directory not found" };
6693
7772
  const detectedLanguages = detectProjectLanguages(projectRoot);
6694
7773
  const paths = getStartupRulePaths(rulesDir, detectedLanguages);
@@ -6698,8 +7777,8 @@ function lazyLoadRulePaths(projectRoot) {
6698
7777
  }
6699
7778
  function loadCommands() {
6700
7779
  const __dir = dirname3(fileURLToPath2(import.meta.url));
6701
- const commandsDir = join24(__dir, "..", "src", "commands");
6702
- if (!existsSync25(commandsDir))
7780
+ const commandsDir = join26(__dir, "..", "src", "commands");
7781
+ if (!existsSync27(commandsDir))
6703
7782
  return {};
6704
7783
  const commands = {};
6705
7784
  try {
@@ -6707,7 +7786,7 @@ function loadCommands() {
6707
7786
  if (!file.endsWith(".md"))
6708
7787
  continue;
6709
7788
  const name = basename2(file, ".md");
6710
- const raw = readFileSync24(join24(commandsDir, file), "utf-8");
7789
+ const raw = readFileSync25(join26(commandsDir, file), "utf-8");
6711
7790
  let description;
6712
7791
  let template = raw;
6713
7792
  const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
@@ -6735,7 +7814,8 @@ var plugin = async (input, _options) => {
6735
7814
  const compactionHook = createCompactionHook({ directory }, fileTracker);
6736
7815
  const orchestratorGuard = new OrchestratorGuard;
6737
7816
  const autoLearnHook = createAutoLearnHook(client, fileTracker, directory, appLog);
6738
- const eventLog = createEventLogHooks(appLog);
7817
+ let loopDetector;
7818
+ let eventLog;
6739
7819
  const notifCtrl = new NotificationController(undefined, appLog);
6740
7820
  const agentConfigs = getAgentConfigs({});
6741
7821
  const mcps = createFlowDeckMcps();
@@ -6750,6 +7830,16 @@ var plugin = async (input, _options) => {
6750
7830
  }
6751
7831
  const flowdeckConfig = loadFlowDeckConfig(directory);
6752
7832
  const designFirstConfig = resolveDesignFirstConfig(flowdeckConfig);
7833
+ const loopCfg = flowdeckConfig.governance?.loopDetection ?? {};
7834
+ loopDetector = new LoopDetector({
7835
+ enabled: loopCfg.enabled ?? true,
7836
+ maxRepeats: loopCfg.maxRepeats ?? 2,
7837
+ similarityThreshold: loopCfg.similarityThreshold ?? 0.9,
7838
+ historySize: loopCfg.historySize ?? 20
7839
+ }, appLog);
7840
+ eventLog = createEventLogHooks(appLog, (toolName, args, output, sessionId, status) => {
7841
+ loopDetector?.recordAfter(toolName, args, output, sessionId, status);
7842
+ });
6753
7843
  const agentModels = {};
6754
7844
  for (const [name, agentCfg] of Object.entries(flowdeckConfig.agents ?? {})) {
6755
7845
  if (agentCfg.model) {
@@ -6789,8 +7879,8 @@ var plugin = async (input, _options) => {
6789
7879
  }
6790
7880
  }
6791
7881
  }
6792
- const skillsDir = join24(dirname3(fileURLToPath2(import.meta.url)), "..", "src", "skills");
6793
- if (existsSync25(skillsDir)) {
7882
+ const skillsDir = join26(dirname3(fileURLToPath2(import.meta.url)), "..", "src", "skills");
7883
+ if (existsSync27(skillsDir)) {
6794
7884
  const cfgAny = cfg;
6795
7885
  if (!cfgAny.skills || typeof cfgAny.skills !== "object") {
6796
7886
  cfgAny.skills = { paths: [] };
@@ -6829,7 +7919,8 @@ var plugin = async (input, _options) => {
6829
7919
  codegraph: codegraphTool,
6830
7920
  "load-rules": loadRulesTool,
6831
7921
  "list-rules": listRulesTool,
6832
- "rtk-setup": rtkSetupTool
7922
+ "rtk-setup": rtkSetupTool,
7923
+ "merge-assist": mergeAssistTool
6833
7924
  },
6834
7925
  "shell.env": shellEnvHook,
6835
7926
  "todo.updated": todoHook,
@@ -6898,9 +7989,19 @@ var plugin = async (input, _options) => {
6898
7989
  await patchTrustHook({ directory }, toolInput, toolOutput);
6899
7990
  await decisionTraceHook({ directory }, toolInput, toolOutput);
6900
7991
  await eventLog.before({ directory }, toolInput, toolOutput);
7992
+ const loopResult = loopDetector.checkBefore(toolInput.tool ?? toolInput.name ?? "unknown", toolOutput?.args ?? toolInput?.args ?? {}, toolInput.sessionID ?? "");
7993
+ if (loopResult.action === "block") {
7994
+ throw new Error(loopResult.escalationMessage);
7995
+ }
7996
+ if (loopResult.action === "warn") {
7997
+ appLog(loopResult.message);
7998
+ }
6901
7999
  },
6902
8000
  "tool.execute.after": async (toolInput, toolOutput) => {
6903
- await eventLog.after({ directory }, toolInput, toolOutput);
8001
+ const eventLogHealthy = await eventLog.after({ directory }, toolInput, toolOutput);
8002
+ if (!eventLogHealthy) {
8003
+ loopDetector.setPersistenceHealthy(false);
8004
+ }
6904
8005
  await contextMonitor["tool.execute.after"](toolInput, toolOutput);
6905
8006
  }
6906
8007
  };