@dv.nghiem/flowdeck 0.4.9 → 0.4.11

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("");
@@ -3589,10 +4403,23 @@ async function runAutoLearner(client, directory, appLog) {
3589
4403
  }
3590
4404
 
3591
4405
  // src/mcp/index.ts
4406
+ import { spawnSync as spawnSync4 } from "child_process";
3592
4407
  function getDisabledMcps() {
3593
4408
  const raw = process.env.FLOWDECK_DISABLE_MCP ?? "";
3594
4409
  return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
3595
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
+ }
3596
4423
  function createFlowDeckMcps() {
3597
4424
  const disabled = getDisabledMcps();
3598
4425
  const mcps = {};
@@ -3639,6 +4466,41 @@ function createFlowDeckMcps() {
3639
4466
  enabled: true
3640
4467
  };
3641
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("sequential-thinking") && isLauncherAvailable("npx")) {
4477
+ mcps.sequentialThinking = {
4478
+ type: "local",
4479
+ command: ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
4480
+ enabled: true
4481
+ };
4482
+ }
4483
+ if (!disabled.has("magic") && isLauncherAvailable("npx")) {
4484
+ mcps.magic = {
4485
+ type: "local",
4486
+ command: ["npx", "-y", "@magicuidesign/mcp@latest"],
4487
+ enabled: true
4488
+ };
4489
+ }
4490
+ if (!disabled.has("playwright") && isLauncherAvailable("npx")) {
4491
+ mcps.playwright = {
4492
+ type: "local",
4493
+ command: ["npx", "-y", "@playwright/mcp", "--browser", "chrome"],
4494
+ enabled: true
4495
+ };
4496
+ }
4497
+ if (!disabled.has("token-optimizer") && isLauncherAvailable("npx")) {
4498
+ mcps.tokenOptimizer = {
4499
+ type: "local",
4500
+ command: ["npx", "-y", "token-optimizer-mcp"],
4501
+ enabled: true
4502
+ };
4503
+ }
3642
4504
  return mcps;
3643
4505
  }
3644
4506
 
@@ -4731,6 +5593,19 @@ var RESEARCHER_PROMPT = `You find accurate, cited information. You do not guess.
4731
5593
 
4732
5594
  Never cite StackOverflow as a primary source. Always verify against official docs.
4733
5595
 
5596
+ ## MCP Tool Guidance
5597
+
5598
+ Use the following MCP tools when relevant to the research task:
5599
+
5600
+ - **context7** — library documentation lookup (always try first for API/docs questions)
5601
+ - **sequential-thinking** — stepwise investigation and planning for complex research tasks
5602
+ - **memory** — retrieve prior context from previous research sessions when relevant
5603
+ - **magic** — UI/design system research (component libraries, design tokens, theming)
5604
+ - **playwright** — verify browser behavior, test interactive examples, or research runtime DOM/API behavior
5605
+ - **token-optimizer** — compress or reduce large context before presenting findings
5606
+
5607
+ Maintain Context7-first priority. Use other MCPs to supplement, not replace, authoritative docs.
5608
+
4734
5609
  ## Source Citation
4735
5610
 
4736
5611
  Every fact must include its source:
@@ -6884,8 +7759,8 @@ function getAgentConfigs(agentModels) {
6884
7759
  // src/index.ts
6885
7760
  function lazyLoadRulePaths(projectRoot) {
6886
7761
  const __dir = dirname3(fileURLToPath2(import.meta.url));
6887
- const rulesDir = join24(__dir, "..", "src", "rules");
6888
- if (!existsSync25(rulesDir))
7762
+ const rulesDir = join26(__dir, "..", "src", "rules");
7763
+ if (!existsSync27(rulesDir))
6889
7764
  return { paths: [], diagnostics: "[LazyRuleLoader] rules directory not found" };
6890
7765
  const detectedLanguages = detectProjectLanguages(projectRoot);
6891
7766
  const paths = getStartupRulePaths(rulesDir, detectedLanguages);
@@ -6895,8 +7770,8 @@ function lazyLoadRulePaths(projectRoot) {
6895
7770
  }
6896
7771
  function loadCommands() {
6897
7772
  const __dir = dirname3(fileURLToPath2(import.meta.url));
6898
- const commandsDir = join24(__dir, "..", "src", "commands");
6899
- if (!existsSync25(commandsDir))
7773
+ const commandsDir = join26(__dir, "..", "src", "commands");
7774
+ if (!existsSync27(commandsDir))
6900
7775
  return {};
6901
7776
  const commands = {};
6902
7777
  try {
@@ -6904,7 +7779,7 @@ function loadCommands() {
6904
7779
  if (!file.endsWith(".md"))
6905
7780
  continue;
6906
7781
  const name = basename2(file, ".md");
6907
- const raw = readFileSync24(join24(commandsDir, file), "utf-8");
7782
+ const raw = readFileSync25(join26(commandsDir, file), "utf-8");
6908
7783
  let description;
6909
7784
  let template = raw;
6910
7785
  const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
@@ -6932,7 +7807,8 @@ var plugin = async (input, _options) => {
6932
7807
  const compactionHook = createCompactionHook({ directory }, fileTracker);
6933
7808
  const orchestratorGuard = new OrchestratorGuard;
6934
7809
  const autoLearnHook = createAutoLearnHook(client, fileTracker, directory, appLog);
6935
- const eventLog = createEventLogHooks(appLog);
7810
+ let loopDetector;
7811
+ let eventLog;
6936
7812
  const notifCtrl = new NotificationController(undefined, appLog);
6937
7813
  const agentConfigs = getAgentConfigs({});
6938
7814
  const mcps = createFlowDeckMcps();
@@ -6947,6 +7823,16 @@ var plugin = async (input, _options) => {
6947
7823
  }
6948
7824
  const flowdeckConfig = loadFlowDeckConfig(directory);
6949
7825
  const designFirstConfig = resolveDesignFirstConfig(flowdeckConfig);
7826
+ const loopCfg = flowdeckConfig.governance?.loopDetection ?? {};
7827
+ loopDetector = new LoopDetector({
7828
+ enabled: loopCfg.enabled ?? true,
7829
+ maxRepeats: loopCfg.maxRepeats ?? 2,
7830
+ similarityThreshold: loopCfg.similarityThreshold ?? 0.9,
7831
+ historySize: loopCfg.historySize ?? 20
7832
+ }, appLog);
7833
+ eventLog = createEventLogHooks(appLog, (toolName, args, output, sessionId, status) => {
7834
+ loopDetector?.recordAfter(toolName, args, output, sessionId, status);
7835
+ });
6950
7836
  const agentModels = {};
6951
7837
  for (const [name, agentCfg] of Object.entries(flowdeckConfig.agents ?? {})) {
6952
7838
  if (agentCfg.model) {
@@ -6986,8 +7872,8 @@ var plugin = async (input, _options) => {
6986
7872
  }
6987
7873
  }
6988
7874
  }
6989
- const skillsDir = join24(dirname3(fileURLToPath2(import.meta.url)), "..", "src", "skills");
6990
- if (existsSync25(skillsDir)) {
7875
+ const skillsDir = join26(dirname3(fileURLToPath2(import.meta.url)), "..", "src", "skills");
7876
+ if (existsSync27(skillsDir)) {
6991
7877
  const cfgAny = cfg;
6992
7878
  if (!cfgAny.skills || typeof cfgAny.skills !== "object") {
6993
7879
  cfgAny.skills = { paths: [] };
@@ -7026,7 +7912,8 @@ var plugin = async (input, _options) => {
7026
7912
  codegraph: codegraphTool,
7027
7913
  "load-rules": loadRulesTool,
7028
7914
  "list-rules": listRulesTool,
7029
- "rtk-setup": rtkSetupTool
7915
+ "rtk-setup": rtkSetupTool,
7916
+ "merge-assist": mergeAssistTool
7030
7917
  },
7031
7918
  "shell.env": shellEnvHook,
7032
7919
  "todo.updated": todoHook,
@@ -7095,9 +7982,19 @@ var plugin = async (input, _options) => {
7095
7982
  await patchTrustHook({ directory }, toolInput, toolOutput);
7096
7983
  await decisionTraceHook({ directory }, toolInput, toolOutput);
7097
7984
  await eventLog.before({ directory }, toolInput, toolOutput);
7985
+ const loopResult = loopDetector.checkBefore(toolInput.tool ?? toolInput.name ?? "unknown", toolOutput?.args ?? toolInput?.args ?? {}, toolInput.sessionID ?? "");
7986
+ if (loopResult.action === "block") {
7987
+ throw new Error(loopResult.escalationMessage);
7988
+ }
7989
+ if (loopResult.action === "warn") {
7990
+ appLog(loopResult.message);
7991
+ }
7098
7992
  },
7099
7993
  "tool.execute.after": async (toolInput, toolOutput) => {
7100
- await eventLog.after({ directory }, toolInput, toolOutput);
7994
+ const eventLogHealthy = await eventLog.after({ directory }, toolInput, toolOutput);
7995
+ if (!eventLogHealthy) {
7996
+ loopDetector.setPersistenceHealthy(false);
7997
+ }
7101
7998
  await contextMonitor["tool.execute.after"](toolInput, toolOutput);
7102
7999
  }
7103
8000
  };