@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/agents/default-executor.d.ts +3 -0
- package/dist/agents/default-executor.d.ts.map +1 -0
- package/dist/agents/index.d.ts +2 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/researcher.d.ts.map +1 -1
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/hooks/event-log-hook.d.ts +4 -3
- package/dist/hooks/event-log-hook.d.ts.map +1 -1
- package/dist/hooks/orchestrator-guard-hook.d.ts +12 -6
- package/dist/hooks/orchestrator-guard-hook.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1299 -198
- package/dist/lib/confirmation.d.ts +9 -1
- package/dist/lib/confirmation.d.ts.map +1 -1
- package/dist/mcp/index.d.ts +9 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/services/event-logger.d.ts +4 -1
- package/dist/services/event-logger.d.ts.map +1 -1
- package/dist/services/loop-detector.d.ts +47 -0
- package/dist/services/loop-detector.d.ts.map +1 -0
- package/dist/tools/merge-assist.d.ts +54 -0
- package/dist/tools/merge-assist.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/commands/fd-execute.md +6 -0
- package/src/commands/fd-fix-bug.md +7 -1
- package/src/commands/fd-merge-assist.md +180 -0
- package/src/commands/fd-plan.md +7 -1
- package/src/rules/common/agent-orchestration.md +88 -30
- package/src/skills/merge-assist/SKILL.md +232 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { readFileSync as
|
|
3
|
-
import { join as
|
|
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/
|
|
1998
|
-
import {
|
|
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
|
|
2046
|
-
import { join as
|
|
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 ?
|
|
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(
|
|
2530
|
+
candidates.push(join17(directory, ".opencode", CONFIG_FILENAME));
|
|
2056
2531
|
}
|
|
2057
|
-
candidates.push(
|
|
2532
|
+
candidates.push(join17(getGlobalConfigDir(), CONFIG_FILENAME));
|
|
2058
2533
|
for (const configPath of candidates) {
|
|
2059
|
-
if (
|
|
2534
|
+
if (existsSync17(configPath)) {
|
|
2060
2535
|
try {
|
|
2061
|
-
const content =
|
|
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 (
|
|
2563
|
+
if (existsSync18(configPath)) {
|
|
2089
2564
|
try {
|
|
2090
|
-
const config = JSON.parse(
|
|
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 =
|
|
2619
|
+
const planningDirPath = join18(dir, PLANNING_DIR2);
|
|
2145
2620
|
const codebaseDirectory = codebaseDir(dir);
|
|
2146
|
-
const configPath =
|
|
2147
|
-
const
|
|
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" && !
|
|
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 (!
|
|
2632
|
+
if (!existsSync18(planningDirPath))
|
|
2158
2633
|
return;
|
|
2159
|
-
if (!
|
|
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,
|
|
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(
|
|
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 (!
|
|
2690
|
+
if (!existsSync18(planPath))
|
|
2216
2691
|
return false;
|
|
2217
|
-
const planContent =
|
|
2692
|
+
const planContent = readFileSync17(planPath, "utf-8");
|
|
2218
2693
|
return isUiHeavyTask(planContent);
|
|
2219
2694
|
}
|
|
2220
|
-
function effectiveSeverity(configPath,
|
|
2221
|
-
if (
|
|
2695
|
+
function effectiveSeverity(configPath, statePath3) {
|
|
2696
|
+
if (existsSync18(configPath)) {
|
|
2222
2697
|
try {
|
|
2223
|
-
const configContent =
|
|
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(
|
|
2708
|
+
return getPlanConfirmed(statePath3) ? "block" : "warn";
|
|
2234
2709
|
}
|
|
2235
|
-
function getEffectiveSeverity(configPath,
|
|
2236
|
-
return effectiveSeverity(configPath,
|
|
2710
|
+
function getEffectiveSeverity(configPath, statePath3) {
|
|
2711
|
+
return effectiveSeverity(configPath, statePath3);
|
|
2237
2712
|
}
|
|
2238
|
-
function getPlanConfirmed(
|
|
2239
|
-
if (!
|
|
2713
|
+
function getPlanConfirmed(statePath3) {
|
|
2714
|
+
if (!existsSync18(statePath3))
|
|
2240
2715
|
return false;
|
|
2241
2716
|
try {
|
|
2242
|
-
const content =
|
|
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 (!
|
|
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 (!
|
|
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
|
|
2264
|
-
import { join as
|
|
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(
|
|
2272
|
-
const patterns = BLOCKED_PATTERNS[
|
|
2746
|
+
function isBlocked(tool14, args) {
|
|
2747
|
+
const patterns = BLOCKED_PATTERNS[tool14];
|
|
2273
2748
|
if (!patterns)
|
|
2274
2749
|
return null;
|
|
2275
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 =
|
|
2312
|
-
if (!
|
|
2786
|
+
const constraintsPath = join19(codebaseDir(directory), "CONSTRAINTS.md");
|
|
2787
|
+
if (!existsSync19(constraintsPath))
|
|
2313
2788
|
return null;
|
|
2314
2789
|
try {
|
|
2315
|
-
const content =
|
|
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 (!
|
|
2831
|
+
if (!existsSync19(planPath))
|
|
2357
2832
|
return false;
|
|
2358
|
-
const planContent =
|
|
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
|
|
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 (!
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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(
|
|
2564
|
-
notify("FlowDeck Permission Required", `Agent needs approval to use tool: ${
|
|
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
|
|
2569
|
-
import { join as
|
|
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 =
|
|
2592
|
-
if (!
|
|
3066
|
+
const p = join20(codebaseDir(directory), "FAILURES.json");
|
|
3067
|
+
if (!existsSync21(p))
|
|
2593
3068
|
return [];
|
|
2594
3069
|
try {
|
|
2595
|
-
const data = JSON.parse(
|
|
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
|
|
2649
|
-
import { join as
|
|
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 (!
|
|
2659
|
-
|
|
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
|
-
|
|
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
|
|
2678
|
-
import { join as
|
|
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
|
|
3190
|
+
return join22(codebaseDir(dir), "APPROVALS.json");
|
|
2716
3191
|
}
|
|
2717
3192
|
function loadStore(dir) {
|
|
2718
3193
|
const p = approvalsPath(dir);
|
|
2719
|
-
if (!
|
|
3194
|
+
if (!existsSync23(p))
|
|
2720
3195
|
return { requests: [] };
|
|
2721
3196
|
try {
|
|
2722
|
-
return JSON.parse(
|
|
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
|
|
2741
|
-
if (!WRITE_TOOLS.has(
|
|
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
|
|
2759
|
-
import { join as
|
|
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
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
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 (!
|
|
2828
|
-
|
|
3307
|
+
if (!existsSync24(logDir)) {
|
|
3308
|
+
mkdirSync13(logDir, { recursive: true });
|
|
2829
3309
|
}
|
|
2830
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3094
|
-
import { join as
|
|
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 (
|
|
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) &&
|
|
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
|
|
3148
|
-
if (!
|
|
3961
|
+
const statePath3 = join24(root, ".planning", "STATE.md");
|
|
3962
|
+
if (!existsSync25(statePath3))
|
|
3149
3963
|
return;
|
|
3150
3964
|
try {
|
|
3151
|
-
const content =
|
|
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
|
|
3277
|
-
import { join as
|
|
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 (!
|
|
4132
|
+
if (!existsSync26(sp))
|
|
3319
4133
|
return null;
|
|
3320
4134
|
try {
|
|
3321
|
-
const content =
|
|
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 =
|
|
3351
|
-
if (indexChanged &&
|
|
4164
|
+
const indexPath2 = join25(ctx.directory, ".planning", "CODEBASE_INDEX.md");
|
|
4165
|
+
if (indexChanged && existsSync26(indexPath2)) {
|
|
3352
4166
|
try {
|
|
3353
|
-
const indexContent =
|
|
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 (
|
|
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
|
|
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
|
|
4261
|
+
const norm = normalizeToolName(name);
|
|
3413
4262
|
for (const b of BLOCKED_TOOLS) {
|
|
3414
|
-
if (norm ===
|
|
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
|
-
` + `
|
|
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
|
-
` + `
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3605
|
-
-
|
|
3606
|
-
-
|
|
3607
|
-
-
|
|
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:
|
|
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:
|
|
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
|
|
3862
|
-
-
|
|
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
|
|
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 =
|
|
6691
|
-
if (!
|
|
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 =
|
|
6702
|
-
if (!
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
6793
|
-
if (
|
|
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
|
};
|