@dv.nghiem/flowdeck 0.4.9 → 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/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/index.d.ts.map +1 -1
- package/dist/index.js +1018 -114
- 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/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("");
|
|
@@ -3589,10 +4403,23 @@ async function runAutoLearner(client, directory, appLog) {
|
|
|
3589
4403
|
}
|
|
3590
4404
|
|
|
3591
4405
|
// src/mcp/index.ts
|
|
4406
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
3592
4407
|
function getDisabledMcps() {
|
|
3593
4408
|
const raw = process.env.FLOWDECK_DISABLE_MCP ?? "";
|
|
3594
4409
|
return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
|
3595
4410
|
}
|
|
4411
|
+
function isLauncherAvailable(launcher) {
|
|
4412
|
+
try {
|
|
4413
|
+
const result = spawnSync4(launcher, ["--version"], {
|
|
4414
|
+
encoding: "utf-8",
|
|
4415
|
+
timeout: 5000,
|
|
4416
|
+
stdio: "pipe"
|
|
4417
|
+
});
|
|
4418
|
+
return result.status === 0;
|
|
4419
|
+
} catch {
|
|
4420
|
+
return false;
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
3596
4423
|
function createFlowDeckMcps() {
|
|
3597
4424
|
const disabled = getDisabledMcps();
|
|
3598
4425
|
const mcps = {};
|
|
@@ -3639,6 +4466,48 @@ function createFlowDeckMcps() {
|
|
|
3639
4466
|
enabled: true
|
|
3640
4467
|
};
|
|
3641
4468
|
}
|
|
4469
|
+
if (!disabled.has("memory") && isLauncherAvailable("npx")) {
|
|
4470
|
+
mcps.memory = {
|
|
4471
|
+
type: "local",
|
|
4472
|
+
command: ["npx", "-y", "@modelcontextprotocol/server-memory"],
|
|
4473
|
+
enabled: true
|
|
4474
|
+
};
|
|
4475
|
+
}
|
|
4476
|
+
if (!disabled.has("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
|
+
}
|
|
3642
4511
|
return mcps;
|
|
3643
4512
|
}
|
|
3644
4513
|
|
|
@@ -4731,6 +5600,19 @@ var RESEARCHER_PROMPT = `You find accurate, cited information. You do not guess.
|
|
|
4731
5600
|
|
|
4732
5601
|
Never cite StackOverflow as a primary source. Always verify against official docs.
|
|
4733
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
|
+
|
|
4734
5616
|
## Source Citation
|
|
4735
5617
|
|
|
4736
5618
|
Every fact must include its source:
|
|
@@ -6884,8 +7766,8 @@ function getAgentConfigs(agentModels) {
|
|
|
6884
7766
|
// src/index.ts
|
|
6885
7767
|
function lazyLoadRulePaths(projectRoot) {
|
|
6886
7768
|
const __dir = dirname3(fileURLToPath2(import.meta.url));
|
|
6887
|
-
const rulesDir =
|
|
6888
|
-
if (!
|
|
7769
|
+
const rulesDir = join26(__dir, "..", "src", "rules");
|
|
7770
|
+
if (!existsSync27(rulesDir))
|
|
6889
7771
|
return { paths: [], diagnostics: "[LazyRuleLoader] rules directory not found" };
|
|
6890
7772
|
const detectedLanguages = detectProjectLanguages(projectRoot);
|
|
6891
7773
|
const paths = getStartupRulePaths(rulesDir, detectedLanguages);
|
|
@@ -6895,8 +7777,8 @@ function lazyLoadRulePaths(projectRoot) {
|
|
|
6895
7777
|
}
|
|
6896
7778
|
function loadCommands() {
|
|
6897
7779
|
const __dir = dirname3(fileURLToPath2(import.meta.url));
|
|
6898
|
-
const commandsDir =
|
|
6899
|
-
if (!
|
|
7780
|
+
const commandsDir = join26(__dir, "..", "src", "commands");
|
|
7781
|
+
if (!existsSync27(commandsDir))
|
|
6900
7782
|
return {};
|
|
6901
7783
|
const commands = {};
|
|
6902
7784
|
try {
|
|
@@ -6904,7 +7786,7 @@ function loadCommands() {
|
|
|
6904
7786
|
if (!file.endsWith(".md"))
|
|
6905
7787
|
continue;
|
|
6906
7788
|
const name = basename2(file, ".md");
|
|
6907
|
-
const raw =
|
|
7789
|
+
const raw = readFileSync25(join26(commandsDir, file), "utf-8");
|
|
6908
7790
|
let description;
|
|
6909
7791
|
let template = raw;
|
|
6910
7792
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
@@ -6932,7 +7814,8 @@ var plugin = async (input, _options) => {
|
|
|
6932
7814
|
const compactionHook = createCompactionHook({ directory }, fileTracker);
|
|
6933
7815
|
const orchestratorGuard = new OrchestratorGuard;
|
|
6934
7816
|
const autoLearnHook = createAutoLearnHook(client, fileTracker, directory, appLog);
|
|
6935
|
-
|
|
7817
|
+
let loopDetector;
|
|
7818
|
+
let eventLog;
|
|
6936
7819
|
const notifCtrl = new NotificationController(undefined, appLog);
|
|
6937
7820
|
const agentConfigs = getAgentConfigs({});
|
|
6938
7821
|
const mcps = createFlowDeckMcps();
|
|
@@ -6947,6 +7830,16 @@ var plugin = async (input, _options) => {
|
|
|
6947
7830
|
}
|
|
6948
7831
|
const flowdeckConfig = loadFlowDeckConfig(directory);
|
|
6949
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
|
+
});
|
|
6950
7843
|
const agentModels = {};
|
|
6951
7844
|
for (const [name, agentCfg] of Object.entries(flowdeckConfig.agents ?? {})) {
|
|
6952
7845
|
if (agentCfg.model) {
|
|
@@ -6986,8 +7879,8 @@ var plugin = async (input, _options) => {
|
|
|
6986
7879
|
}
|
|
6987
7880
|
}
|
|
6988
7881
|
}
|
|
6989
|
-
const skillsDir =
|
|
6990
|
-
if (
|
|
7882
|
+
const skillsDir = join26(dirname3(fileURLToPath2(import.meta.url)), "..", "src", "skills");
|
|
7883
|
+
if (existsSync27(skillsDir)) {
|
|
6991
7884
|
const cfgAny = cfg;
|
|
6992
7885
|
if (!cfgAny.skills || typeof cfgAny.skills !== "object") {
|
|
6993
7886
|
cfgAny.skills = { paths: [] };
|
|
@@ -7026,7 +7919,8 @@ var plugin = async (input, _options) => {
|
|
|
7026
7919
|
codegraph: codegraphTool,
|
|
7027
7920
|
"load-rules": loadRulesTool,
|
|
7028
7921
|
"list-rules": listRulesTool,
|
|
7029
|
-
"rtk-setup": rtkSetupTool
|
|
7922
|
+
"rtk-setup": rtkSetupTool,
|
|
7923
|
+
"merge-assist": mergeAssistTool
|
|
7030
7924
|
},
|
|
7031
7925
|
"shell.env": shellEnvHook,
|
|
7032
7926
|
"todo.updated": todoHook,
|
|
@@ -7095,9 +7989,19 @@ var plugin = async (input, _options) => {
|
|
|
7095
7989
|
await patchTrustHook({ directory }, toolInput, toolOutput);
|
|
7096
7990
|
await decisionTraceHook({ directory }, toolInput, toolOutput);
|
|
7097
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
|
+
}
|
|
7098
7999
|
},
|
|
7099
8000
|
"tool.execute.after": async (toolInput, toolOutput) => {
|
|
7100
|
-
await eventLog.after({ directory }, toolInput, toolOutput);
|
|
8001
|
+
const eventLogHealthy = await eventLog.after({ directory }, toolInput, toolOutput);
|
|
8002
|
+
if (!eventLogHealthy) {
|
|
8003
|
+
loopDetector.setPersistenceHealthy(false);
|
|
8004
|
+
}
|
|
7101
8005
|
await contextMonitor["tool.execute.after"](toolInput, toolOutput);
|
|
7102
8006
|
}
|
|
7103
8007
|
};
|