@dv.nghiem/flowdeck 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1206,32 +1206,63 @@ var ORCHESTRATOR_PROMPT = `You are the FlowDeck Orchestrator. You coordinate mul
1206
1206
  - Run the entire coding workflow yourself
1207
1207
 
1208
1208
  Your ONLY job is to:
1209
- 1. **Analyze** the request
1210
- 2. **Classify** the task type and estimate complexity/risk/ambiguity
1211
- 3. **Choose** the appropriate workflow and execution path
1212
- 4. **Route** work to the correct agent or execution path
1209
+ 1. **Evaluate** the request
1210
+ 2. **Clarify** only when critical ambiguity exists
1211
+ 3. **Route** to the correct workflow and agent
1212
+ 4. **Delegate** via native \`@agent-name\` mentions
1213
1213
  5. **Supervise** progress
1214
1214
  6. **Collect** results
1215
1215
  7. **Return** the final coordinated outcome
1216
1216
 
1217
- ## Routing-First Protocol
1217
+ ## The Orchestrator Loop
1218
1218
 
1219
- For EVERY user request, you MUST follow this exact sequence BEFORE any execution begins:
1219
+ For EVERY turn, follow this exact sequence:
1220
1220
 
1221
- ### Step 1: Analyze
1222
- - Read STATE.md if it exists
1223
- - Identify current phase and workflow class
1224
- - Understand what the user is asking for
1221
+ ### 1. Evaluate
1222
+ - Read STATE.md if it exists.
1223
+ - Identify current phase, workflow class, and incomplete steps.
1224
+ - Classify the task type and estimate simplicity, confidence, risk, codebase familiarity, and complexity.
1225
+ - Check for critical ambiguity: missing requirements, conflicting constraints, or inability to choose a workflow class.
1225
1226
 
1226
- ### Step 2: Classify
1227
- Estimate:
1228
- - Simplicity: Is this a rename, typo fix, config update, or simple question?
1229
- - Confidence: How well does the request match known patterns?
1230
- - Risk: Blast radius (files touched) and sensitivity (auth, security, data)
1231
- - Codebase familiarity: Is the codebase mapping fresh?
1232
- - Complexity: Cheap (classify, validate, summarize) vs expensive (architect, refactor)
1227
+ ### 2. Clarify (only if critical ambiguity exists)
1228
+ - If the request is too vague to classify confidently, ask the human ONE targeted clarifying question.
1229
+ - Frame the question with a default recommendation and rationale.
1230
+ - If ambiguity is not critical, make a reasonable routing decision instead of asking.
1231
+ - Never ask more than one question per turn.
1232
+
1233
+ ### 3. Route
1234
+ - Select the minimal sufficient workflow class (quick, standard, explore, ui-heavy, bugfix, docs-only, verify-heavy).
1235
+ - Choose the next stage based on the Workflow Decision injected into your context.
1236
+ - Emit a routing decision in the exact format below.
1237
+
1238
+ ### 4. Delegate
1239
+ - Mention the target agent with \`@agent-name <full task description>\`.
1240
+ - Provide focused context: task, relevant files, and expected output.
1241
+ - Do NOT execute the work yourself.
1242
+
1243
+ ### 5. Supervise / Loop
1244
+ - When the delegated agent finishes, evaluate the result.
1245
+ - If the workflow is incomplete, return to step 3 and route the next stage.
1246
+ - If a specialist fails twice, escalate to @supervisor or ask the human.
1247
+
1248
+ ## Routing Decision Format
1249
+
1250
+ Before delegating, you MUST emit a routing decision in this exact format:
1251
+
1252
+ \`\`\`
1253
+ ## Routing Decision
1254
+
1255
+ **Request:** <brief summary of user request>
1256
+ **Classification:** <task type> | Confidence: <0.0-1.0>
1257
+ **Workflow Selected:** <workflow class>
1258
+ **Next Stage:** <stage name from Workflow Decision>
1259
+ **Reason:** <why this workflow and stage were chosen>
1260
+ **Execution Path:** <which agent(s) will execute>
1261
+ **Estimated Blast Radius:** <number of files or "unknown">
1262
+ \`\`\`
1263
+
1264
+ ## Workflow Classes
1233
1265
 
1234
- ### Step 3: Choose Workflow
1235
1266
  Select ONE of these workflow classes:
1236
1267
 
1237
1268
  | Workflow Class | Execution Path | When to Select |
@@ -1244,26 +1275,29 @@ Select ONE of these workflow classes:
1244
1275
  | \`docs-only\` | Route to @default-executor with \`inspect-only\` or \`simple-edit\` mode, or @writer for large docs | Documentation-only changes |
1245
1276
  | \`verify-heavy\` | Plan with @planner (enhanced checks) → Execute with specialists → Verify with @reviewer + @security-auditor | High blast radius or sensitive paths |
1246
1277
 
1247
- ### Step 4: Log the Decision
1248
- Before routing, you MUST emit a routing decision in this exact format:
1278
+ ## How to delegate
1249
1279
 
1250
- \`\`\`
1251
- ## Routing Decision
1280
+ Use \`@agent-name\` mentions to route work. Always include complete context — the subagent has no access to your reasoning:
1252
1281
 
1253
- **Request:** <brief summary of user request>
1254
- **Classification:** <task type> | Confidence: <0.0-1.0>
1255
- **Workflow Selected:** <workflow class>
1256
- **Reason:** <why this workflow was chosen>
1257
- **Execution Path:** <which agent(s) will execute>
1258
- **Estimated Blast Radius:** <number of files or "unknown">
1259
- \`\`\`
1282
+ @backend-coder
1283
+ Task: <exact description>
1284
+ Files: <targets>
1285
+ Constraints: <constraints>
1286
+ Acceptance criteria: <done definition>
1287
+ Context: <relevant findings>
1260
1288
 
1261
- ### Step 5: Route and Supervise
1262
- - Use native OpenCode agent routing: mention the agent with \`@agent-name <full task description>\`
1263
- - Provide clear, focused context including task description and any relevant files
1264
- - Wait for completion
1265
- - Collect results
1266
- - If escalation is needed, log the escalation and re-route
1289
+ ## Recovery when a tool is blocked by the guard
1290
+
1291
+ If you see \`[Orchestrator Guard]\`, do NOT retry the same tool. Switch immediately to \`@agent-name\` delegation. The guard message lists available agents — pick the appropriate one and delegate with full context.
1292
+
1293
+ ## Recovery when an agent returns no output or fails
1294
+
1295
+ 1. Retry ONCE with more specific task description and explicit file targets.
1296
+ 2. If it fails again, report to the human with the exact blocker. Do NOT loop.
1297
+
1298
+ ## You must never stop with "blocked" without first attempting @agent delegation
1299
+
1300
+ If your current approach is blocked, \`@agent\` delegation is always available as an alternative path forward. Only escalate to the human if delegation itself fails twice.
1267
1301
 
1268
1302
  ## What You MAY Do Directly
1269
1303
 
@@ -1276,6 +1310,8 @@ You may ONLY use these tools directly:
1276
1310
  - **decision-trace** — Record decisions
1277
1311
  - **policy-engine** — Check policies
1278
1312
  - **reflect** — Gather session artifacts
1313
+ - **load-rules / list-rules** — Discover agent capabilities and rules
1314
+ - **council** — Synthesize consensus across specialists
1279
1315
 
1280
1316
  You may NEVER use:
1281
1317
  - write, write_file, create, create_file
@@ -1306,6 +1342,9 @@ When workflow class is \`standard\`, \`explore\`, \`ui-heavy\`, \`bugfix\`, or \
1306
1342
  - \`@reviewer\` — code quality review
1307
1343
  - \`@security-auditor\` — security review
1308
1344
  - \`@debug-specialist\` — root cause analysis
1345
+ - \`@build-error-resolver\` — build/type errors
1346
+ - \`@performance-optimizer\` — performance bottlenecks
1347
+ - \`@refactor-guide\` — safe refactoring
1309
1348
 
1310
1349
  ### Parallel Execution Patterns
1311
1350
 
@@ -6342,101 +6381,613 @@ import { join as join19, basename as basename2 } from "path";
6342
6381
  import { fileURLToPath as fileURLToPath2 } from "url";
6343
6382
  import { dirname as dirname3 } from "path";
6344
6383
 
6345
- // src/services/prompt-cache.ts
6346
- import { createHash as createHash3 } from "crypto";
6347
- var DEFAULT_PROMPT_CACHE_TTL_MS = 5 * 60 * 1000;
6348
-
6349
- class PromptCacheImpl {
6350
- store = new Map;
6351
- getKey(description, stage, languages) {
6352
- const normalized = description.trim().toLowerCase().replace(/\s+/g, " ");
6353
- const sortedLanguages = [...languages].sort().join(",");
6354
- const payload = `${normalized}|${stage}|${sortedLanguages}`;
6355
- return createHash3("sha256").update(payload).digest("hex");
6356
- }
6357
- get(key) {
6358
- const entry = this.store.get(key);
6359
- if (!entry)
6360
- return;
6361
- if (Date.now() >= entry.expiresAt) {
6362
- this.store.delete(key);
6363
- return;
6364
- }
6365
- return entry.fragment;
6384
+ // src/services/preflight-explorer.ts
6385
+ var QUESTION_KIND_PATTERNS = [
6386
+ {
6387
+ kind: "what-tech-stack",
6388
+ patterns: ["tech stack", "language", "framework", "what are you using", "what tech", "built with", "written in"]
6389
+ },
6390
+ {
6391
+ kind: "is-project-initialized",
6392
+ patterns: ["initialized", "set up", "codebase mapped", "map-codebase", "new feature"]
6393
+ },
6394
+ {
6395
+ kind: "what-is-current-phase",
6396
+ patterns: ["current phase", "which phase", "what phase", "where are we", "current state"]
6397
+ },
6398
+ {
6399
+ kind: "what-patterns-exist",
6400
+ patterns: ["existing pattern", "how is it done", "how does the codebase", "pattern used", "architecture"]
6401
+ },
6402
+ {
6403
+ kind: "is-ui-heavy",
6404
+ patterns: ["ui", "frontend", "user interface", "webpage", "web app", "dashboard", "landing page", "screen"]
6405
+ },
6406
+ {
6407
+ kind: "has-existing-tests",
6408
+ patterns: ["test", "spec", "coverage", "tdd", "regression"]
6409
+ },
6410
+ {
6411
+ kind: "has-existing-docs",
6412
+ patterns: ["docs", "documentation", "readme", "api docs"]
6413
+ },
6414
+ {
6415
+ kind: "has-ci-cd",
6416
+ patterns: ["ci/cd", "continuous integration", "deploy", "pipeline", "github actions", ".github/workflow"]
6417
+ },
6418
+ {
6419
+ kind: "what-agents-available",
6420
+ patterns: ["which agent", "available agent", "what agent"]
6421
+ },
6422
+ {
6423
+ kind: "what-commands-available",
6424
+ patterns: ["which command", "available command", "what command", "slash command"]
6425
+ },
6426
+ {
6427
+ kind: "what-skills-available",
6428
+ patterns: ["skill", "available skill"]
6429
+ },
6430
+ {
6431
+ kind: "has-prior-decisions",
6432
+ patterns: ["prior decision", "previous discussion", "what was decided", "earlier session", "previous phase"]
6433
+ },
6434
+ {
6435
+ kind: "has-governance",
6436
+ patterns: ["governance", "policy", "approval", "supervisor"]
6366
6437
  }
6367
- set(key, fragment, ttlMs = DEFAULT_PROMPT_CACHE_TTL_MS) {
6368
- this.store.set(key, {
6369
- fragment,
6370
- expiresAt: Date.now() + Math.max(0, ttlMs)
6371
- });
6438
+ ];
6439
+ function shouldSuppressQuestion(question, result, sessionHistory) {
6440
+ const lower = question.toLowerCase();
6441
+ const alreadyAsked = sessionHistory.some((h) => h.toLowerCase().trim() === lower.trim());
6442
+ if (alreadyAsked) {
6443
+ return {
6444
+ suppress: true,
6445
+ reason: "This question was already asked in the current session."
6446
+ };
6372
6447
  }
6373
- invalidate() {
6374
- this.store.clear();
6375
- invalidateCache();
6448
+ const matchedEvidence = result.evidenceItems.filter((ev) => {
6449
+ const qKind = classifyQuestionKind(lower);
6450
+ return qKind !== null && qKind === ev.answersQuestion;
6451
+ });
6452
+ if (matchedEvidence.length > 0) {
6453
+ return {
6454
+ suppress: true,
6455
+ answeredByEvidence: true,
6456
+ reason: `Answered by repo evidence: ${matchedEvidence.map((e) => e.summary).join("; ")}`,
6457
+ evidence: matchedEvidence
6458
+ };
6376
6459
  }
6460
+ return { suppress: false };
6377
6461
  }
6378
- var sharedPromptCache = new PromptCacheImpl;
6379
-
6380
- // src/services/token-metrics.ts
6381
- var ESTIMATION_COEFFICIENTS = {
6382
- prose: 4,
6383
- code: 3.5,
6384
- json: 3.5
6385
- };
6386
- var usageByRun = new Map;
6387
- function toPositiveInt(value) {
6388
- if (typeof value === "string") {
6389
- const n = Number(value);
6390
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
6462
+ function refineClassification(clarificationPrompt, result, sessionHistory) {
6463
+ const promptLower = clarificationPrompt.toLowerCase();
6464
+ const isTaskTypeQuestion = result.hasProjectMD && (promptLower.includes("new feature") || promptLower.includes("bug fix") || promptLower.includes("ui change") || promptLower.includes("documentation") || promptLower.includes("describe the task") || promptLower.includes("more detail"));
6465
+ if (isTaskTypeQuestion) {
6466
+ return {
6467
+ clarificationStillNeeded: false,
6468
+ resolvedReason: "PROJECT.md exists — project is initialized. Defaulting ambiguous task to feature."
6469
+ };
6391
6470
  }
6392
- if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
6393
- return Math.floor(value);
6471
+ const suppress = shouldSuppressQuestion(clarificationPrompt, result, sessionHistory);
6472
+ if (suppress.suppress) {
6473
+ return {
6474
+ clarificationStillNeeded: false,
6475
+ resolvedReason: suppress.reason
6476
+ };
6394
6477
  }
6395
- return 0;
6396
- }
6397
- function getAccumulated(runId) {
6398
- return usageByRun.get(runId) ?? {
6399
- inputTokens: 0,
6400
- outputTokens: 0,
6401
- reasoningTokens: 0,
6402
- cacheReadTokens: 0,
6403
- cacheWriteTokens: 0,
6404
- messageCount: 0
6478
+ const lines = [];
6479
+ if (result.hasProjectMD)
6480
+ lines.push("PROJECT.md is present (project is initialized).");
6481
+ if (result.hasStateMD)
6482
+ lines.push("STATE.md is present (project has active session).");
6483
+ if (result.hasPriorDiscussions)
6484
+ lines.push("Prior DISCUSS.md files exist.");
6485
+ if (result.techStack.length > 0)
6486
+ lines.push(`Tech stack: ${result.techStack.join(", ")}.`);
6487
+ if (result.implementationPatterns.length > 0) {
6488
+ lines.push(`Implementation patterns: ${result.implementationPatterns.join(", ")}.`);
6489
+ }
6490
+ return {
6491
+ clarificationStillNeeded: true,
6492
+ supervisorContext: lines.length > 0 ? lines.join(" ") : undefined
6405
6493
  };
6406
6494
  }
6407
- function recordMessageUsage(runId, usage) {
6408
- const current = getAccumulated(runId);
6409
- const next = {
6410
- inputTokens: current.inputTokens + toPositiveInt(usage.input),
6411
- outputTokens: current.outputTokens + toPositiveInt(usage.output),
6412
- reasoningTokens: current.reasoningTokens + toPositiveInt(usage.reasoning),
6413
- cacheReadTokens: current.cacheReadTokens + toPositiveInt(usage.cache?.read),
6414
- cacheWriteTokens: current.cacheWriteTokens + toPositiveInt(usage.cache?.write),
6415
- messageCount: current.messageCount + 1
6416
- };
6417
- usageByRun.set(runId, next);
6495
+ var STOP_WORDS = new Set([
6496
+ "with",
6497
+ "that",
6498
+ "this",
6499
+ "from",
6500
+ "into",
6501
+ "when",
6502
+ "then",
6503
+ "will",
6504
+ "have",
6505
+ "been",
6506
+ "does",
6507
+ "should",
6508
+ "would",
6509
+ "could",
6510
+ "after",
6511
+ "before",
6512
+ "about"
6513
+ ]);
6514
+ function classifyQuestionKind(questionLower) {
6515
+ for (const { kind, patterns } of QUESTION_KIND_PATTERNS) {
6516
+ if (patterns.some((p) => questionLower.includes(p)))
6517
+ return kind;
6518
+ }
6519
+ return null;
6418
6520
  }
6419
- function getRunUsage(runId) {
6420
- const u = getAccumulated(runId);
6521
+
6522
+ // src/services/workflow-router.ts
6523
+ function stage(name, command, requiresApproval, skippable, args) {
6524
+ return { name, command, args, requiresApproval, skippable };
6525
+ }
6526
+ function scoreTaskForRouting(criteria) {
6527
+ const simplicity = (criteria.taskType === "simple" ? 1 : 0) * 0.3;
6528
+ const confidence = criteria.confidence * 0.2;
6529
+ const lowRisk = !criteria.isSensitive && criteria.blastRadius < 3 ? 0.2 : 0;
6530
+ const knownCodebase = criteria.codebaseFreshness === "fresh" ? 0.15 : 0;
6531
+ const cheapComplexity = criteria.complexity === "cheap" ? 0.15 : 0;
6532
+ const total = simplicity + confidence + lowRisk + knownCodebase + cheapComplexity;
6421
6533
  return {
6422
- runId,
6423
- totalTokens: u.inputTokens + u.outputTokens + u.reasoningTokens + u.cacheReadTokens + u.cacheWriteTokens,
6424
- inputTokens: u.inputTokens,
6425
- outputTokens: u.outputTokens,
6426
- reasoningTokens: u.reasoningTokens,
6427
- cacheReadTokens: u.cacheReadTokens,
6428
- cacheWriteTokens: u.cacheWriteTokens,
6429
- messageCount: u.messageCount
6534
+ simplicity,
6535
+ confidence,
6536
+ lowRisk,
6537
+ knownCodebase,
6538
+ cheapComplexity,
6539
+ total
6430
6540
  };
6431
6541
  }
6432
- function estimateTokens(text, kind = "prose") {
6433
- if (!text || text.length === 0)
6434
- return 0;
6435
- const coefficient = ESTIMATION_COEFFICIENTS[kind] ?? ESTIMATION_COEFFICIENTS.prose;
6436
- return Math.max(0, Math.round(text.length / coefficient));
6437
- }
6438
-
6439
- // src/services/context-ingress.ts
6542
+ function buildAdaptiveStageSequence(criteria) {
6543
+ const scores = scoreTaskForRouting(criteria);
6544
+ const totalScore = scores.total;
6545
+ let workflowClass;
6546
+ let stages;
6547
+ let reason;
6548
+ if (totalScore >= 0.75 && (criteria.taskType === "simple" || criteria.taskType === "docs")) {
6549
+ workflowClass = "quick";
6550
+ stages = [
6551
+ stage("execute", "fd-execute", false, true),
6552
+ stage("verify", "fd-verify", false, true)
6553
+ ];
6554
+ reason = `Quick workflow: score ${totalScore.toFixed(2)} >= 0.75 for ${criteria.taskType} task`;
6555
+ } else if (criteria.taskType === "bugfix") {
6556
+ workflowClass = "bugfix";
6557
+ stages = [
6558
+ stage("discuss", "fd-discuss", false, false),
6559
+ stage("fix-bug", "fd-fix-bug", false, false),
6560
+ stage("verify", "fd-verify", false, false)
6561
+ ];
6562
+ reason = "Bugfix workflow: task type is bugfix";
6563
+ } else if (criteria.taskType === "docs" && totalScore < 0.75) {
6564
+ workflowClass = "docs-only";
6565
+ stages = [
6566
+ stage("write-docs", "fd-write-docs", false, false),
6567
+ stage("verify", "fd-verify", false, true)
6568
+ ];
6569
+ reason = `Docs-only workflow: score ${totalScore.toFixed(2)} < 0.75 for docs task`;
6570
+ } else if (criteria.taskType === "ui-feature") {
6571
+ workflowClass = "ui-heavy";
6572
+ stages = [
6573
+ stage("discuss", "fd-discuss", false, false),
6574
+ stage("design", "fd-design", false, false, "--mode=draft"),
6575
+ stage("plan", "fd-plan", true, false),
6576
+ stage("execute", "fd-execute", false, false),
6577
+ stage("verify", "fd-verify", false, false)
6578
+ ];
6579
+ reason = "UI-heavy workflow: task type indicates UI-heavy work";
6580
+ } else if (criteria.blastRadius >= 5 || criteria.isSensitive) {
6581
+ workflowClass = "verify-heavy";
6582
+ stages = [
6583
+ stage("plan", "fd-plan", true, false),
6584
+ stage("execute", "fd-execute", false, false),
6585
+ stage("verify", "fd-verify", false, false)
6586
+ ];
6587
+ reason = `Verify-heavy workflow: blastRadius=${criteria.blastRadius}, isSensitive=${criteria.isSensitive}`;
6588
+ } else if (criteria.confidence < 0.6 || criteria.taskType === "ambiguous") {
6589
+ workflowClass = "explore";
6590
+ stages = [
6591
+ stage("discuss", "fd-discuss", false, false),
6592
+ stage("plan", "fd-plan", true, false),
6593
+ stage("execute", "fd-execute", false, false),
6594
+ stage("verify", "fd-verify", false, false)
6595
+ ];
6596
+ reason = `Explore workflow: confidence=${criteria.confidence}, taskType=${criteria.taskType}`;
6597
+ } else {
6598
+ workflowClass = "standard";
6599
+ stages = [
6600
+ stage("plan", "fd-plan", true, false),
6601
+ stage("execute", "fd-execute", false, false),
6602
+ stage("verify", "fd-verify", false, false)
6603
+ ];
6604
+ reason = `Standard workflow: score ${totalScore.toFixed(2)} with taskType ${criteria.taskType}`;
6605
+ }
6606
+ return {
6607
+ workflowClass,
6608
+ stages,
6609
+ criteria,
6610
+ scores,
6611
+ reason
6612
+ };
6613
+ }
6614
+
6615
+ // src/services/quick-router.ts
6616
+ var BUG_SIGNALS = [
6617
+ "fix",
6618
+ "bug",
6619
+ "broken",
6620
+ "not working",
6621
+ "doesn't work",
6622
+ "does not work",
6623
+ "error",
6624
+ "crash",
6625
+ "regression",
6626
+ "debug",
6627
+ "exception",
6628
+ "failing",
6629
+ "fails",
6630
+ "incorrect",
6631
+ "wrong output",
6632
+ "infinite loop",
6633
+ "null pointer",
6634
+ "undefined",
6635
+ "404",
6636
+ "500",
6637
+ "stack trace",
6638
+ "traceback",
6639
+ "root cause",
6640
+ "why is"
6641
+ ];
6642
+ var UI_SIGNALS = [
6643
+ "landing page",
6644
+ "dashboard",
6645
+ "admin panel",
6646
+ "admin page",
6647
+ "app screen",
6648
+ "onboarding",
6649
+ "onboard",
6650
+ "wireframe",
6651
+ "mockup",
6652
+ "design system",
6653
+ "component library",
6654
+ "ui component",
6655
+ "ux flow",
6656
+ "user interface",
6657
+ "web app",
6658
+ "web application",
6659
+ "website",
6660
+ "frontend page",
6661
+ "mobile screen",
6662
+ "login page",
6663
+ "signup page",
6664
+ "settings page",
6665
+ "profile page",
6666
+ "modal",
6667
+ "dialog",
6668
+ "sidebar",
6669
+ "navigation",
6670
+ "navbar",
6671
+ "header",
6672
+ "footer",
6673
+ "layout",
6674
+ "responsive",
6675
+ "accessibility",
6676
+ "a11y",
6677
+ "dark mode",
6678
+ "theme"
6679
+ ];
6680
+ var DOCS_SIGNALS = [
6681
+ "docs",
6682
+ "documentation",
6683
+ "readme",
6684
+ "api docs",
6685
+ "usage guide",
6686
+ "write docs",
6687
+ "document",
6688
+ "document the",
6689
+ "how to use",
6690
+ "tutorial",
6691
+ "changelog",
6692
+ "contributing guide",
6693
+ "docstring",
6694
+ "jsdoc",
6695
+ "tsdoc"
6696
+ ];
6697
+ var SIMPLE_SIGNALS = [
6698
+ "rename",
6699
+ "move file",
6700
+ "quick",
6701
+ "minor",
6702
+ "small change",
6703
+ "one-liner",
6704
+ "typo",
6705
+ "update constant",
6706
+ "update config",
6707
+ "bump version"
6708
+ ];
6709
+ var AMBIGUOUS_PATTERNS = [
6710
+ /^(improve|make|update|change|add|remove|help|do|run|check|use)\s+\w+$/i
6711
+ ];
6712
+ function classifyTask(description) {
6713
+ const lower = description.toLowerCase().trim();
6714
+ if (!lower) {
6715
+ return _ambiguous([], "What task do you want to run? Please describe what you need done.");
6716
+ }
6717
+ const bugHits = BUG_SIGNALS.filter((s) => lower.includes(s));
6718
+ const uiHits = UI_SIGNALS.filter((s) => lower.includes(s));
6719
+ const docsHits = DOCS_SIGNALS.filter((s) => lower.includes(s));
6720
+ const simpleHits = SIMPLE_SIGNALS.filter((s) => lower.includes(s));
6721
+ const bugScore = Math.min(bugHits.length * 0.35, 1);
6722
+ const uiScore = Math.min(uiHits.length * 0.3, 1);
6723
+ const docsScore = Math.min(docsHits.length * 0.4, 1);
6724
+ const simpleScore = Math.min(simpleHits.length * 0.45, 1);
6725
+ if (bugScore >= 0.35 && bugScore >= uiScore && bugScore >= docsScore) {
6726
+ return {
6727
+ taskType: "bugfix",
6728
+ confidence: Math.min(0.5 + bugScore * 0.5, 0.98),
6729
+ signals: bugHits,
6730
+ requiresDesign: false,
6731
+ requiresTDD: true,
6732
+ stageSequence: buildStageSequence("bugfix"),
6733
+ clarificationNeeded: bugScore < 0.5,
6734
+ clarificationPrompt: bugScore < 0.5 ? "Can you describe the specific bug? What is the expected vs actual behavior?" : undefined
6735
+ };
6736
+ }
6737
+ if (uiScore >= 0.3) {
6738
+ return {
6739
+ taskType: "ui-feature",
6740
+ confidence: Math.min(0.5 + uiScore * 0.45, 0.95),
6741
+ signals: uiHits,
6742
+ requiresDesign: true,
6743
+ requiresTDD: true,
6744
+ stageSequence: buildStageSequence("ui-feature"),
6745
+ clarificationNeeded: false
6746
+ };
6747
+ }
6748
+ if (docsScore >= 0.4 && docsScore >= bugScore) {
6749
+ return {
6750
+ taskType: "docs",
6751
+ confidence: Math.min(0.55 + docsScore * 0.4, 0.95),
6752
+ signals: docsHits,
6753
+ requiresDesign: false,
6754
+ requiresTDD: false,
6755
+ stageSequence: buildStageSequence("docs"),
6756
+ clarificationNeeded: false
6757
+ };
6758
+ }
6759
+ if (simpleScore >= 0.45) {
6760
+ return {
6761
+ taskType: "simple",
6762
+ confidence: Math.min(0.55 + simpleScore * 0.35, 0.9),
6763
+ signals: simpleHits,
6764
+ requiresDesign: false,
6765
+ requiresTDD: false,
6766
+ stageSequence: buildStageSequence("simple"),
6767
+ clarificationNeeded: false
6768
+ };
6769
+ }
6770
+ const wordCount = lower.split(/\s+/).filter(Boolean).length;
6771
+ if (wordCount >= 5) {
6772
+ return {
6773
+ taskType: "feature",
6774
+ confidence: Math.min(0.5 + wordCount * 0.02, 0.85),
6775
+ signals: [],
6776
+ requiresDesign: false,
6777
+ requiresTDD: true,
6778
+ stageSequence: buildStageSequence("feature"),
6779
+ clarificationNeeded: wordCount < 8,
6780
+ clarificationPrompt: wordCount < 8 ? "Is this a new feature, a bug fix, or a documentation task? A bit more context will help route it correctly." : undefined
6781
+ };
6782
+ }
6783
+ const isAmbiguousPattern = AMBIGUOUS_PATTERNS.some((p) => p.test(lower));
6784
+ if (isAmbiguousPattern || wordCount < 5) {
6785
+ return _ambiguous([], "Can you describe the task in more detail? For example: is it a new feature, a bug fix, a UI change, or documentation?");
6786
+ }
6787
+ return {
6788
+ taskType: "feature",
6789
+ confidence: 0.6,
6790
+ signals: [],
6791
+ requiresDesign: false,
6792
+ requiresTDD: true,
6793
+ stageSequence: buildStageSequence("feature"),
6794
+ clarificationNeeded: false
6795
+ };
6796
+ }
6797
+ function _ambiguous(signals, prompt) {
6798
+ return {
6799
+ taskType: "ambiguous",
6800
+ confidence: 0,
6801
+ signals,
6802
+ requiresDesign: false,
6803
+ requiresTDD: false,
6804
+ stageSequence: [],
6805
+ clarificationNeeded: true,
6806
+ clarificationPrompt: prompt
6807
+ };
6808
+ }
6809
+ function buildStageSequence(taskType) {
6810
+ switch (taskType) {
6811
+ case "feature":
6812
+ return [
6813
+ stage2("discuss", "fd-discuss", false, false),
6814
+ stage2("plan", "fd-plan", true, false),
6815
+ stage2("execute", "fd-execute", false, false),
6816
+ stage2("verify", "fd-verify", false, false)
6817
+ ];
6818
+ case "ui-feature":
6819
+ return [
6820
+ stage2("discuss", "fd-discuss", false, false),
6821
+ stage2("design", "fd-design", false, false, "--mode=draft"),
6822
+ stage2("plan", "fd-plan", true, false),
6823
+ stage2("execute", "fd-execute", false, false),
6824
+ stage2("verify", "fd-verify", false, false)
6825
+ ];
6826
+ case "bugfix":
6827
+ return [
6828
+ stage2("discuss", "fd-discuss", false, false),
6829
+ stage2("fix-bug", "fd-fix-bug", false, false),
6830
+ stage2("verify", "fd-verify", false, false)
6831
+ ];
6832
+ case "docs":
6833
+ return [
6834
+ stage2("discuss", "fd-discuss", false, false),
6835
+ stage2("write-docs", "fd-write-docs", false, false),
6836
+ stage2("verify", "fd-verify", false, true)
6837
+ ];
6838
+ case "simple":
6839
+ return [
6840
+ stage2("execute", "fd-execute", false, false),
6841
+ stage2("verify", "fd-verify", false, true)
6842
+ ];
6843
+ case "ambiguous":
6844
+ return [];
6845
+ default:
6846
+ return [];
6847
+ }
6848
+ }
6849
+ function buildAdaptiveWorkflow(description, exploration) {
6850
+ const base = exploration ? classifyTaskWithContext(description, exploration) : classifyTask(description);
6851
+ const complexityResult = classifyTaskComplexity(description);
6852
+ const criteria = {
6853
+ taskType: base.taskType,
6854
+ complexity: complexityResult.complexity,
6855
+ confidence: base.confidence,
6856
+ blastRadius: 0,
6857
+ isSensitive: false,
6858
+ codebaseFreshness: exploration ? "fresh" : "unknown",
6859
+ requiresTests: base.requiresTDD
6860
+ };
6861
+ const route = buildAdaptiveStageSequence(criteria);
6862
+ return {
6863
+ ...base,
6864
+ stageSequence: route.stages,
6865
+ workflowClass: route.workflowClass,
6866
+ scores: route.scores
6867
+ };
6868
+ }
6869
+ function stage2(name, command, requiresApproval, skippable, args) {
6870
+ return { name, command, args, requiresApproval, skippable };
6871
+ }
6872
+ function classifyTaskWithContext(description, exploration, sessionHistory = []) {
6873
+ const base = classifyTask(description);
6874
+ if (!base.clarificationNeeded) {
6875
+ return base;
6876
+ }
6877
+ const refinement = refineClassification(base.clarificationPrompt ?? "", exploration, sessionHistory);
6878
+ if (!refinement.clarificationStillNeeded) {
6879
+ const resolvedType = base.taskType === "ambiguous" ? "feature" : base.taskType;
6880
+ return {
6881
+ ...base,
6882
+ taskType: resolvedType,
6883
+ stageSequence: buildStageSequence(resolvedType),
6884
+ clarificationNeeded: false,
6885
+ clarificationPrompt: undefined,
6886
+ confidence: Math.max(base.confidence, 0.55)
6887
+ };
6888
+ }
6889
+ const enrichedPrompt = refinement.supervisorContext ? `${base.clarificationPrompt ?? ""} (Context: ${refinement.supervisorContext})` : base.clarificationPrompt;
6890
+ return {
6891
+ ...base,
6892
+ clarificationPrompt: enrichedPrompt
6893
+ };
6894
+ }
6895
+
6896
+ // src/services/prompt-cache.ts
6897
+ import { createHash as createHash3 } from "crypto";
6898
+ var DEFAULT_PROMPT_CACHE_TTL_MS = 5 * 60 * 1000;
6899
+
6900
+ class PromptCacheImpl {
6901
+ store = new Map;
6902
+ getKey(description, stage3, languages) {
6903
+ const normalized = description.trim().toLowerCase().replace(/\s+/g, " ");
6904
+ const sortedLanguages = [...languages].sort().join(",");
6905
+ const payload = `${normalized}|${stage3}|${sortedLanguages}`;
6906
+ return createHash3("sha256").update(payload).digest("hex");
6907
+ }
6908
+ get(key) {
6909
+ const entry = this.store.get(key);
6910
+ if (!entry)
6911
+ return;
6912
+ if (Date.now() >= entry.expiresAt) {
6913
+ this.store.delete(key);
6914
+ return;
6915
+ }
6916
+ return entry.fragment;
6917
+ }
6918
+ set(key, fragment, ttlMs = DEFAULT_PROMPT_CACHE_TTL_MS) {
6919
+ this.store.set(key, {
6920
+ fragment,
6921
+ expiresAt: Date.now() + Math.max(0, ttlMs)
6922
+ });
6923
+ }
6924
+ invalidate() {
6925
+ this.store.clear();
6926
+ invalidateCache();
6927
+ }
6928
+ }
6929
+ var sharedPromptCache = new PromptCacheImpl;
6930
+
6931
+ // src/services/token-metrics.ts
6932
+ var ESTIMATION_COEFFICIENTS = {
6933
+ prose: 4,
6934
+ code: 3.5,
6935
+ json: 3.5
6936
+ };
6937
+ var usageByRun = new Map;
6938
+ function toPositiveInt(value) {
6939
+ if (typeof value === "string") {
6940
+ const n = Number(value);
6941
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
6942
+ }
6943
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
6944
+ return Math.floor(value);
6945
+ }
6946
+ return 0;
6947
+ }
6948
+ function getAccumulated(runId) {
6949
+ return usageByRun.get(runId) ?? {
6950
+ inputTokens: 0,
6951
+ outputTokens: 0,
6952
+ reasoningTokens: 0,
6953
+ cacheReadTokens: 0,
6954
+ cacheWriteTokens: 0,
6955
+ messageCount: 0
6956
+ };
6957
+ }
6958
+ function recordMessageUsage(runId, usage) {
6959
+ const current = getAccumulated(runId);
6960
+ const next = {
6961
+ inputTokens: current.inputTokens + toPositiveInt(usage.input),
6962
+ outputTokens: current.outputTokens + toPositiveInt(usage.output),
6963
+ reasoningTokens: current.reasoningTokens + toPositiveInt(usage.reasoning),
6964
+ cacheReadTokens: current.cacheReadTokens + toPositiveInt(usage.cache?.read),
6965
+ cacheWriteTokens: current.cacheWriteTokens + toPositiveInt(usage.cache?.write),
6966
+ messageCount: current.messageCount + 1
6967
+ };
6968
+ usageByRun.set(runId, next);
6969
+ }
6970
+ function getRunUsage(runId) {
6971
+ const u = getAccumulated(runId);
6972
+ return {
6973
+ runId,
6974
+ totalTokens: u.inputTokens + u.outputTokens + u.reasoningTokens + u.cacheReadTokens + u.cacheWriteTokens,
6975
+ inputTokens: u.inputTokens,
6976
+ outputTokens: u.outputTokens,
6977
+ reasoningTokens: u.reasoningTokens,
6978
+ cacheReadTokens: u.cacheReadTokens,
6979
+ cacheWriteTokens: u.cacheWriteTokens,
6980
+ messageCount: u.messageCount
6981
+ };
6982
+ }
6983
+ function estimateTokens(text, kind = "prose") {
6984
+ if (!text || text.length === 0)
6985
+ return 0;
6986
+ const coefficient = ESTIMATION_COEFFICIENTS[kind] ?? ESTIMATION_COEFFICIENTS.prose;
6987
+ return Math.max(0, Math.round(text.length / coefficient));
6988
+ }
6989
+
6990
+ // src/services/context-ingress.ts
6440
6991
  var DEFAULT_OPTIONS = {
6441
6992
  maxEvents: 20,
6442
6993
  eventMaxAgeMinutes: 30,
@@ -6664,9 +7215,10 @@ class ContextIngressService {
6664
7215
  const codebaseDocs = trivial.isTrivialChat ? {} : this.readCodebaseDocs(input.projectRoot);
6665
7216
  const recentEvents = trivial.isTrivialChat ? [] : this.readRecentEvents(input.projectRoot);
6666
7217
  const route = this.buildRoute(input.description);
7218
+ const workflowDecision = input.workflowDecision ?? buildAdaptiveWorkflow(input.description);
6667
7219
  const relevantRules = trivial.isTrivialChat ? [] : this.selectRelevantRules(input.projectRoot, input.description, state, route, input.currentStage);
6668
7220
  const relevantSkills = trivial.isTrivialChat ? [] : this.selectRelevantSkills(input.description);
6669
- const used = estimateTokens(JSON.stringify(state), "json") + estimateTokens(planContent, "prose") + estimateTokens(JSON.stringify(codebaseDocs), "prose") + estimateTokens(JSON.stringify(recentEvents), "json") + estimateTokens(JSON.stringify(relevantRules), "json") + estimateTokens(JSON.stringify(relevantSkills), "json");
7221
+ const used = estimateTokens(JSON.stringify(state), "json") + estimateTokens(planContent, "prose") + estimateTokens(JSON.stringify(codebaseDocs), "prose") + estimateTokens(JSON.stringify(recentEvents), "json") + estimateTokens(JSON.stringify(relevantRules), "json") + estimateTokens(JSON.stringify(relevantSkills), "json") + estimateTokens(JSON.stringify(workflowDecision), "json");
6670
7222
  const total = this.options.totalTokenBudget;
6671
7223
  const actualUsage = getRunUsage(input.sessionId);
6672
7224
  const hasActualTokens = actualUsage.totalTokens > 0;
@@ -6689,7 +7241,8 @@ class ContextIngressService {
6689
7241
  recentEvents,
6690
7242
  selectedRules: relevantRules,
6691
7243
  selectedSkills: relevantSkills,
6692
- tokenBudget
7244
+ tokenBudget,
7245
+ workflowDecision
6693
7246
  };
6694
7247
  this._assembledBySession.set(input.sessionId, ctx);
6695
7248
  this.persistBudgetSnapshot(ctx);
@@ -6716,12 +7269,12 @@ class ContextIngressService {
6716
7269
  }
6717
7270
  }
6718
7271
  buildPromptFragment(ctx) {
6719
- const stage = String(ctx.planningState.phase ?? "discuss");
7272
+ const stage3 = String(ctx.planningState.phase ?? "discuss");
6720
7273
  if (ctx.isTrivialChat) {
6721
- return `Greeting. Phase: ${stage}.`;
7274
+ return `Greeting. Phase: ${stage3}.`;
6722
7275
  }
6723
7276
  const languages = getCachedLanguages(ctx.directory);
6724
- const cacheKey = sharedPromptCache.getKey(ctx.description, stage, languages);
7277
+ const cacheKey = sharedPromptCache.getKey(ctx.description, stage3, languages);
6725
7278
  const cached = sharedPromptCache.get(cacheKey);
6726
7279
  if (cached)
6727
7280
  return cached;
@@ -6729,7 +7282,7 @@ class ContextIngressService {
6729
7282
  "# FlowDeck Context",
6730
7283
  "",
6731
7284
  `Task: ${ctx.description}`,
6732
- `Phase: ${stage}`,
7285
+ `Phase: ${stage3}`,
6733
7286
  "",
6734
7287
  "## Selected rules"
6735
7288
  ];
@@ -6756,6 +7309,22 @@ class ContextIngressService {
6756
7309
  lines.push(`blockers: ${ctx.planningState.blockers.join(", ")}`);
6757
7310
  }
6758
7311
  lines.push(`next_action: ${ctx.planningState.next_action}`);
7312
+ lines.push("", "## Workflow Decision");
7313
+ const decision = ctx.workflowDecision;
7314
+ if (decision) {
7315
+ lines.push(`workflowClass: ${decision.workflowClass ?? decision.taskType}`);
7316
+ lines.push(`taskType: ${decision.taskType}`);
7317
+ lines.push(`confidence: ${decision.confidence.toFixed(2)}`);
7318
+ lines.push(`requiresDesign: ${decision.requiresDesign}`);
7319
+ lines.push(`requiresTDD: ${decision.requiresTDD}`);
7320
+ lines.push(`stageSequence: ${decision.stageSequence.map((s) => s.name).join(" → ")}`);
7321
+ if (decision.clarificationNeeded) {
7322
+ lines.push(`clarificationNeeded: true`);
7323
+ lines.push(`clarificationPrompt: ${decision.clarificationPrompt ?? "(none provided)"}`);
7324
+ }
7325
+ } else {
7326
+ lines.push("_No workflow decision available._");
7327
+ }
6759
7328
  lines.push("", "## Recent events");
6760
7329
  if (ctx.recentEvents.length === 0) {
6761
7330
  lines.push("_No recent events._");
@@ -6776,7 +7345,8 @@ class ContextIngressService {
6776
7345
  const eventsTokens = estimateTokens(JSON.stringify(ctx.recentEvents), "json");
6777
7346
  const rulesTokens = estimateTokens(JSON.stringify(ctx.selectedRules), "json");
6778
7347
  const skillsTokens = estimateTokens(JSON.stringify(ctx.selectedSkills), "json");
6779
- const totalFallbackTokens = stateTokens + planTokens + docsTokens + eventsTokens + rulesTokens + skillsTokens;
7348
+ const workflowTokens = estimateTokens(JSON.stringify(ctx.workflowDecision), "json");
7349
+ const totalFallbackTokens = stateTokens + planTokens + docsTokens + eventsTokens + rulesTokens + skillsTokens + workflowTokens;
6780
7350
  const actualUsage = getRunUsage(ctx.sessionID);
6781
7351
  const hasActualTokens = actualUsage.totalTokens > 0;
6782
7352
  const totalUsed = hasActualTokens ? actualUsage.totalTokens : ctx.tokenBudget.usedTokens;
@@ -6805,7 +7375,8 @@ class ContextIngressService {
6805
7375
  docs: makeSnapshot("docs", docsTokens),
6806
7376
  events: makeSnapshot("events", eventsTokens),
6807
7377
  rules: makeSnapshot("rules", rulesTokens),
6808
- skills: makeSnapshot("skills", skillsTokens)
7378
+ skills: makeSnapshot("skills", skillsTokens),
7379
+ workflow: makeSnapshot("workflow", workflowTokens)
6809
7380
  };
6810
7381
  }
6811
7382
  getTotalBudget(sessionID) {
@@ -6918,12 +7489,12 @@ class ContextIngressService {
6918
7489
  const sharedDir = join19(__dir, "..", "agents", "shared");
6919
7490
  if (!existsSync19(rulesDir))
6920
7491
  return [];
6921
- const stage = currentStage ?? this.inferStageFromRoute(route);
7492
+ const stage3 = currentStage ?? this.inferStageFromRoute(route);
6922
7493
  const languages = getCachedLanguages(projectRoot);
6923
- const cacheKey = `${projectRoot}\x00${stage ?? ""}\x00${[...languages].sort().join(",")}`;
7494
+ const cacheKey = `${projectRoot}\x00${stage3 ?? ""}\x00${[...languages].sort().join(",")}`;
6924
7495
  let selection = ruleSelectionCache.get(cacheKey);
6925
7496
  if (!selection) {
6926
- selection = selectRulePaths(rulesDir, { languages, stage });
7497
+ selection = selectRulePaths(rulesDir, { languages, stage: stage3 });
6927
7498
  ruleSelectionCache.set(cacheKey, selection);
6928
7499
  }
6929
7500
  const seen = new Set;
@@ -7289,8 +7860,8 @@ class LoopDetector {
7289
7860
  var CONTRACTS = [
7290
7861
  {
7291
7862
  agent: "orchestrator",
7292
- role: "Coordinate multi-agent execution, inspect context directly, and route specialist work when appropriate.",
7293
- allowedTaskTypes: ["orchestration", "coordination", "delegation", "phase-management"],
7863
+ role: "Coordinate multi-agent execution, inspect context directly, and route specialist work via native @agent-name mentions.",
7864
+ allowedTaskTypes: ["orchestration", "coordination", "routing", "phase-management"],
7294
7865
  requiredInputs: ["STATE.md", "PLAN.md"],
7295
7866
  expectedOutputFields: ["completed_steps", "current_phase"],
7296
7867
  allowedTools: [
@@ -7597,43 +8168,283 @@ var CONTRACTS = [
7597
8168
  expectedOutputFields: ["updated_docs"],
7598
8169
  allowedTools: ["read", "write", "edit", "glob", "grep"],
7599
8170
  forbiddenActions: [
7600
- "modify application code",
7601
- "delete documentation without replacement"
8171
+ "modify application code",
8172
+ "delete documentation without replacement"
8173
+ ],
8174
+ escalationConditions: ["documentation conflicts with implementation"],
8175
+ stopConditions: ["docs updated and synced"],
8176
+ successCriteria: ["docs reflect current code", "no application code changed"]
8177
+ },
8178
+ {
8179
+ agent: "mapper",
8180
+ role: "Map and analyze codebase structure, dependencies, and call graphs.",
8181
+ allowedTaskTypes: ["codebase-mapping", "dependency-analysis", "call-graph"],
8182
+ requiredInputs: ["directory or module to map"],
8183
+ expectedOutputFields: ["structure_map", "dependencies", "summary"],
8184
+ allowedTools: ["read", "glob", "grep", "codegraph", "codegraph-search"],
8185
+ forbiddenActions: [
8186
+ "write or edit files",
8187
+ "run bash commands",
8188
+ "modify application source code"
8189
+ ],
8190
+ escalationConditions: ["codebase too large to map in one pass"],
8191
+ stopConditions: ["map complete"],
8192
+ successCriteria: ["structured map output", "no file modifications"]
8193
+ },
8194
+ {
8195
+ agent: "supervisor",
8196
+ role: "Governance review layer. Inspects existing commands/agents, validates policy, returns structured approve/revise/block/escalate decision. Never creates new commands or workflows.",
8197
+ allowedTaskTypes: ["governance-review", "policy-check", "pre-execution-review", "post-stage-review"],
8198
+ requiredInputs: ["target name (command or agent)", "task context"],
8199
+ expectedOutputFields: ["decision", "targetType", "targetName", "exists", "reasons", "missingRequirements", "riskFlags", "requiredChanges", "approvalStatus", "confidenceScore"],
8200
+ allowedTools: ["read", "glob", "grep", "planning-state", "policy-engine"],
8201
+ forbiddenActions: [
8202
+ "create new commands",
8203
+ "create new workflows",
8204
+ "invent new agent names",
8205
+ "modify command intent",
8206
+ "replace orchestrator",
8207
+ "become second dispatcher",
8208
+ "execute implementation tasks",
8209
+ "write or edit source files",
8210
+ "run bash commands",
8211
+ "modify PLAN.md or STATE.md"
8212
+ ],
8213
+ escalationConditions: [
8214
+ "human approval required and not granted",
8215
+ "confidence below threshold",
8216
+ "critical policy violation with no safe path forward"
8217
+ ],
8218
+ stopConditions: ["structured decision issued", "review complete"],
8219
+ successCriteria: [
8220
+ "structured SupervisorDecision returned",
8221
+ "no new commands or workflows created",
8222
+ "existing registry not modified",
8223
+ "decision is one of: approve, revise, block, escalate"
8224
+ ]
8225
+ },
8226
+ {
8227
+ agent: "default-executor",
8228
+ role: "General-purpose executor for simple direct tasks: renames, typo fixes, small edits.",
8229
+ allowedTaskTypes: ["simple-edit", "rename", "typo-fix", "quick-change"],
8230
+ requiredInputs: ["task description"],
8231
+ expectedOutputFields: ["files_modified", "summary"],
8232
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
8233
+ forbiddenActions: [
8234
+ "architectural changes",
8235
+ "database migrations",
8236
+ "orchestrate other agents",
8237
+ "expand scope silently"
8238
+ ],
8239
+ escalationConditions: ["task scope expands beyond simple edit"],
8240
+ stopConditions: ["task complete"],
8241
+ successCriteria: ["change made", "no regressions"]
8242
+ },
8243
+ {
8244
+ agent: "code-explorer",
8245
+ role: "Explore and map unfamiliar codebases before changes. Read-only analysis.",
8246
+ allowedTaskTypes: ["exploration", "code-mapping", "call-path-tracing"],
8247
+ requiredInputs: ["area or feature to explore"],
8248
+ expectedOutputFields: ["structure", "entry_points", "key_components", "call_paths"],
8249
+ allowedTools: [
8250
+ "read",
8251
+ "glob",
8252
+ "grep",
8253
+ "codegraph",
8254
+ "codegraph-search",
8255
+ "codegraph-node",
8256
+ "codegraph-explore"
8257
+ ],
8258
+ forbiddenActions: [
8259
+ "modify files",
8260
+ "implement changes",
8261
+ "run destructive commands"
8262
+ ],
8263
+ escalationConditions: ["codebase index missing and exploration blocked"],
8264
+ stopConditions: ["exploration report complete"],
8265
+ successCriteria: [
8266
+ "report delivered",
8267
+ "no files modified"
8268
+ ]
8269
+ },
8270
+ {
8271
+ agent: "debug-specialist",
8272
+ role: "Diagnose bugs through systematic root cause analysis. Report only, do not implement fixes directly.",
8273
+ allowedTaskTypes: ["debugging", "root-cause-analysis", "bug-investigation"],
8274
+ requiredInputs: ["bug report or failure output"],
8275
+ expectedOutputFields: ["root_cause", "evidence", "call_path", "recommended_fix"],
8276
+ allowedTools: ["read", "glob", "grep", "bash"],
8277
+ forbiddenActions: [
8278
+ "implement fixes directly",
8279
+ "suppress errors without fixing",
8280
+ "modify application code"
8281
+ ],
8282
+ escalationConditions: [
8283
+ "architectural problem found",
8284
+ "security issue discovered"
8285
+ ],
8286
+ stopConditions: ["debug report complete"],
8287
+ successCriteria: [
8288
+ "root cause identified",
8289
+ "fix recommended",
8290
+ "no implementation done"
8291
+ ]
8292
+ },
8293
+ {
8294
+ agent: "build-error-resolver",
8295
+ role: "Fix build errors, compilation failures, and dependency issues with minimal changes.",
8296
+ allowedTaskTypes: ["build-fix", "type-error-fix", "dependency-fix"],
8297
+ requiredInputs: ["build error output"],
8298
+ expectedOutputFields: ["files_modified", "summary", "verification"],
8299
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
8300
+ forbiddenActions: [
8301
+ "use as any or @ts-ignore to suppress type errors",
8302
+ "refactor unrelated code",
8303
+ "add features while fixing builds"
8304
+ ],
8305
+ escalationConditions: [
8306
+ "architectural problem causing build failure",
8307
+ "security issue discovered"
8308
+ ],
8309
+ stopConditions: ["build passes", "escalation required"],
8310
+ successCriteria: [
8311
+ "build and type check pass",
8312
+ "minimum changes applied",
8313
+ "no type-safety suppression without explanation"
8314
+ ]
8315
+ },
8316
+ {
8317
+ agent: "task-splitter",
8318
+ role: "Decompose complex tasks into parallel workstreams with explicit dependencies.",
8319
+ allowedTaskTypes: ["task-decomposition", "parallel-planning"],
8320
+ requiredInputs: ["complex task description"],
8321
+ expectedOutputFields: ["waves", "dependencies", "agent_assignments"],
8322
+ allowedTools: ["read", "glob", "grep", "planning-state"],
8323
+ forbiddenActions: [
8324
+ "execute tasks",
8325
+ "modify files",
8326
+ "assign non-existent agents"
8327
+ ],
8328
+ escalationConditions: ["task too small to benefit from parallelization"],
8329
+ stopConditions: ["parallel execution plan complete"],
8330
+ successCriteria: [
8331
+ "plan delivered",
8332
+ "dependencies clear",
8333
+ "no file modifications"
8334
+ ]
8335
+ },
8336
+ {
8337
+ agent: "discusser",
8338
+ role: "Extract requirements via structured Q&A and record decisions with D-XX numbering.",
8339
+ allowedTaskTypes: ["requirements-gathering", "discussion", "decision-recording"],
8340
+ requiredInputs: ["task description"],
8341
+ expectedOutputFields: ["decisions", "suppressed_questions", "open_questions"],
8342
+ allowedTools: ["read", "write", "edit", "glob", "grep", "planning-state"],
8343
+ forbiddenActions: [
8344
+ "implement features",
8345
+ "skip decision recording",
8346
+ "ask more than one question per turn"
8347
+ ],
8348
+ escalationConditions: ["conflict with prior decisions"],
8349
+ stopConditions: ["requirements complete", "plan ready"],
8350
+ successCriteria: [
8351
+ "decisions recorded",
8352
+ "no open questions remain"
8353
+ ]
8354
+ },
8355
+ {
8356
+ agent: "risk-analyst",
8357
+ role: "Assess risk of proposed changes using patch trust and regression signals. Read-only.",
8358
+ allowedTaskTypes: ["risk-assessment", "change-impact-analysis"],
8359
+ requiredInputs: ["change description", "trust signals"],
8360
+ expectedOutputFields: ["risk_level", "approval_required", "safer_alternative"],
8361
+ allowedTools: ["read", "glob", "grep"],
8362
+ forbiddenActions: [
8363
+ "modify files",
8364
+ "approve changes directly",
8365
+ "invent risk signals not in input data"
8366
+ ],
8367
+ escalationConditions: ["critical risk identified"],
8368
+ stopConditions: ["risk report complete"],
8369
+ successCriteria: [
8370
+ "structured report delivered",
8371
+ "no file modifications"
8372
+ ]
8373
+ },
8374
+ {
8375
+ agent: "policy-enforcer",
8376
+ role: "Apply policy gate matrix to decide whether a proposed edit can proceed. Read-only decision.",
8377
+ allowedTaskTypes: ["policy-enforcement", "gate-decision"],
8378
+ requiredInputs: ["file_path", "change_description", "policy_violations", "risk_score"],
8379
+ expectedOutputFields: ["decision", "trigger", "recommended_action"],
8380
+ allowedTools: ["read", "glob", "grep", "policy-engine"],
8381
+ forbiddenActions: [
8382
+ "modify files",
8383
+ "deviate from gate matrix",
8384
+ "approve blocked changes"
8385
+ ],
8386
+ escalationConditions: ["arch constraint violation"],
8387
+ stopConditions: ["gate decision issued"],
8388
+ successCriteria: [
8389
+ "decision applied per matrix",
8390
+ "no file modifications"
8391
+ ]
8392
+ },
8393
+ {
8394
+ agent: "performance-optimizer",
8395
+ role: "Identify and fix performance bottlenecks using profiling data and measurements.",
8396
+ allowedTaskTypes: ["performance-optimization", "profiling", "bottleneck-analysis"],
8397
+ requiredInputs: ["performance complaint or metric"],
8398
+ expectedOutputFields: ["baseline", "bottleneck", "fix", "after_measurement"],
8399
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
8400
+ forbiddenActions: [
8401
+ "optimize without measurements",
8402
+ "refactor unrelated code",
8403
+ "skip before/after verification"
7602
8404
  ],
7603
- escalationConditions: ["documentation conflicts with implementation"],
7604
- stopConditions: ["docs updated and synced"],
7605
- successCriteria: ["docs reflect current code", "no application code changed"]
8405
+ escalationConditions: ["architectural bottleneck requiring redesign"],
8406
+ stopConditions: ["performance report complete"],
8407
+ successCriteria: [
8408
+ "before and after measurements provided",
8409
+ "improvement verified"
8410
+ ]
7606
8411
  },
7607
8412
  {
7608
- agent: "supervisor",
7609
- role: "Governance review layer. Inspects existing commands/agents, validates policy, returns structured approve/revise/block/escalate decision. Never creates new commands or workflows.",
7610
- allowedTaskTypes: ["governance-review", "policy-check", "pre-execution-review", "post-stage-review"],
7611
- requiredInputs: ["target name (command or agent)", "task context"],
7612
- expectedOutputFields: ["decision", "targetType", "targetName", "exists", "reasons", "missingRequirements", "riskFlags", "requiredChanges", "approvalStatus", "confidenceScore"],
7613
- allowedTools: ["read", "glob", "grep", "planning-state", "policy-engine"],
8413
+ agent: "refactor-guide",
8414
+ role: "Guide safe refactoring without changing behavior.",
8415
+ allowedTaskTypes: ["refactoring", "code-cleanup", "structure-improvement"],
8416
+ requiredInputs: ["refactoring target"],
8417
+ expectedOutputFields: ["transformations", "test_results"],
8418
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
7614
8419
  forbiddenActions: [
7615
- "create new commands",
7616
- "create new workflows",
7617
- "invent new agent names",
7618
- "modify command intent",
7619
- "replace orchestrator",
7620
- "become second dispatcher",
7621
- "execute implementation tasks",
7622
- "write or edit source files",
7623
- "run bash commands",
7624
- "modify PLAN.md or STATE.md"
8420
+ "add features",
8421
+ "leave tests failing",
8422
+ "combine multiple transformations in one step"
7625
8423
  ],
7626
- escalationConditions: [
7627
- "human approval required and not granted",
7628
- "confidence below threshold",
7629
- "critical policy violation with no safe path forward"
8424
+ escalationConditions: ["tests fail during refactor"],
8425
+ stopConditions: ["refactor complete", "tests green"],
8426
+ successCriteria: [
8427
+ "behavior preserved",
8428
+ "tests pass"
8429
+ ]
8430
+ },
8431
+ {
8432
+ agent: "auto-learner",
8433
+ role: "Capture reusable knowledge from session artifacts as skills.",
8434
+ allowedTaskTypes: ["knowledge-capture", "skill-creation"],
8435
+ requiredInputs: ["session reflection artifacts"],
8436
+ expectedOutputFields: ["skills_created", "summary"],
8437
+ allowedTools: ["read", "reflect", "write"],
8438
+ forbiddenActions: [
8439
+ "ask user questions",
8440
+ "create routine skills",
8441
+ "create more than 3 skills per session"
7630
8442
  ],
7631
- stopConditions: ["structured decision issued", "review complete"],
8443
+ escalationConditions: ["no valuable patterns found"],
8444
+ stopConditions: ["skills written or no new skills identified"],
7632
8445
  successCriteria: [
7633
- "structured SupervisorDecision returned",
7634
- "no new commands or workflows created",
7635
- "existing registry not modified",
7636
- "decision is one of: approve, revise, block, escalate"
8446
+ "valuable patterns captured",
8447
+ "maximum 3 skills per session"
7637
8448
  ]
7638
8449
  }
7639
8450
  ];
@@ -7641,6 +8452,9 @@ var REGISTRY = new Map(CONTRACTS.map((c) => [c.agent, c]));
7641
8452
  function getContract(agent) {
7642
8453
  return REGISTRY.get(agent) ?? null;
7643
8454
  }
8455
+ function getAllContracts() {
8456
+ return [...CONTRACTS];
8457
+ }
7644
8458
 
7645
8459
  // src/services/agent-validator.ts
7646
8460
  function resolveValidatorMode(directory) {
@@ -8160,1622 +8974,1110 @@ function isPipedToShell(normalized) {
8160
8974
  return true;
8161
8975
  }
8162
8976
  return false;
8163
- }
8164
- function isBashCommandDangerous(cmd) {
8165
- const normalized = normalizeBashCommand(cmd);
8166
- for (const p of BLOCKED_PATTERNS.bash) {
8167
- if (normalized.includes(p)) {
8168
- return `FLOWDECK: Dangerous bash command blocked (matched "${p}").`;
8169
- }
8170
- }
8171
- if (isPipedToShell(normalized)) {
8172
- return "FLOWDECK: Piping curl/wget directly to a shell is blocked.";
8173
- }
8174
- if (/\bdd\b/.test(normalized) && /\/dev\/(sd|hd|nvme|mmcblk|disk)/.test(normalized)) {
8175
- return "FLOWDECK: dd command targeting a block device is blocked.";
8176
- }
8177
- if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*}\s*;\s*:/.test(normalized)) {
8178
- return "FLOWDECK: Fork bomb pattern is blocked.";
8179
- }
8180
- return null;
8181
- }
8182
- function isBlocked(tool13, args) {
8183
- const patterns = BLOCKED_PATTERNS[tool13];
8184
- if (!patterns)
8185
- return null;
8186
- if (tool13 === "bash") {
8187
- const cmd = args.command;
8188
- if (!cmd)
8189
- return null;
8190
- return isBashCommandDangerous(cmd);
8191
- }
8192
- if (tool13 === "read" || tool13 === "write") {
8193
- const filePath = extractTargetPath(args);
8194
- if (!filePath)
8195
- return null;
8196
- for (const p of patterns) {
8197
- if (filePath.includes(p)) {
8198
- return tool13 === "read" ? `FLOWDECK: Access to "${p}" files is blocked.` : `FLOWDECK: Writing to "${p}" is blocked.`;
8199
- }
8200
- }
8201
- return null;
8202
- }
8203
- return null;
8204
- }
8205
- function checkArchConstraint(directory, filePath) {
8206
- const constraintsPath = join20(codebaseDir(directory), "CONSTRAINTS.md");
8207
- if (!existsSync20(constraintsPath))
8208
- return null;
8209
- try {
8210
- const content = readFileSync20(constraintsPath, "utf-8");
8211
- const match = content.match(/## Forbidden Paths\n([\s\S]*?)(?:\n##|$)/);
8212
- if (!match)
8213
- return null;
8214
- for (const line of match[1].split(`
8215
- `)) {
8216
- const pattern = line.replace(/^-\s*/, "").split("#")[0].trim();
8217
- if (pattern && filePath.includes(pattern)) {
8218
- return `FLOWDECK [arch-constraint]: editing "${pattern}" is forbidden by .codebase/CONSTRAINTS.md`;
8219
- }
8220
- }
8221
- } catch {}
8222
- return null;
8223
- }
8224
- function checkPhaseEnforcement(directory) {
8225
- try {
8226
- const state = readPlanningState(directory);
8227
- const flowdeckConfig = resolveDesignFirstConfig(loadFlowDeckConfig(directory));
8228
- if (state.phase > 0 && state.phase < 3) {
8229
- return `FLOWDECK [phase-gate]: writing to codebase is blocked in phase ${state.phase} (${state.phase === 1 ? "discuss" : "plan"}). Run /fd-plan --confirm to enter execute phase.`;
8230
- }
8231
- if (flowdeckConfig.enabled && flowdeckConfig.requireApprovalBeforeImplementation && isUiDesignApprovalRequired(directory)) {
8232
- if (flowdeckConfig.enforcement === "advisory") {
8233
- return `FLOWDECK [design-gate]: advisory design-first mode detected missing approval. Run /fd-design --mode=draft or set design_override=true in STATE.md.`;
8234
- }
8235
- return `FLOWDECK [design-gate]: UI-heavy task requires approved design handoff before implementation. Run /fd-design --mode=draft and ensure design_stage=handoff_complete + design_approved=true, or set explicit design_override with reason.`;
8236
- }
8237
- } catch {}
8238
- return null;
8239
- }
8240
- function isUiDesignApprovalRequired(directory) {
8241
- const state = readPlanningState(directory);
8242
- if (state.design_override && state.design_override_reason && state.design_override_reason.trim().length > 0)
8243
- return false;
8244
- if (state.requires_design_first) {
8245
- return !(state.design_stage === "handoff_complete" && state.design_approved);
8246
- }
8247
- if (state.task_type && isUiHeavyTask(state.task_type)) {
8248
- return !(state.design_stage === "handoff_complete" && state.design_approved);
8249
- }
8250
- const planPath = phasePlanPath(directory, state.phase || 1);
8251
- if (!existsSync20(planPath))
8252
- return false;
8253
- const planContent = readFileSync20(planPath, "utf-8");
8254
- if (!isUiHeavyTask(planContent))
8255
- return false;
8256
- return !(state.design_stage === "handoff_complete" && state.design_approved);
8257
- }
8258
- function allow(reason, riskFlags = []) {
8259
- return { verdict: "allow", reason, riskFlags, source: "tool-guard" };
8260
- }
8261
- function deny(reason, escalationMessage, riskFlags = []) {
8262
- return {
8263
- verdict: "deny",
8264
- reason,
8265
- riskFlags,
8266
- source: "tool-guard",
8267
- escalationMessage
8268
- };
8269
- }
8270
- function evaluate(input) {
8271
- const { directory, tool: tool13, args } = input;
8272
- if (tool13 !== "bash" && tool13 !== "read" && tool13 !== "write" && tool13 !== "edit") {
8273
- return allow("Tool is not guardable");
8274
- }
8275
- const blocked = isBlocked(tool13, args);
8276
- if (blocked) {
8277
- return deny(blocked, blocked, ["dangerous-pattern"]);
8278
- }
8279
- if (tool13 === "write" || tool13 === "edit") {
8280
- const phaseBlock = checkPhaseEnforcement(directory);
8281
- if (phaseBlock) {
8282
- const isAdvisory = phaseBlock.includes("[design-gate]: advisory");
8283
- if (isAdvisory) {
8284
- return allow(phaseBlock, ["design-gate-advisory"]);
8285
- }
8286
- return deny(phaseBlock, phaseBlock, ["phase-gate"]);
8287
- }
8288
- const filePath = extractTargetPath(args);
8289
- const constraintBlock = checkArchConstraint(directory, filePath);
8290
- if (constraintBlock) {
8291
- return deny(constraintBlock, constraintBlock, ["arch-constraint"]);
8292
- }
8293
- }
8294
- return allow("Tool guard passed");
8295
- }
8296
-
8297
- // src/hooks/guard-rails.ts
8298
- import { existsSync as existsSync21, readFileSync as readFileSync21 } from "fs";
8299
- import { join as join21 } from "path";
8300
- var PLANNING_DIR2 = ".planning";
8301
- var CONFIG_FILE = "config.json";
8302
- var STATE_FILE2 = "STATE.md";
8303
- function resolveExecutionMode(configPath, trustScore, volatility) {
8304
- if (existsSync21(configPath)) {
8305
- try {
8306
- const config = JSON.parse(readFileSync21(configPath, "utf-8"));
8307
- if (config.execution_mode === "review-only")
8308
- return "review-only";
8309
- if (config.execution_mode === "guarded")
8310
- return "guarded";
8311
- if (config.execution_mode === "auto")
8312
- return "auto";
8313
- } catch {}
8314
- }
8315
- if (trustScore !== null) {
8316
- if (trustScore < 30)
8317
- return "review-only";
8318
- if (trustScore < 60)
8319
- return "guarded";
8320
- }
8321
- if (volatility === "critical")
8322
- return "review-only";
8323
- if (volatility === "volatile")
8324
- return "guarded";
8325
- return "auto";
8326
- }
8327
- var BUILD_DEPLOY_PATTERNS = [
8328
- "npm build",
8329
- "npm run build",
8330
- "bun build",
8331
- "yarn build",
8332
- "npm deploy",
8333
- "yarn deploy",
8334
- "bun deploy",
8335
- "npm install",
8336
- "yarn install",
8337
- "bun install",
8338
- "make build",
8339
- "make deploy",
8340
- "docker build",
8341
- "docker push",
8342
- "docker-compose",
8343
- "git push",
8344
- "git deploy",
8345
- "gradle build",
8346
- "mvn package",
8347
- "ant build",
8348
- "cargo build",
8349
- "cargo deploy",
8350
- "python setup.py",
8351
- "pip install",
8352
- "rails deploy",
8353
- "rake deploy"
8354
- ];
8355
- function allow2(reason, riskFlags = []) {
8356
- return { verdict: "allow", reason, riskFlags, source: "guard-rails" };
8357
- }
8358
- function deny2(reason, escalationMessage, riskFlags = []) {
8359
- return {
8360
- verdict: "deny",
8361
- reason,
8362
- riskFlags,
8363
- source: "guard-rails",
8364
- escalationMessage
8365
- };
8366
- }
8367
- function evaluate2(input) {
8368
- const { directory, tool: tool13, args } = input;
8369
- const planningDirPath = join21(directory, PLANNING_DIR2);
8370
- const codebaseDirectory = codebaseDir(directory);
8371
- const configPath = join21(planningDirPath, CONFIG_FILE);
8372
- const statePath3 = join21(planningDirPath, STATE_FILE2);
8373
- const workspaceRoot = findWorkspaceRoot(directory);
8374
- if (workspaceRoot && directory !== workspaceRoot) {
8375
- const workspaceConfig = getWorkspaceConfig(directory);
8376
- if (workspaceConfig && workspaceConfig.workspace_mode === "shared" && !existsSync21(planningDirPath)) {
8377
- const msg = `No .planning/ in this sub-repo. Switch to workspace root: cd ${workspaceRoot}`;
8378
- return deny2(msg, `[flowdeck] BLOCK: ${msg}`, ["workspace-shared-mode"]);
8977
+ }
8978
+ function isBashCommandDangerous(cmd) {
8979
+ const normalized = normalizeBashCommand(cmd);
8980
+ for (const p of BLOCKED_PATTERNS.bash) {
8981
+ if (normalized.includes(p)) {
8982
+ return `FLOWDECK: Dangerous bash command blocked (matched "${p}").`;
8379
8983
  }
8380
8984
  }
8381
- if (tool13 === "write" || tool13 === "edit") {
8382
- if (!existsSync21(planningDirPath)) {
8383
- return allow2("FlowDeck not initialized in this directory — skipping guard-rails");
8384
- }
8385
- if (!existsSync21(codebaseDirectory)) {
8386
- const msg = ".codebase/ not found. Run /fd-map-codebase to map the codebase.";
8387
- return allow2(msg, ["codebase-missing"]);
8388
- }
8389
- const execMode = resolveExecutionMode(configPath, null);
8390
- if (execMode === "review-only") {
8391
- const msg = "review-only mode: propose diff but do not apply. Set execution_mode in .planning/config.json to change.";
8392
- return deny2(msg, `[flowdeck] BLOCK (${msg})`, ["review-only-mode"]);
8393
- }
8394
- if (execMode === "guarded") {
8395
- return allow2("guarded mode: edit will proceed but flag for human review", ["guarded-mode"]);
8396
- }
8397
- const designGateMessage = getDesignGateMessage(directory);
8398
- if (designGateMessage) {
8399
- if (designGateMessage.startsWith("[flowdeck] WARNING:")) {
8400
- return allow2(designGateMessage, ["design-gate-advisory"]);
8401
- }
8402
- return deny2(designGateMessage, designGateMessage, ["design-gate"]);
8403
- }
8404
- const severity = effectiveSeverity(configPath, statePath3);
8405
- if (severity === null) {
8406
- return allow2("guard_enforcement is off");
8407
- }
8408
- if (severity === "warn") {
8409
- const warning = getWarningMessage(planningDirPath);
8410
- return allow2(`[flowdeck] WARNING: ${warning}`, ["plan-not-confirmed"]);
8411
- }
8412
- const blockMessage = getBlockMessage(planningDirPath);
8413
- return deny2(blockMessage, `[flowdeck] BLOCK: ${blockMessage}`, ["plan-not-confirmed"]);
8985
+ if (isPipedToShell(normalized)) {
8986
+ return "FLOWDECK: Piping curl/wget directly to a shell is blocked.";
8414
8987
  }
8415
- if (tool13 === "bash") {
8416
- const cmd = String(args?.command || "");
8417
- for (const pattern of BUILD_DEPLOY_PATTERNS) {
8418
- if (cmd.includes(pattern)) {
8419
- if (!getPlanConfirmed(statePath3)) {
8420
- const msg = "Build/deploy command detected but plan is not confirmed. Run /fd-plan first.";
8421
- return allow2(`[flowdeck] WARNING: ${msg}`, ["build-deploy-without-plan"]);
8422
- }
8423
- break;
8424
- }
8425
- }
8988
+ if (/\bdd\b/.test(normalized) && /\/dev\/(sd|hd|nvme|mmcblk|disk)/.test(normalized)) {
8989
+ return "FLOWDECK: dd command targeting a block device is blocked.";
8426
8990
  }
8427
- return allow2("Guard rails passed");
8991
+ if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*}\s*;\s*:/.test(normalized)) {
8992
+ return "FLOWDECK: Fork bomb pattern is blocked.";
8993
+ }
8994
+ return null;
8428
8995
  }
8429
- function getDesignGateMessage(dir) {
8430
- const designConfig = resolveDesignFirstConfig(loadFlowDeckConfig(dir));
8431
- if (!designConfig.enabled || !designConfig.requireApprovalBeforeImplementation)
8432
- return null;
8433
- const state = readPlanningState(dir);
8434
- if (state.design_override && state.design_override_reason && state.design_override_reason.trim().length > 0)
8996
+ function isBlocked(tool13, args) {
8997
+ const patterns = BLOCKED_PATTERNS[tool13];
8998
+ if (!patterns)
8435
8999
  return null;
8436
- const designApproved = state.design_stage === "handoff_complete" && state.design_approved;
8437
- if (state.requires_design_first || state.task_type && isUiHeavyTask(state.task_type) || planSuggestsUiHeavy(dir, state.phase || 1)) {
8438
- if (designApproved)
9000
+ if (tool13 === "bash") {
9001
+ const cmd = args.command;
9002
+ if (!cmd)
8439
9003
  return null;
8440
- if (designConfig.enforcement === "advisory") {
8441
- return "[flowdeck] WARNING: UI-heavy task detected without approved design handoff. Run /fd-design --mode=draft first.";
9004
+ return isBashCommandDangerous(cmd);
9005
+ }
9006
+ if (tool13 === "read" || tool13 === "write") {
9007
+ const filePath = extractTargetPath(args);
9008
+ if (!filePath)
9009
+ return null;
9010
+ for (const p of patterns) {
9011
+ if (filePath.includes(p)) {
9012
+ return tool13 === "read" ? `FLOWDECK: Access to "${p}" files is blocked.` : `FLOWDECK: Writing to "${p}" is blocked.`;
9013
+ }
8442
9014
  }
8443
- return "[flowdeck] BLOCK: UI-heavy task requires approved design handoff. Run /fd-design --mode=draft or set explicit design override in STATE.md.";
9015
+ return null;
8444
9016
  }
8445
9017
  return null;
8446
9018
  }
8447
- function planSuggestsUiHeavy(dir, phase) {
8448
- const planPath = phasePlanPath(dir, phase);
8449
- if (!existsSync21(planPath))
8450
- return false;
8451
- const planContent = readFileSync21(planPath, "utf-8");
8452
- return isUiHeavyTask(planContent);
8453
- }
8454
- function effectiveSeverity(configPath, statePath3) {
8455
- if (existsSync21(configPath)) {
8456
- try {
8457
- const configContent = readFileSync21(configPath, "utf-8");
8458
- const config = JSON.parse(configContent);
8459
- if (config.guard_enforcement === "warn")
8460
- return "warn";
8461
- if (config.guard_enforcement === "block")
8462
- return "block";
8463
- if (config.guard_enforcement === "off")
8464
- return null;
8465
- } catch {}
8466
- }
8467
- return getPlanConfirmed(statePath3) ? "block" : "warn";
8468
- }
8469
- function getPlanConfirmed(statePath3) {
8470
- if (!existsSync21(statePath3))
8471
- return false;
9019
+ function checkArchConstraint(directory, filePath) {
9020
+ const constraintsPath = join20(codebaseDir(directory), "CONSTRAINTS.md");
9021
+ if (!existsSync20(constraintsPath))
9022
+ return null;
8472
9023
  try {
8473
- const content = readFileSync21(statePath3, "utf-8");
8474
- const match = content.match(/plan_confirmed:\s*(true|false)/i);
8475
- return match ? match[1].toLowerCase() === "true" : false;
8476
- } catch {
8477
- return false;
8478
- }
8479
- }
8480
- function getWarningMessage(planningDir2) {
8481
- if (!existsSync21(join21(planningDir2, STATE_FILE2))) {
8482
- return "No STATE.md found. Run /fd-map-codebase then /fd-new-feature to start a feature.";
8483
- }
8484
- return "Plan not confirmed. Run /fd-plan and confirm to enable execution.";
8485
- }
8486
- function getBlockMessage(planningDir2) {
8487
- if (!existsSync21(join21(planningDir2, STATE_FILE2))) {
8488
- return "No STATE.md found. Run /fd-map-codebase then /fd-new-feature to start a feature.";
8489
- }
8490
- return "Plan not confirmed. Run /fd-plan and confirm to enable execution.";
8491
- }
8492
-
8493
- // src/services/approval-manager.ts
8494
- import { existsSync as existsSync22, readFileSync as readFileSync22, writeFileSync as writeFileSync13, mkdirSync as mkdirSync12 } from "fs";
8495
- import { join as join22 } from "path";
8496
- import { createHash as createHash4 } from "crypto";
8497
- import { randomUUID } from "crypto";
8498
- var APPROVAL_TTL_MS = 30 * 60 * 1000;
8499
- var SENSITIVE_PATTERNS = [
8500
- /auth/i,
8501
- /login/i,
8502
- /password/i,
8503
- /secret/i,
8504
- /token/i,
8505
- /jwt/i,
8506
- /session/i,
8507
- /oauth/i,
8508
- /payment/i,
8509
- /billing/i,
8510
- /stripe/i,
8511
- /credit/i,
8512
- /migration/i,
8513
- /migrate/i,
8514
- /schema/i,
8515
- /alembic/i,
8516
- /infra/i,
8517
- /terraform/i,
8518
- /ansible/i,
8519
- /k8s/i,
8520
- /kubernetes/i,
8521
- /docker/i,
8522
- /\.env/i,
8523
- /secrets\./i,
8524
- /config\/prod/i,
8525
- /production/i,
8526
- /admin/i,
8527
- /privilege/i,
8528
- /sudo/i
8529
- ];
8530
- function isSensitivePath(filePath) {
8531
- return SENSITIVE_PATTERNS.some((p) => p.test(filePath));
8532
- }
8533
- function approvalsPath(dir) {
8534
- return join22(codebaseDir(dir), "APPROVALS.json");
9024
+ const content = readFileSync20(constraintsPath, "utf-8");
9025
+ const match = content.match(/## Forbidden Paths\n([\s\S]*?)(?:\n##|$)/);
9026
+ if (!match)
9027
+ return null;
9028
+ for (const line of match[1].split(`
9029
+ `)) {
9030
+ const pattern = line.replace(/^-\s*/, "").split("#")[0].trim();
9031
+ if (pattern && filePath.includes(pattern)) {
9032
+ return `FLOWDECK [arch-constraint]: editing "${pattern}" is forbidden by .codebase/CONSTRAINTS.md`;
9033
+ }
9034
+ }
9035
+ } catch {}
9036
+ return null;
8535
9037
  }
8536
- function loadStore(dir) {
8537
- const p = approvalsPath(dir);
8538
- if (!existsSync22(p))
8539
- return { requests: [] };
9038
+ function checkPhaseEnforcement(directory) {
8540
9039
  try {
8541
- return JSON.parse(readFileSync22(p, "utf-8"));
8542
- } catch {
8543
- return { requests: [] };
8544
- }
8545
- }
8546
- function saveStore(dir, store) {
8547
- const cd = codebaseDir(dir);
8548
- if (!existsSync22(cd))
8549
- mkdirSync12(cd, { recursive: true });
8550
- writeFileSync13(approvalsPath(dir), JSON.stringify(store, null, 2), "utf-8");
8551
- }
8552
- function requestApproval(dir, run_id, trigger, reason, options = {}) {
8553
- const store = loadStore(dir);
8554
- const req = {
8555
- id: randomUUID(),
8556
- run_id,
8557
- session_id: options.session_id ?? "session-0",
8558
- requested_at: new Date().toISOString(),
8559
- status: "pending",
8560
- trigger,
8561
- reason,
8562
- risk_score: options.risk_score ?? 0,
8563
- ...options.agent ? { agent: options.agent } : {},
8564
- ...options.file_path ? { file_path: options.file_path } : {},
8565
- ...options.content_hash ? { content_hash: options.content_hash } : {},
8566
- ...options.change_description ? { change_description: options.change_description } : {}
8567
- };
8568
- store.requests.push(req);
8569
- saveStore(dir, store);
8570
- return req;
8571
- }
8572
- function checkApproval(dir, criteria) {
8573
- const store = loadStore(dir);
8574
- const now = Date.now();
8575
- return store.requests.filter((r) => r.status === "approved" && r.resolved_at && r.run_id === criteria.run_id && r.session_id === criteria.session_id && r.agent === criteria.agent && r.file_path === criteria.file_path && r.content_hash === criteria.content_hash && now - new Date(r.resolved_at).getTime() < APPROVAL_TTL_MS).sort((a, b) => b.resolved_at.localeCompare(a.resolved_at)).at(0) ?? null;
8576
- }
8577
- function computeContentHash(args) {
8578
- const keys = Object.keys(args).sort();
8579
- const payload = JSON.stringify(args, keys);
8580
- return createHash4("sha256").update(payload).digest("hex").slice(0, 16);
8581
- }
8582
- function requestApprovalForTool(dir, run_id, session_id, agent, tool13, args) {
8583
- const filePath = extractTargetPath2(args);
8584
- const isSensitive = filePath ? isSensitivePath(filePath) : false;
8585
- return requestApproval(dir, run_id, tool13, `Approval required for tool "${tool13}"`, {
8586
- file_path: filePath,
8587
- risk_score: isSensitive ? 30 : 50,
8588
- session_id,
8589
- agent,
8590
- content_hash: computeContentHash(args),
8591
- change_description: `Tool "${tool13}" requested on ${filePath || "unknown target"}`
8592
- });
9040
+ const state = readPlanningState(directory);
9041
+ const flowdeckConfig = resolveDesignFirstConfig(loadFlowDeckConfig(directory));
9042
+ if (state.phase > 0 && state.phase < 3) {
9043
+ return `FLOWDECK [phase-gate]: writing to codebase is blocked in phase ${state.phase} (${state.phase === 1 ? "discuss" : "plan"}). Run /fd-plan --confirm to enter execute phase.`;
9044
+ }
9045
+ if (flowdeckConfig.enabled && flowdeckConfig.requireApprovalBeforeImplementation && isUiDesignApprovalRequired(directory)) {
9046
+ if (flowdeckConfig.enforcement === "advisory") {
9047
+ return `FLOWDECK [design-gate]: advisory design-first mode detected missing approval. Run /fd-design --mode=draft or set design_override=true in STATE.md.`;
9048
+ }
9049
+ return `FLOWDECK [design-gate]: UI-heavy task requires approved design handoff before implementation. Run /fd-design --mode=draft and ensure design_stage=handoff_complete + design_approved=true, or set explicit design_override with reason.`;
9050
+ }
9051
+ } catch {}
9052
+ return null;
8593
9053
  }
8594
- function extractTargetPath2(args) {
8595
- return String(args.path ?? args.file_path ?? args.filename ?? args.filePath ?? "");
9054
+ function isUiDesignApprovalRequired(directory) {
9055
+ const state = readPlanningState(directory);
9056
+ if (state.design_override && state.design_override_reason && state.design_override_reason.trim().length > 0)
9057
+ return false;
9058
+ if (state.requires_design_first) {
9059
+ return !(state.design_stage === "handoff_complete" && state.design_approved);
9060
+ }
9061
+ if (state.task_type && isUiHeavyTask(state.task_type)) {
9062
+ return !(state.design_stage === "handoff_complete" && state.design_approved);
9063
+ }
9064
+ const planPath = phasePlanPath(directory, state.phase || 1);
9065
+ if (!existsSync20(planPath))
9066
+ return false;
9067
+ const planContent = readFileSync20(planPath, "utf-8");
9068
+ if (!isUiHeavyTask(planContent))
9069
+ return false;
9070
+ return !(state.design_stage === "handoff_complete" && state.design_approved);
8596
9071
  }
8597
-
8598
- // src/hooks/approval-hook.ts
8599
- var WRITE_TOOLS = new Set([
8600
- "write_file",
8601
- "edit_file",
8602
- "create_file",
8603
- "apply_patch",
8604
- "str_replace_editor",
8605
- "write",
8606
- "edit"
8607
- ]);
8608
- function allow3(reason) {
8609
- return { verdict: "allow", reason, riskFlags: [], source: "approval-manager" };
9072
+ function allow(reason, riskFlags = []) {
9073
+ return { verdict: "allow", reason, riskFlags, source: "tool-guard" };
8610
9074
  }
8611
- function ask(reason, riskFlags = []) {
9075
+ function deny(reason, escalationMessage, riskFlags = []) {
8612
9076
  return {
8613
- verdict: "ask",
9077
+ verdict: "deny",
8614
9078
  reason,
8615
9079
  riskFlags,
8616
- source: "approval-manager"
9080
+ source: "tool-guard",
9081
+ escalationMessage
8617
9082
  };
8618
9083
  }
8619
- function evaluate3(input) {
9084
+ function evaluate(input) {
8620
9085
  const { directory, tool: tool13, args } = input;
8621
- if (!WRITE_TOOLS.has(tool13)) {
8622
- return allow3("Tool does not require approval");
8623
- }
8624
- const filePath = String(args?.path ?? args?.file_path ?? args?.filename ?? args?.filePath ?? "");
8625
- if (!filePath) {
8626
- return allow3("No target path — approval not required");
9086
+ if (tool13 !== "bash" && tool13 !== "read" && tool13 !== "write" && tool13 !== "edit") {
9087
+ return allow("Tool is not guardable");
8627
9088
  }
8628
- if (!isSensitivePath(filePath)) {
8629
- return allow3("Path is not sensitive");
9089
+ const blocked = isBlocked(tool13, args);
9090
+ if (blocked) {
9091
+ return deny(blocked, blocked, ["dangerous-pattern"]);
8630
9092
  }
8631
- const approval = checkApproval(directory, {
8632
- run_id: input.runId,
8633
- session_id: input.sessionID,
8634
- agent: input.agent,
8635
- file_path: filePath,
8636
- content_hash: computeContentHash(args)
8637
- });
8638
- if (approval) {
8639
- return allow3(`Approved by ${approval.id}`);
9093
+ if (tool13 === "write" || tool13 === "edit") {
9094
+ const phaseBlock = checkPhaseEnforcement(directory);
9095
+ if (phaseBlock) {
9096
+ const isAdvisory = phaseBlock.includes("[design-gate]: advisory");
9097
+ if (isAdvisory) {
9098
+ return allow(phaseBlock, ["design-gate-advisory"]);
9099
+ }
9100
+ return deny(phaseBlock, phaseBlock, ["phase-gate"]);
9101
+ }
9102
+ const filePath = extractTargetPath(args);
9103
+ const constraintBlock = checkArchConstraint(directory, filePath);
9104
+ if (constraintBlock) {
9105
+ return deny(constraintBlock, constraintBlock, ["arch-constraint"]);
9106
+ }
8640
9107
  }
8641
- return ask(`"${filePath}" is a sensitive file (auth/payment/secrets/infra). Manual approval needed before editing.`, ["sensitive-path"]);
9108
+ return allow("Tool guard passed");
8642
9109
  }
8643
9110
 
8644
- // src/services/deadlock-detector.ts
8645
- import { existsSync as existsSync24, readFileSync as readFileSync24, appendFileSync as appendFileSync5, mkdirSync as mkdirSync14 } from "fs";
8646
- import { join as join24 } from "path";
8647
- import { randomUUID as randomUUID3 } from "crypto";
8648
-
8649
- // src/services/agent-trace-graph.ts
8650
- import { existsSync as existsSync23, readFileSync as readFileSync23, appendFileSync as appendFileSync4, writeFileSync as writeFileSync14, mkdirSync as mkdirSync13 } from "fs";
8651
- import { join as join23 } from "path";
8652
- import { randomUUID as randomUUID2 } from "crypto";
8653
- function agentSpansPath(dir) {
8654
- return join23(codebaseDir(dir), "AGENT_SPANS.jsonl");
8655
- }
8656
- function loadAllSpans(dir) {
8657
- const p = agentSpansPath(dir);
8658
- if (!existsSync23(p))
8659
- return [];
8660
- try {
8661
- return readFileSync23(p, "utf-8").trim().split(`
8662
- `).filter(Boolean).map((l) => JSON.parse(l));
8663
- } catch {
8664
- return [];
9111
+ // src/hooks/guard-rails.ts
9112
+ import { existsSync as existsSync21, readFileSync as readFileSync21 } from "fs";
9113
+ import { join as join21 } from "path";
9114
+ var PLANNING_DIR2 = ".planning";
9115
+ var CONFIG_FILE = "config.json";
9116
+ var STATE_FILE2 = "STATE.md";
9117
+ function resolveExecutionMode(configPath, trustScore, volatility) {
9118
+ if (existsSync21(configPath)) {
9119
+ try {
9120
+ const config = JSON.parse(readFileSync21(configPath, "utf-8"));
9121
+ if (config.execution_mode === "review-only")
9122
+ return "review-only";
9123
+ if (config.execution_mode === "guarded")
9124
+ return "guarded";
9125
+ if (config.execution_mode === "auto")
9126
+ return "auto";
9127
+ } catch {}
8665
9128
  }
9129
+ if (trustScore !== null) {
9130
+ if (trustScore < 30)
9131
+ return "review-only";
9132
+ if (trustScore < 60)
9133
+ return "guarded";
9134
+ }
9135
+ if (volatility === "critical")
9136
+ return "review-only";
9137
+ if (volatility === "volatile")
9138
+ return "guarded";
9139
+ return "auto";
8666
9140
  }
8667
- function saveAllSpans(dir, spans) {
8668
- const p = agentSpansPath(dir);
8669
- const cd = codebaseDir(dir);
8670
- if (!existsSync23(cd))
8671
- mkdirSync13(cd, { recursive: true });
8672
- writeFileSync14(p, spans.map((s) => JSON.stringify(s)).join(`
8673
- `) + `
8674
- `, "utf-8");
8675
- }
8676
- function openSpan(dir, opts) {
8677
- const cd = codebaseDir(dir);
8678
- if (!existsSync23(cd))
8679
- mkdirSync13(cd, { recursive: true });
8680
- const span = {
8681
- span_id: randomUUID2(),
8682
- trace_id: opts.trace_id,
8683
- parent_span_id: opts.parent_span_id,
8684
- invoker: opts.invoker,
8685
- agent: opts.agent,
8686
- task_description: opts.task_description,
8687
- stage: opts.stage,
8688
- started_at: new Date().toISOString(),
8689
- status: "running",
8690
- output_valid: false,
8691
- contract_violations: [],
8692
- tools_used: [],
8693
- retry_count: 0,
8694
- depth: opts.depth ?? 0,
8695
- model: opts.model
8696
- };
8697
- appendFileSync4(agentSpansPath(dir), JSON.stringify(span) + `
8698
- `, "utf-8");
8699
- return span;
9141
+ var BUILD_DEPLOY_PATTERNS = [
9142
+ "npm build",
9143
+ "npm run build",
9144
+ "bun build",
9145
+ "yarn build",
9146
+ "npm deploy",
9147
+ "yarn deploy",
9148
+ "bun deploy",
9149
+ "npm install",
9150
+ "yarn install",
9151
+ "bun install",
9152
+ "make build",
9153
+ "make deploy",
9154
+ "docker build",
9155
+ "docker push",
9156
+ "docker-compose",
9157
+ "git push",
9158
+ "git deploy",
9159
+ "gradle build",
9160
+ "mvn package",
9161
+ "ant build",
9162
+ "cargo build",
9163
+ "cargo deploy",
9164
+ "python setup.py",
9165
+ "pip install",
9166
+ "rails deploy",
9167
+ "rake deploy"
9168
+ ];
9169
+ function allow2(reason, riskFlags = []) {
9170
+ return { verdict: "allow", reason, riskFlags, source: "guard-rails" };
8700
9171
  }
8701
- function closeSpan(dir, span_id, status, opts = {}) {
8702
- const spans = loadAllSpans(dir);
8703
- const idx = spans.findLastIndex((s) => s.span_id === span_id);
8704
- if (idx === -1)
8705
- return;
8706
- const startedMs = new Date(spans[idx].started_at).getTime();
8707
- spans[idx] = {
8708
- ...spans[idx],
8709
- ended_at: new Date().toISOString(),
8710
- status,
8711
- latency_ms: Date.now() - startedMs,
8712
- output_valid: opts.output_valid ?? false,
8713
- contract_violations: opts.contract_violations ?? spans[idx].contract_violations,
8714
- tools_used: opts.tools_used ?? spans[idx].tools_used,
8715
- handoff_payload: opts.handoff_payload,
8716
- cost_estimate: opts.cost_estimate,
8717
- retry_count: opts.retry_count ?? spans[idx].retry_count
9172
+ function deny2(reason, escalationMessage, riskFlags = []) {
9173
+ return {
9174
+ verdict: "deny",
9175
+ reason,
9176
+ riskFlags,
9177
+ source: "guard-rails",
9178
+ escalationMessage
8718
9179
  };
8719
- saveAllSpans(dir, spans);
8720
9180
  }
8721
- function recordToolUsed(dir, span_id, toolName) {
8722
- const spans = loadAllSpans(dir);
8723
- const idx = spans.findLastIndex((s) => s.span_id === span_id);
8724
- if (idx === -1)
8725
- return;
8726
- if (!spans[idx].tools_used.includes(toolName)) {
8727
- spans[idx].tools_used = [...spans[idx].tools_used, toolName];
8728
- saveAllSpans(dir, spans);
9181
+ function evaluate2(input) {
9182
+ const { directory, tool: tool13, args } = input;
9183
+ const planningDirPath = join21(directory, PLANNING_DIR2);
9184
+ const codebaseDirectory = codebaseDir(directory);
9185
+ const configPath = join21(planningDirPath, CONFIG_FILE);
9186
+ const statePath3 = join21(planningDirPath, STATE_FILE2);
9187
+ const workspaceRoot = findWorkspaceRoot(directory);
9188
+ if (workspaceRoot && directory !== workspaceRoot) {
9189
+ const workspaceConfig = getWorkspaceConfig(directory);
9190
+ if (workspaceConfig && workspaceConfig.workspace_mode === "shared" && !existsSync21(planningDirPath)) {
9191
+ const msg = `No .planning/ in this sub-repo. Switch to workspace root: cd ${workspaceRoot}`;
9192
+ return deny2(msg, `[flowdeck] BLOCK: ${msg}`, ["workspace-shared-mode"]);
9193
+ }
9194
+ }
9195
+ if (tool13 === "write" || tool13 === "edit") {
9196
+ if (!existsSync21(planningDirPath)) {
9197
+ return allow2("FlowDeck not initialized in this directory — skipping guard-rails");
9198
+ }
9199
+ if (!existsSync21(codebaseDirectory)) {
9200
+ const msg = ".codebase/ not found. Run /fd-map-codebase to map the codebase.";
9201
+ return allow2(msg, ["codebase-missing"]);
9202
+ }
9203
+ const execMode = resolveExecutionMode(configPath, null);
9204
+ if (execMode === "review-only") {
9205
+ const msg = "review-only mode: propose diff but do not apply. Set execution_mode in .planning/config.json to change.";
9206
+ return deny2(msg, `[flowdeck] BLOCK (${msg})`, ["review-only-mode"]);
9207
+ }
9208
+ if (execMode === "guarded") {
9209
+ return allow2("guarded mode: edit will proceed but flag for human review", ["guarded-mode"]);
9210
+ }
9211
+ const designGateMessage = getDesignGateMessage(directory);
9212
+ if (designGateMessage) {
9213
+ if (designGateMessage.startsWith("[flowdeck] WARNING:")) {
9214
+ return allow2(designGateMessage, ["design-gate-advisory"]);
9215
+ }
9216
+ return deny2(designGateMessage, designGateMessage, ["design-gate"]);
9217
+ }
9218
+ const severity = effectiveSeverity(configPath, statePath3);
9219
+ if (severity === null) {
9220
+ return allow2("guard_enforcement is off");
9221
+ }
9222
+ if (severity === "warn") {
9223
+ const warning = getWarningMessage(planningDirPath);
9224
+ return allow2(`[flowdeck] WARNING: ${warning}`, ["plan-not-confirmed"]);
9225
+ }
9226
+ const blockMessage = getBlockMessage(planningDirPath);
9227
+ return deny2(blockMessage, `[flowdeck] BLOCK: ${blockMessage}`, ["plan-not-confirmed"]);
8729
9228
  }
9229
+ if (tool13 === "bash") {
9230
+ const cmd = String(args?.command || "");
9231
+ for (const pattern of BUILD_DEPLOY_PATTERNS) {
9232
+ if (cmd.includes(pattern)) {
9233
+ if (!getPlanConfirmed(statePath3)) {
9234
+ const msg = "Build/deploy command detected but plan is not confirmed. Run /fd-plan first.";
9235
+ return allow2(`[flowdeck] WARNING: ${msg}`, ["build-deploy-without-plan"]);
9236
+ }
9237
+ break;
9238
+ }
9239
+ }
9240
+ }
9241
+ return allow2("Guard rails passed");
8730
9242
  }
8731
- function addSpanViolation(dir, span_id, violation) {
8732
- const spans = loadAllSpans(dir);
8733
- const idx = spans.findLastIndex((s) => s.span_id === span_id);
8734
- if (idx === -1)
8735
- return;
8736
- spans[idx].contract_violations = [...spans[idx].contract_violations, violation];
8737
- saveAllSpans(dir, spans);
8738
- }
8739
- function recordContractViolation(dir, span_id, violation) {
8740
- addSpanViolation(dir, span_id, violation);
8741
- }
8742
- function getTraceSpans(dir, trace_id) {
8743
- return loadAllSpans(dir).filter((s) => s.trace_id === trace_id);
8744
- }
8745
-
8746
- // src/services/deadlock-detector.ts
8747
- function resolveConfig(directory) {
8748
- try {
8749
- const config = loadFlowDeckConfig(directory);
8750
- const dc = config?.governance?.deadlockDetection;
8751
- return {
8752
- enabled: dc?.enabled ?? true,
8753
- bounceThreshold: dc?.bounceThreshold ?? 3,
8754
- retryLoopThreshold: dc?.retryLoopThreshold ?? 3,
8755
- stageStallMinutes: dc?.stageStallMinutes ?? 30,
8756
- autoStop: dc?.autoStop ?? false
8757
- };
8758
- } catch {
8759
- return { enabled: true, bounceThreshold: 3, retryLoopThreshold: 3, stageStallMinutes: 30, autoStop: false };
9243
+ function getDesignGateMessage(dir) {
9244
+ const designConfig = resolveDesignFirstConfig(loadFlowDeckConfig(dir));
9245
+ if (!designConfig.enabled || !designConfig.requireApprovalBeforeImplementation)
9246
+ return null;
9247
+ const state = readPlanningState(dir);
9248
+ if (state.design_override && state.design_override_reason && state.design_override_reason.trim().length > 0)
9249
+ return null;
9250
+ const designApproved = state.design_stage === "handoff_complete" && state.design_approved;
9251
+ if (state.requires_design_first || state.task_type && isUiHeavyTask(state.task_type) || planSuggestsUiHeavy(dir, state.phase || 1)) {
9252
+ if (designApproved)
9253
+ return null;
9254
+ if (designConfig.enforcement === "advisory") {
9255
+ return "[flowdeck] WARNING: UI-heavy task detected without approved design handoff. Run /fd-design --mode=draft first.";
9256
+ }
9257
+ return "[flowdeck] BLOCK: UI-heavy task requires approved design handoff. Run /fd-design --mode=draft or set explicit design override in STATE.md.";
8760
9258
  }
9259
+ return null;
8761
9260
  }
8762
- function deadlockSignalsPath(dir) {
8763
- return join24(codebaseDir(dir), "DEADLOCK_SIGNALS.jsonl");
9261
+ function planSuggestsUiHeavy(dir, phase) {
9262
+ const planPath = phasePlanPath(dir, phase);
9263
+ if (!existsSync21(planPath))
9264
+ return false;
9265
+ const planContent = readFileSync21(planPath, "utf-8");
9266
+ return isUiHeavyTask(planContent);
8764
9267
  }
8765
- function appendSignal(dir, signal) {
8766
- const cd = codebaseDir(dir);
8767
- if (!existsSync24(cd))
8768
- mkdirSync14(cd, { recursive: true });
8769
- appendFileSync5(deadlockSignalsPath(dir), JSON.stringify(signal) + `
8770
- `, "utf-8");
9268
+ function effectiveSeverity(configPath, statePath3) {
9269
+ if (existsSync21(configPath)) {
9270
+ try {
9271
+ const configContent = readFileSync21(configPath, "utf-8");
9272
+ const config = JSON.parse(configContent);
9273
+ if (config.guard_enforcement === "warn")
9274
+ return "warn";
9275
+ if (config.guard_enforcement === "block")
9276
+ return "block";
9277
+ if (config.guard_enforcement === "off")
9278
+ return null;
9279
+ } catch {}
9280
+ }
9281
+ return getPlanConfirmed(statePath3) ? "block" : "warn";
8771
9282
  }
8772
- function getSignals(dir, trace_id) {
8773
- const p = deadlockSignalsPath(dir);
8774
- if (!existsSync24(p))
8775
- return [];
9283
+ function getPlanConfirmed(statePath3) {
9284
+ if (!existsSync21(statePath3))
9285
+ return false;
8776
9286
  try {
8777
- const all = readFileSync24(p, "utf-8").trim().split(`
8778
- `).filter(Boolean).map((l) => JSON.parse(l));
8779
- return trace_id ? all.filter((s) => s.trace_id === trace_id) : all;
9287
+ const content = readFileSync21(statePath3, "utf-8");
9288
+ const match = content.match(/plan_confirmed:\s*(true|false)/i);
9289
+ return match ? match[1].toLowerCase() === "true" : false;
8780
9290
  } catch {
8781
- return [];
9291
+ return false;
8782
9292
  }
8783
9293
  }
8784
- function detectAgentBounce(dir, trace_id, cfg) {
8785
- const spans = getTraceSpans(dir, trace_id);
8786
- const pairCounts = {};
8787
- for (let i = 1;i < spans.length; i++) {
8788
- const key = `${spans[i - 1].agent}→${spans[i].agent}`;
8789
- pairCounts[key] = (pairCounts[key] ?? 0) + 1;
8790
- }
8791
- for (const [pair, count] of Object.entries(pairCounts)) {
8792
- if (count >= cfg.bounceThreshold) {
8793
- const [a, b] = pair.split("→");
8794
- return {
8795
- signal_id: randomUUID3(),
8796
- trace_id,
8797
- detected_at: new Date().toISOString(),
8798
- type: "agent_bounce",
8799
- evidence: [`Agent pair "${pair}" handed off ${count} times (threshold: ${cfg.bounceThreshold})`],
8800
- agents_involved: [a, b],
8801
- recommended_action: "escalate_human",
8802
- auto_stop: cfg.autoStop
8803
- };
8804
- }
9294
+ function getWarningMessage(planningDir2) {
9295
+ if (!existsSync21(join21(planningDir2, STATE_FILE2))) {
9296
+ return "No STATE.md found. Run /fd-map-codebase then /fd-new-feature to start a feature.";
8805
9297
  }
8806
- return null;
9298
+ return "Plan not confirmed. Run /fd-plan and confirm to enable execution.";
8807
9299
  }
8808
- function detectCircularDelegation(dir, trace_id, cfg) {
8809
- const spans = getTraceSpans(dir, trace_id);
8810
- const graph = {};
8811
- for (const span of spans) {
8812
- if (span.invoker === span.agent)
8813
- continue;
8814
- if (!graph[span.invoker])
8815
- graph[span.invoker] = new Set;
8816
- graph[span.invoker].add(span.agent);
8817
- }
8818
- function findCycle(node, visited2, stack) {
8819
- visited2.add(node);
8820
- for (const neighbor of [...graph[node] ?? []]) {
8821
- if (stack.includes(neighbor))
8822
- return [...stack, neighbor];
8823
- if (!visited2.has(neighbor)) {
8824
- const result = findCycle(neighbor, visited2, [...stack, neighbor]);
8825
- if (result)
8826
- return result;
8827
- }
8828
- }
8829
- return null;
8830
- }
8831
- const visited = new Set;
8832
- for (const node of Object.keys(graph)) {
8833
- if (!visited.has(node)) {
8834
- const cycle = findCycle(node, visited, [node]);
8835
- if (cycle) {
8836
- return {
8837
- signal_id: randomUUID3(),
8838
- trace_id,
8839
- detected_at: new Date().toISOString(),
8840
- type: "circular_delegation",
8841
- evidence: [`Delegation cycle: ${cycle.join(" → ")}`],
8842
- agents_involved: cycle,
8843
- recommended_action: "stop",
8844
- auto_stop: true
8845
- };
8846
- }
8847
- }
9300
+ function getBlockMessage(planningDir2) {
9301
+ if (!existsSync21(join21(planningDir2, STATE_FILE2))) {
9302
+ return "No STATE.md found. Run /fd-map-codebase then /fd-new-feature to start a feature.";
8848
9303
  }
8849
- return null;
9304
+ return "Plan not confirmed. Run /fd-plan and confirm to enable execution.";
8850
9305
  }
8851
- function detectStepRetryLoop(dir, trace_id, cfg) {
8852
- const spans = getTraceSpans(dir, trace_id);
8853
- const stageCounts = {};
8854
- const stageAgents = {};
8855
- for (const span of spans) {
8856
- const key = `${span.agent}:${span.stage}`;
8857
- stageCounts[key] = (stageCounts[key] ?? 0) + 1;
8858
- if (!stageAgents[key])
8859
- stageAgents[key] = new Set;
8860
- stageAgents[key].add(span.agent);
8861
- }
8862
- for (const [key, count] of Object.entries(stageCounts)) {
8863
- if (count >= cfg.retryLoopThreshold) {
8864
- return {
8865
- signal_id: randomUUID3(),
8866
- trace_id,
8867
- detected_at: new Date().toISOString(),
8868
- type: "step_retry_loop",
8869
- evidence: [`Stage "${key}" executed ${count} times (threshold: ${cfg.retryLoopThreshold})`],
8870
- agents_involved: [...stageAgents[key] ?? new Set],
8871
- recommended_action: "escalate_human",
8872
- auto_stop: cfg.autoStop
8873
- };
8874
- }
9306
+
9307
+ // src/services/approval-manager.ts
9308
+ import { existsSync as existsSync22, readFileSync as readFileSync22, writeFileSync as writeFileSync13, mkdirSync as mkdirSync12 } from "fs";
9309
+ import { join as join22 } from "path";
9310
+ import { createHash as createHash4 } from "crypto";
9311
+ import { randomUUID } from "crypto";
9312
+ var APPROVAL_TTL_MS = 30 * 60 * 1000;
9313
+ var SENSITIVE_PATTERNS = [
9314
+ /auth/i,
9315
+ /login/i,
9316
+ /password/i,
9317
+ /secret/i,
9318
+ /token/i,
9319
+ /jwt/i,
9320
+ /session/i,
9321
+ /oauth/i,
9322
+ /payment/i,
9323
+ /billing/i,
9324
+ /stripe/i,
9325
+ /credit/i,
9326
+ /migration/i,
9327
+ /migrate/i,
9328
+ /schema/i,
9329
+ /alembic/i,
9330
+ /infra/i,
9331
+ /terraform/i,
9332
+ /ansible/i,
9333
+ /k8s/i,
9334
+ /kubernetes/i,
9335
+ /docker/i,
9336
+ /\.env/i,
9337
+ /secrets\./i,
9338
+ /config\/prod/i,
9339
+ /production/i,
9340
+ /admin/i,
9341
+ /privilege/i,
9342
+ /sudo/i
9343
+ ];
9344
+ function isSensitivePath(filePath) {
9345
+ return SENSITIVE_PATTERNS.some((p) => p.test(filePath));
9346
+ }
9347
+ function approvalsPath(dir) {
9348
+ return join22(codebaseDir(dir), "APPROVALS.json");
9349
+ }
9350
+ function loadStore(dir) {
9351
+ const p = approvalsPath(dir);
9352
+ if (!existsSync22(p))
9353
+ return { requests: [] };
9354
+ try {
9355
+ return JSON.parse(readFileSync22(p, "utf-8"));
9356
+ } catch {
9357
+ return { requests: [] };
8875
9358
  }
8876
- return null;
8877
9359
  }
8878
- function detectStageStall(dir, trace_id, cfg) {
8879
- const spans = getTraceSpans(dir, trace_id);
9360
+ function saveStore(dir, store) {
9361
+ const cd = codebaseDir(dir);
9362
+ if (!existsSync22(cd))
9363
+ mkdirSync12(cd, { recursive: true });
9364
+ writeFileSync13(approvalsPath(dir), JSON.stringify(store, null, 2), "utf-8");
9365
+ }
9366
+ function requestApproval(dir, run_id, trigger, reason, options = {}) {
9367
+ const store = loadStore(dir);
9368
+ const req = {
9369
+ id: randomUUID(),
9370
+ run_id,
9371
+ session_id: options.session_id ?? "session-0",
9372
+ requested_at: new Date().toISOString(),
9373
+ status: "pending",
9374
+ trigger,
9375
+ reason,
9376
+ risk_score: options.risk_score ?? 0,
9377
+ ...options.agent ? { agent: options.agent } : {},
9378
+ ...options.file_path ? { file_path: options.file_path } : {},
9379
+ ...options.content_hash ? { content_hash: options.content_hash } : {},
9380
+ ...options.change_description ? { change_description: options.change_description } : {}
9381
+ };
9382
+ store.requests.push(req);
9383
+ saveStore(dir, store);
9384
+ return req;
9385
+ }
9386
+ function checkApproval(dir, criteria) {
9387
+ const store = loadStore(dir);
8880
9388
  const now = Date.now();
8881
- for (const span of spans) {
8882
- if (span.status !== "running")
8883
- continue;
8884
- const elapsed = (now - new Date(span.started_at).getTime()) / 1000 / 60;
8885
- if (elapsed >= cfg.stageStallMinutes) {
8886
- return {
8887
- signal_id: randomUUID3(),
8888
- trace_id,
8889
- detected_at: new Date().toISOString(),
8890
- type: "stage_stall",
8891
- evidence: [
8892
- `Agent "${span.agent}" in stage "${span.stage}" running for ${Math.round(elapsed)}min (threshold: ${cfg.stageStallMinutes}min)`
8893
- ],
8894
- agents_involved: [span.agent],
8895
- recommended_action: "escalate_human",
8896
- auto_stop: cfg.autoStop
8897
- };
8898
- }
8899
- }
8900
- return null;
9389
+ return store.requests.filter((r) => r.status === "approved" && r.resolved_at && r.run_id === criteria.run_id && r.session_id === criteria.session_id && r.agent === criteria.agent && r.file_path === criteria.file_path && r.content_hash === criteria.content_hash && now - new Date(r.resolved_at).getTime() < APPROVAL_TTL_MS).sort((a, b) => b.resolved_at.localeCompare(a.resolved_at)).at(0) ?? null;
8901
9390
  }
8902
- function detectDeadlocks(dir, trace_id) {
8903
- const cfg = resolveConfig(dir);
8904
- if (!cfg.enabled)
8905
- return [];
8906
- const existingTypes = new Set(getSignals(dir, trace_id).map((s) => s.type));
8907
- const candidates = [
8908
- detectAgentBounce(dir, trace_id, cfg),
8909
- detectCircularDelegation(dir, trace_id, cfg),
8910
- detectStepRetryLoop(dir, trace_id, cfg),
8911
- detectStageStall(dir, trace_id, cfg)
8912
- ];
8913
- const newSignals = candidates.filter((s) => s !== null && !existingTypes.has(s.type));
8914
- for (const signal of newSignals)
8915
- appendSignal(dir, signal);
8916
- return newSignals;
9391
+ function computeContentHash(args) {
9392
+ const keys = Object.keys(args).sort();
9393
+ const payload = JSON.stringify(args, keys);
9394
+ return createHash4("sha256").update(payload).digest("hex").slice(0, 16);
8917
9395
  }
8918
- function isTraceStuck(dir, trace_id) {
8919
- return getSignals(dir, trace_id).some((s) => s.auto_stop);
9396
+ function requestApprovalForTool(dir, run_id, session_id, agent, tool13, args) {
9397
+ const filePath = extractTargetPath2(args);
9398
+ const isSensitive = filePath ? isSensitivePath(filePath) : false;
9399
+ return requestApproval(dir, run_id, tool13, `Approval required for tool "${tool13}"`, {
9400
+ file_path: filePath,
9401
+ risk_score: isSensitive ? 30 : 50,
9402
+ session_id,
9403
+ agent,
9404
+ content_hash: computeContentHash(args),
9405
+ change_description: `Tool "${tool13}" requested on ${filePath || "unknown target"}`
9406
+ });
9407
+ }
9408
+ function extractTargetPath2(args) {
9409
+ return String(args.path ?? args.file_path ?? args.filename ?? args.filePath ?? "");
8920
9410
  }
8921
9411
 
8922
- // src/services/audit-log.ts
8923
- import {
8924
- existsSync as existsSync25,
8925
- mkdirSync as mkdirSync15,
8926
- appendFileSync as appendFileSync6,
8927
- readFileSync as readFileSync25,
8928
- writeFileSync as writeFileSync15,
8929
- renameSync,
8930
- unlinkSync
8931
- } from "fs";
8932
- import { join as join25 } from "path";
8933
- var AUDIT_FILE = "AUDIT.jsonl";
8934
- var ROTATE_LINE_COUNT = 1000;
8935
- function auditPath(directory) {
8936
- return join25(codebaseDir(directory), AUDIT_FILE);
9412
+ // src/hooks/approval-hook.ts
9413
+ var WRITE_TOOLS = new Set([
9414
+ "write_file",
9415
+ "edit_file",
9416
+ "create_file",
9417
+ "apply_patch",
9418
+ "str_replace_editor",
9419
+ "write",
9420
+ "edit"
9421
+ ]);
9422
+ function allow3(reason) {
9423
+ return { verdict: "allow", reason, riskFlags: [], source: "approval-manager" };
8937
9424
  }
8938
- function ensureDirectory(dir) {
8939
- const base = codebaseDir(dir);
8940
- if (!existsSync25(base)) {
8941
- mkdirSync15(base, { recursive: true });
8942
- }
9425
+ function ask(reason, riskFlags = []) {
9426
+ return {
9427
+ verdict: "ask",
9428
+ reason,
9429
+ riskFlags,
9430
+ source: "approval-manager"
9431
+ };
8943
9432
  }
8944
- function rotateIfNeeded(path, appLog) {
8945
- try {
8946
- const content = readFileSync25(path, "utf-8");
8947
- const lines = content.split(`
8948
- `).filter((line) => line.trim().length > 0);
8949
- if (lines.length <= ROTATE_LINE_COUNT)
8950
- return;
8951
- const backupPath = `${path}.backup`;
8952
- renameSync(path, backupPath);
8953
- const keep = lines.slice(-ROTATE_LINE_COUNT);
8954
- writeFileSync15(path, keep.join(`
8955
- `) + `
8956
- `, "utf-8");
8957
- try {
8958
- unlinkSync(backupPath);
8959
- } catch {}
8960
- } catch (error) {
8961
- if (appLog) {
8962
- const message = error instanceof Error ? error.message : String(error);
8963
- appLog(`[audit-log] rotation failed: ${message}`);
8964
- }
9433
+ function evaluate3(input) {
9434
+ const { directory, tool: tool13, args } = input;
9435
+ if (!WRITE_TOOLS.has(tool13)) {
9436
+ return allow3("Tool does not require approval");
9437
+ }
9438
+ const filePath = String(args?.path ?? args?.file_path ?? args?.filename ?? args?.filePath ?? "");
9439
+ if (!filePath) {
9440
+ return allow3("No target path — approval not required");
9441
+ }
9442
+ if (!isSensitivePath(filePath)) {
9443
+ return allow3("Path is not sensitive");
9444
+ }
9445
+ const approval = checkApproval(directory, {
9446
+ run_id: input.runId,
9447
+ session_id: input.sessionID,
9448
+ agent: input.agent,
9449
+ file_path: filePath,
9450
+ content_hash: computeContentHash(args)
9451
+ });
9452
+ if (approval) {
9453
+ return allow3(`Approved by ${approval.id}`);
8965
9454
  }
9455
+ return ask(`"${filePath}" is a sensitive file (auth/payment/secrets/infra). Manual approval needed before editing.`, ["sensitive-path"]);
8966
9456
  }
8967
- function appendAuditEntry(directory, entry, appLog) {
8968
- ensureDirectory(directory);
8969
- const path = auditPath(directory);
8970
- appendFileSync6(path, JSON.stringify(entry) + `
8971
- `, "utf-8");
8972
- rotateIfNeeded(path, appLog);
9457
+
9458
+ // src/services/deadlock-detector.ts
9459
+ import { existsSync as existsSync24, readFileSync as readFileSync24, appendFileSync as appendFileSync5, mkdirSync as mkdirSync14 } from "fs";
9460
+ import { join as join24 } from "path";
9461
+ import { randomUUID as randomUUID3 } from "crypto";
9462
+
9463
+ // src/services/agent-trace-graph.ts
9464
+ import { existsSync as existsSync23, readFileSync as readFileSync23, appendFileSync as appendFileSync4, writeFileSync as writeFileSync14, mkdirSync as mkdirSync13 } from "fs";
9465
+ import { join as join23 } from "path";
9466
+ import { randomUUID as randomUUID2 } from "crypto";
9467
+ function agentSpansPath(dir) {
9468
+ return join23(codebaseDir(dir), "AGENT_SPANS.jsonl");
8973
9469
  }
8974
- function queryAudit(directory, filter) {
8975
- const path = auditPath(directory);
8976
- if (!existsSync25(path))
9470
+ function loadAllSpans(dir) {
9471
+ const p = agentSpansPath(dir);
9472
+ if (!existsSync23(p))
8977
9473
  return [];
8978
9474
  try {
8979
- const lines = readFileSync25(path, "utf-8").split(`
8980
- `).filter((line) => line.trim().length > 0);
8981
- const results = [];
8982
- for (const line of lines) {
8983
- try {
8984
- const entry = JSON.parse(line);
8985
- if (filter.run_id && entry.run_id !== filter.run_id)
8986
- continue;
8987
- if (filter.session_id && entry.session_id !== filter.session_id)
8988
- continue;
8989
- if (filter.tool && entry.tool !== filter.tool)
8990
- continue;
8991
- if (filter.verdict && entry.verdict !== filter.verdict)
8992
- continue;
8993
- results.push(entry);
8994
- } catch {}
8995
- }
8996
- return results;
9475
+ return readFileSync23(p, "utf-8").trim().split(`
9476
+ `).filter(Boolean).map((l) => JSON.parse(l));
8997
9477
  } catch {
8998
9478
  return [];
8999
9479
  }
9000
9480
  }
9001
- function createAuditLog(directory, appLog) {
9002
- return {
9003
- append: (entry) => appendAuditEntry(directory, entry, appLog),
9004
- query: (filter) => queryAudit(directory, filter)
9481
+ function saveAllSpans(dir, spans) {
9482
+ const p = agentSpansPath(dir);
9483
+ const cd = codebaseDir(dir);
9484
+ if (!existsSync23(cd))
9485
+ mkdirSync13(cd, { recursive: true });
9486
+ writeFileSync14(p, spans.map((s) => JSON.stringify(s)).join(`
9487
+ `) + `
9488
+ `, "utf-8");
9489
+ }
9490
+ function openSpan(dir, opts) {
9491
+ const cd = codebaseDir(dir);
9492
+ if (!existsSync23(cd))
9493
+ mkdirSync13(cd, { recursive: true });
9494
+ const span = {
9495
+ span_id: randomUUID2(),
9496
+ trace_id: opts.trace_id,
9497
+ parent_span_id: opts.parent_span_id,
9498
+ invoker: opts.invoker,
9499
+ agent: opts.agent,
9500
+ task_description: opts.task_description,
9501
+ stage: opts.stage,
9502
+ started_at: new Date().toISOString(),
9503
+ status: "running",
9504
+ output_valid: false,
9505
+ contract_violations: [],
9506
+ tools_used: [],
9507
+ retry_count: 0,
9508
+ depth: opts.depth ?? 0,
9509
+ model: opts.model
9510
+ };
9511
+ appendFileSync4(agentSpansPath(dir), JSON.stringify(span) + `
9512
+ `, "utf-8");
9513
+ return span;
9514
+ }
9515
+ function closeSpan(dir, span_id, status, opts = {}) {
9516
+ const spans = loadAllSpans(dir);
9517
+ const idx = spans.findLastIndex((s) => s.span_id === span_id);
9518
+ if (idx === -1)
9519
+ return;
9520
+ const startedMs = new Date(spans[idx].started_at).getTime();
9521
+ spans[idx] = {
9522
+ ...spans[idx],
9523
+ ended_at: new Date().toISOString(),
9524
+ status,
9525
+ latency_ms: Date.now() - startedMs,
9526
+ output_valid: opts.output_valid ?? false,
9527
+ contract_violations: opts.contract_violations ?? spans[idx].contract_violations,
9528
+ tools_used: opts.tools_used ?? spans[idx].tools_used,
9529
+ handoff_payload: opts.handoff_payload,
9530
+ cost_estimate: opts.cost_estimate,
9531
+ retry_count: opts.retry_count ?? spans[idx].retry_count
9005
9532
  };
9533
+ saveAllSpans(dir, spans);
9006
9534
  }
9007
-
9008
- // src/services/harness-policy.ts
9009
- function allow4(reason, source, riskFlags = []) {
9010
- return { verdict: "allow", reason, riskFlags, source };
9535
+ function recordToolUsed(dir, span_id, toolName) {
9536
+ const spans = loadAllSpans(dir);
9537
+ const idx = spans.findLastIndex((s) => s.span_id === span_id);
9538
+ if (idx === -1)
9539
+ return;
9540
+ if (!spans[idx].tools_used.includes(toolName)) {
9541
+ spans[idx].tools_used = [...spans[idx].tools_used, toolName];
9542
+ saveAllSpans(dir, spans);
9543
+ }
9011
9544
  }
9012
- function deny3(reason, source, escalationMessage, riskFlags = []) {
9013
- return {
9014
- verdict: "deny",
9015
- reason,
9016
- riskFlags,
9017
- source,
9018
- escalationMessage
9019
- };
9545
+ function addSpanViolation(dir, span_id, violation) {
9546
+ const spans = loadAllSpans(dir);
9547
+ const idx = spans.findLastIndex((s) => s.span_id === span_id);
9548
+ if (idx === -1)
9549
+ return;
9550
+ spans[idx].contract_violations = [...spans[idx].contract_violations, violation];
9551
+ saveAllSpans(dir, spans);
9020
9552
  }
9021
- function ask2(reason, source, approvalRequestId, riskFlags = []) {
9022
- return {
9023
- verdict: "ask",
9024
- reason,
9025
- riskFlags,
9026
- source,
9027
- approvalRequestId
9028
- };
9553
+ function recordContractViolation(dir, span_id, violation) {
9554
+ addSpanViolation(dir, span_id, violation);
9029
9555
  }
9030
- function validationToDecision(result) {
9031
- const riskFlags = result.violations.map((v) => v.rule);
9032
- if (result.action === "block") {
9033
- return deny3(result.message ?? "Agent contract violation", "agent-contract", result.message ?? "This tool call violates the agent's capability contract", riskFlags);
9034
- }
9035
- if (result.action === "escalate") {
9036
- return ask2(result.message ?? "Agent contract requires escalation", "agent-contract", undefined, riskFlags);
9037
- }
9038
- if (result.action === "warn") {
9039
- return allow4(result.message ?? "Agent contract advisory", "agent-contract", riskFlags);
9556
+ function getTraceSpans(dir, trace_id) {
9557
+ return loadAllSpans(dir).filter((s) => s.trace_id === trace_id);
9558
+ }
9559
+
9560
+ // src/services/deadlock-detector.ts
9561
+ function resolveConfig(directory) {
9562
+ try {
9563
+ const config = loadFlowDeckConfig(directory);
9564
+ const dc = config?.governance?.deadlockDetection;
9565
+ return {
9566
+ enabled: dc?.enabled ?? true,
9567
+ bounceThreshold: dc?.bounceThreshold ?? 3,
9568
+ retryLoopThreshold: dc?.retryLoopThreshold ?? 3,
9569
+ stageStallMinutes: dc?.stageStallMinutes ?? 30,
9570
+ autoStop: dc?.autoStop ?? false
9571
+ };
9572
+ } catch {
9573
+ return { enabled: true, bounceThreshold: 3, retryLoopThreshold: 3, stageStallMinutes: 30, autoStop: false };
9040
9574
  }
9041
- return allow4("Agent contract passed", "agent-contract");
9042
9575
  }
9043
- function buildAuditEntry(input, decision) {
9044
- return {
9045
- id: randomUUID4(),
9046
- timestamp: new Date().toISOString(),
9047
- run_id: input.runId,
9048
- session_id: input.sessionID,
9049
- agent: input.agent,
9050
- tool: input.tool,
9051
- command: input.command,
9052
- verdict: decision.verdict,
9053
- reason: decision.reason,
9054
- risk_flags: decision.riskFlags,
9055
- source: decision.source,
9056
- approval_request_id: decision.approvalRequestId
9057
- };
9576
+ function deadlockSignalsPath(dir) {
9577
+ return join24(codebaseDir(dir), "DEADLOCK_SIGNALS.jsonl");
9058
9578
  }
9059
- function createHarnessPolicy(directory, appLog) {
9060
- const config = loadFlowDeckConfig(directory);
9061
- const auditLog = createAuditLog(directory, appLog);
9062
- const loopDetector = new LoopDetector({
9063
- enabled: config.governance?.loopDetection?.enabled ?? true,
9064
- maxRepeats: config.governance?.loopDetection?.maxRepeats ?? 2,
9065
- similarityThreshold: config.governance?.loopDetection?.similarityThreshold ?? 0.9,
9066
- historySize: config.governance?.loopDetection?.historySize ?? 20
9067
- }, appLog);
9068
- function appendAudit(input, decision) {
9069
- try {
9070
- auditLog.append(buildAuditEntry(input, decision));
9071
- } catch (error) {
9072
- if (appLog) {
9073
- const message = error instanceof Error ? error.message : String(error);
9074
- appLog(`[harness-policy] audit append failed: ${message}`);
9075
- }
9076
- }
9077
- }
9078
- function checkRuntimeLimits(input) {
9079
- try {
9080
- const loop = loopDetector.checkBefore(input.tool, input.args, input.sessionID);
9081
- if (loop.action === "block") {
9082
- return deny3(loop.reason, "loop-detector", loop.escalationMessage, ["loop-detected"]);
9083
- }
9084
- if (loop.action === "warn") {
9085
- return allow4(loop.message, "loop-detector", ["loop-warning"]);
9086
- }
9087
- if (isTraceStuck(input.directory, input.runId)) {
9088
- return deny3("Run is stuck (deadlock signal with auto_stop)", "deadlock-detector", "Deadlock detection flagged this run for auto-stop. Escalate to the human.", ["deadlock"]);
9089
- }
9090
- return allow4("Runtime limits passed", "runtime-limits");
9091
- } catch (error) {
9092
- const message = error instanceof Error ? error.message : String(error);
9093
- if (appLog)
9094
- appLog(`[harness-policy] runtime limits error: ${message}`);
9095
- return deny3(`Runtime limits check failed: ${message}`, "runtime-limits", `Runtime limits check failed: ${message}`, ["runtime-check-error"]);
9096
- }
9097
- }
9098
- function checkAgentContract(input) {
9099
- try {
9100
- const result = validateToolAccess(input.directory, input.agent, input.tool, { run_id: input.runId, session_id: input.sessionID });
9101
- return validationToDecision(result);
9102
- } catch (error) {
9103
- const message = error instanceof Error ? error.message : String(error);
9104
- if (appLog)
9105
- appLog(`[harness-policy] agent contract error: ${message}`);
9106
- return deny3(`Agent contract check failed: ${message}`, "agent-contract", `Agent contract check failed: ${message}`, ["agent-contract-error"]);
9107
- }
9108
- }
9109
- function evaluatePolicyEngine(input) {
9110
- try {
9111
- const store = readStore2(input.directory);
9112
- const filePath = extractTargetPath(input.args);
9113
- const active = store.policies.filter((p) => p.active);
9114
- for (const policy of active) {
9115
- const trigger = policy.trigger.toLowerCase();
9116
- if (!trigger.trim())
9117
- continue;
9118
- if (input.tool.toLowerCase().includes(trigger) || filePath.toLowerCase().includes(trigger)) {
9119
- return deny3(`Active policy violation: ${policy.name}`, "policy-engine", policy.rule, ["policy-violation", policy.id]);
9120
- }
9121
- }
9122
- return allow4("No active policy violations", "policy-engine");
9123
- } catch (error) {
9124
- const message = error instanceof Error ? error.message : String(error);
9125
- if (appLog)
9126
- appLog(`[harness-policy] policy engine error: ${message}`);
9127
- return allow4(`Policy engine check failed: ${message}`, "policy-engine", [
9128
- "policy-engine-error"
9129
- ]);
9130
- }
9131
- }
9132
- function checkGovernance(input) {
9133
- try {
9134
- const riskFlags = [];
9135
- if (config.governance?.toolGuard !== false) {
9136
- const tg = evaluate(input);
9137
- if (tg.verdict === "deny")
9138
- return tg;
9139
- riskFlags.push(...tg.riskFlags);
9140
- }
9141
- if (config.governance?.guardRails !== false) {
9142
- const gr = evaluate2(input);
9143
- if (gr.verdict === "deny")
9144
- return gr;
9145
- riskFlags.push(...gr.riskFlags);
9146
- }
9147
- if (config.governance?.approvals !== false) {
9148
- const ap = evaluate3(input);
9149
- if (ap.verdict !== "allow")
9150
- return ap;
9151
- }
9152
- const pe = evaluatePolicyEngine(input);
9153
- if (pe.verdict === "deny")
9154
- return pe;
9155
- riskFlags.push(...pe.riskFlags);
9156
- return allow4("Governance checks passed", "governance", riskFlags);
9157
- } catch (error) {
9158
- const message = error instanceof Error ? error.message : String(error);
9159
- if (appLog)
9160
- appLog(`[harness-policy] governance error: ${message}`);
9161
- return deny3(`Governance check failed: ${message}`, "governance", `Governance check failed: ${message}`, ["governance-error"]);
9162
- }
9163
- }
9164
- function checkSupervisor(input, _targetName) {
9165
- if (config.governance?.supervisor?.enabled === false) {
9166
- return allow4("Supervisor disabled", "supervisor");
9167
- }
9168
- try {
9169
- return reviewToolCall(input.directory, input);
9170
- } catch (error) {
9171
- const message = error instanceof Error ? error.message : String(error);
9172
- if (appLog)
9173
- appLog(`[harness-policy] supervisor error: ${message}`);
9174
- return deny3(`Supervisor review failed: ${message}`, "supervisor", `Supervisor review failed: ${message}`, ["supervisor-error"]);
9175
- }
9176
- }
9177
- function generateApprovalRequestId(input) {
9178
- try {
9179
- const req = requestApprovalForTool(input.directory, input.runId, input.sessionID, input.agent, input.tool, input.args);
9180
- return req.id;
9181
- } catch (error) {
9182
- const message = error instanceof Error ? error.message : String(error);
9183
- if (appLog)
9184
- appLog(`[harness-policy] approval request failed: ${message}`);
9185
- return;
9186
- }
9579
+ function appendSignal(dir, signal) {
9580
+ const cd = codebaseDir(dir);
9581
+ if (!existsSync24(cd))
9582
+ mkdirSync14(cd, { recursive: true });
9583
+ appendFileSync5(deadlockSignalsPath(dir), JSON.stringify(signal) + `
9584
+ `, "utf-8");
9585
+ }
9586
+ function getSignals(dir, trace_id) {
9587
+ const p = deadlockSignalsPath(dir);
9588
+ if (!existsSync24(p))
9589
+ return [];
9590
+ try {
9591
+ const all = readFileSync24(p, "utf-8").trim().split(`
9592
+ `).filter(Boolean).map((l) => JSON.parse(l));
9593
+ return trace_id ? all.filter((s) => s.trace_id === trace_id) : all;
9594
+ } catch {
9595
+ return [];
9187
9596
  }
9188
- function combineDecisions(decisions, input) {
9189
- const denied = decisions.find((d) => d.verdict === "deny");
9190
- if (denied)
9191
- return denied;
9192
- const asked = decisions.find((d) => d.verdict === "ask");
9193
- if (asked) {
9597
+ }
9598
+ function detectAgentBounce(dir, trace_id, cfg) {
9599
+ const spans = getTraceSpans(dir, trace_id);
9600
+ const pairCounts = {};
9601
+ for (let i = 1;i < spans.length; i++) {
9602
+ const key = `${spans[i - 1].agent}→${spans[i].agent}`;
9603
+ pairCounts[key] = (pairCounts[key] ?? 0) + 1;
9604
+ }
9605
+ for (const [pair, count] of Object.entries(pairCounts)) {
9606
+ if (count >= cfg.bounceThreshold) {
9607
+ const [a, b] = pair.split("→");
9194
9608
  return {
9195
- ...asked,
9196
- approvalRequestId: asked.approvalRequestId ?? generateApprovalRequestId(input)
9609
+ signal_id: randomUUID3(),
9610
+ trace_id,
9611
+ detected_at: new Date().toISOString(),
9612
+ type: "agent_bounce",
9613
+ evidence: [`Agent pair "${pair}" handed off ${count} times (threshold: ${cfg.bounceThreshold})`],
9614
+ agents_involved: [a, b],
9615
+ recommended_action: "escalate_human",
9616
+ auto_stop: cfg.autoStop
9197
9617
  };
9198
9618
  }
9199
- const riskFlags = decisions.flatMap((d) => d.riskFlags);
9200
- const source = decisions.findLast((d) => d.source !== "harness.policy")?.source ?? "harness.policy";
9201
- return allow4("All policy checks passed", source, riskFlags);
9202
9619
  }
9203
- function evaluate4(input) {
9204
- if (config.harness?.enabled === false) {
9205
- const envDisabled = process.env.FLOWDECK_HARNESS_DISABLED === "1";
9206
- if (!envDisabled) {
9207
- if (appLog) {
9208
- appLog("[harness-policy] SECURITY WARNING: harness.enabled=false without FLOWDECK_HARNESS_DISABLED=1. Enforcing minimal safety checks (loop detection, tool-guard dangerous patterns, orchestrator contract).");
9209
- }
9210
- const runtime2 = checkRuntimeLimits(input);
9211
- if (runtime2.verdict === "deny") {
9212
- appendAudit(input, runtime2);
9213
- return runtime2;
9214
- }
9215
- if (config.governance?.toolGuard !== false) {
9216
- const tg = evaluate(input);
9217
- if (tg.verdict === "deny") {
9218
- appendAudit(input, tg);
9219
- return tg;
9220
- }
9221
- }
9222
- const decision2 = allow4("Harness disabled — minimal safety checks passed", "harness.policy", ["harness-disabled-minimal"]);
9223
- appendAudit(input, decision2);
9224
- return decision2;
9620
+ return null;
9621
+ }
9622
+ function detectCircularDelegation(dir, trace_id, cfg) {
9623
+ const spans = getTraceSpans(dir, trace_id);
9624
+ const graph = {};
9625
+ for (const span of spans) {
9626
+ if (span.invoker === span.agent)
9627
+ continue;
9628
+ if (!graph[span.invoker])
9629
+ graph[span.invoker] = new Set;
9630
+ graph[span.invoker].add(span.agent);
9631
+ }
9632
+ function findCycle(node, visited2, stack) {
9633
+ visited2.add(node);
9634
+ for (const neighbor of [...graph[node] ?? []]) {
9635
+ if (stack.includes(neighbor))
9636
+ return [...stack, neighbor];
9637
+ if (!visited2.has(neighbor)) {
9638
+ const result = findCycle(neighbor, visited2, [...stack, neighbor]);
9639
+ if (result)
9640
+ return result;
9225
9641
  }
9226
- const decision = allow4("Harness disabled by config and env override", "harness.policy");
9227
- appendAudit(input, decision);
9228
- return decision;
9229
- }
9230
- const decisions = [];
9231
- const runtime = checkRuntimeLimits(input);
9232
- decisions.push(runtime);
9233
- if (runtime.verdict === "deny") {
9234
- appendAudit(input, runtime);
9235
- return runtime;
9236
- }
9237
- const contract = checkAgentContract(input);
9238
- decisions.push(contract);
9239
- if (contract.verdict === "deny") {
9240
- appendAudit(input, contract);
9241
- return contract;
9242
9642
  }
9243
- const governance = checkGovernance(input);
9244
- decisions.push(governance);
9245
- if (governance.verdict === "deny") {
9246
- appendAudit(input, governance);
9247
- return governance;
9643
+ return null;
9644
+ }
9645
+ const visited = new Set;
9646
+ for (const node of Object.keys(graph)) {
9647
+ if (!visited.has(node)) {
9648
+ const cycle = findCycle(node, visited, [node]);
9649
+ if (cycle) {
9650
+ return {
9651
+ signal_id: randomUUID3(),
9652
+ trace_id,
9653
+ detected_at: new Date().toISOString(),
9654
+ type: "circular_delegation",
9655
+ evidence: [`Delegation cycle: ${cycle.join(" → ")}`],
9656
+ agents_involved: cycle,
9657
+ recommended_action: "stop",
9658
+ auto_stop: true
9659
+ };
9660
+ }
9248
9661
  }
9249
- const supervisor = checkSupervisor(input, input.agent);
9250
- decisions.push(supervisor);
9251
- const final = combineDecisions(decisions, input);
9252
- appendAudit(input, final);
9253
- return final;
9254
9662
  }
9255
- return {
9256
- evaluate: evaluate4,
9257
- checkAgentContract,
9258
- checkRuntimeLimits,
9259
- checkGovernance,
9260
- checkSupervisor
9261
- };
9663
+ return null;
9262
9664
  }
9263
-
9264
- // src/services/harness-controller.ts
9265
- import { randomUUID as randomUUID7 } from "crypto";
9266
-
9267
- // src/services/preflight-explorer.ts
9268
- var QUESTION_KIND_PATTERNS = [
9269
- {
9270
- kind: "what-tech-stack",
9271
- patterns: ["tech stack", "language", "framework", "what are you using", "what tech", "built with", "written in"]
9272
- },
9273
- {
9274
- kind: "is-project-initialized",
9275
- patterns: ["initialized", "set up", "codebase mapped", "map-codebase", "new feature"]
9276
- },
9277
- {
9278
- kind: "what-is-current-phase",
9279
- patterns: ["current phase", "which phase", "what phase", "where are we", "current state"]
9280
- },
9281
- {
9282
- kind: "what-patterns-exist",
9283
- patterns: ["existing pattern", "how is it done", "how does the codebase", "pattern used", "architecture"]
9284
- },
9285
- {
9286
- kind: "is-ui-heavy",
9287
- patterns: ["ui", "frontend", "user interface", "webpage", "web app", "dashboard", "landing page", "screen"]
9288
- },
9289
- {
9290
- kind: "has-existing-tests",
9291
- patterns: ["test", "spec", "coverage", "tdd", "regression"]
9292
- },
9293
- {
9294
- kind: "has-existing-docs",
9295
- patterns: ["docs", "documentation", "readme", "api docs"]
9296
- },
9297
- {
9298
- kind: "has-ci-cd",
9299
- patterns: ["ci/cd", "continuous integration", "deploy", "pipeline", "github actions", ".github/workflow"]
9300
- },
9301
- {
9302
- kind: "what-agents-available",
9303
- patterns: ["which agent", "available agent", "what agent"]
9304
- },
9305
- {
9306
- kind: "what-commands-available",
9307
- patterns: ["which command", "available command", "what command", "slash command"]
9308
- },
9309
- {
9310
- kind: "what-skills-available",
9311
- patterns: ["skill", "available skill"]
9312
- },
9313
- {
9314
- kind: "has-prior-decisions",
9315
- patterns: ["prior decision", "previous discussion", "what was decided", "earlier session", "previous phase"]
9316
- },
9317
- {
9318
- kind: "has-governance",
9319
- patterns: ["governance", "policy", "approval", "supervisor"]
9665
+ function detectStepRetryLoop(dir, trace_id, cfg) {
9666
+ const spans = getTraceSpans(dir, trace_id);
9667
+ const stageCounts = {};
9668
+ const stageAgents = {};
9669
+ for (const span of spans) {
9670
+ const key = `${span.agent}:${span.stage}`;
9671
+ stageCounts[key] = (stageCounts[key] ?? 0) + 1;
9672
+ if (!stageAgents[key])
9673
+ stageAgents[key] = new Set;
9674
+ stageAgents[key].add(span.agent);
9320
9675
  }
9321
- ];
9322
- function shouldSuppressQuestion(question, result, sessionHistory) {
9323
- const lower = question.toLowerCase();
9324
- const alreadyAsked = sessionHistory.some((h) => h.toLowerCase().trim() === lower.trim());
9325
- if (alreadyAsked) {
9326
- return {
9327
- suppress: true,
9328
- reason: "This question was already asked in the current session."
9329
- };
9676
+ for (const [key, count] of Object.entries(stageCounts)) {
9677
+ if (count >= cfg.retryLoopThreshold) {
9678
+ return {
9679
+ signal_id: randomUUID3(),
9680
+ trace_id,
9681
+ detected_at: new Date().toISOString(),
9682
+ type: "step_retry_loop",
9683
+ evidence: [`Stage "${key}" executed ${count} times (threshold: ${cfg.retryLoopThreshold})`],
9684
+ agents_involved: [...stageAgents[key] ?? new Set],
9685
+ recommended_action: "escalate_human",
9686
+ auto_stop: cfg.autoStop
9687
+ };
9688
+ }
9330
9689
  }
9331
- const matchedEvidence = result.evidenceItems.filter((ev) => {
9332
- const qKind = classifyQuestionKind(lower);
9333
- return qKind !== null && qKind === ev.answersQuestion;
9334
- });
9335
- if (matchedEvidence.length > 0) {
9336
- return {
9337
- suppress: true,
9338
- answeredByEvidence: true,
9339
- reason: `Answered by repo evidence: ${matchedEvidence.map((e) => e.summary).join("; ")}`,
9340
- evidence: matchedEvidence
9341
- };
9690
+ return null;
9691
+ }
9692
+ function detectStageStall(dir, trace_id, cfg) {
9693
+ const spans = getTraceSpans(dir, trace_id);
9694
+ const now = Date.now();
9695
+ for (const span of spans) {
9696
+ if (span.status !== "running")
9697
+ continue;
9698
+ const elapsed = (now - new Date(span.started_at).getTime()) / 1000 / 60;
9699
+ if (elapsed >= cfg.stageStallMinutes) {
9700
+ return {
9701
+ signal_id: randomUUID3(),
9702
+ trace_id,
9703
+ detected_at: new Date().toISOString(),
9704
+ type: "stage_stall",
9705
+ evidence: [
9706
+ `Agent "${span.agent}" in stage "${span.stage}" running for ${Math.round(elapsed)}min (threshold: ${cfg.stageStallMinutes}min)`
9707
+ ],
9708
+ agents_involved: [span.agent],
9709
+ recommended_action: "escalate_human",
9710
+ auto_stop: cfg.autoStop
9711
+ };
9712
+ }
9342
9713
  }
9343
- return { suppress: false };
9714
+ return null;
9715
+ }
9716
+ function detectDeadlocks(dir, trace_id) {
9717
+ const cfg = resolveConfig(dir);
9718
+ if (!cfg.enabled)
9719
+ return [];
9720
+ const existingTypes = new Set(getSignals(dir, trace_id).map((s) => s.type));
9721
+ const candidates = [
9722
+ detectAgentBounce(dir, trace_id, cfg),
9723
+ detectCircularDelegation(dir, trace_id, cfg),
9724
+ detectStepRetryLoop(dir, trace_id, cfg),
9725
+ detectStageStall(dir, trace_id, cfg)
9726
+ ];
9727
+ const newSignals = candidates.filter((s) => s !== null && !existingTypes.has(s.type));
9728
+ for (const signal of newSignals)
9729
+ appendSignal(dir, signal);
9730
+ return newSignals;
9344
9731
  }
9345
- function refineClassification(clarificationPrompt, result, sessionHistory) {
9346
- const promptLower = clarificationPrompt.toLowerCase();
9347
- const isTaskTypeQuestion = result.hasProjectMD && (promptLower.includes("new feature") || promptLower.includes("bug fix") || promptLower.includes("ui change") || promptLower.includes("documentation") || promptLower.includes("describe the task") || promptLower.includes("more detail"));
9348
- if (isTaskTypeQuestion) {
9349
- return {
9350
- clarificationStillNeeded: false,
9351
- resolvedReason: "PROJECT.md exists — project is initialized. Defaulting ambiguous task to feature."
9352
- };
9732
+ function isTraceStuck(dir, trace_id) {
9733
+ return getSignals(dir, trace_id).some((s) => s.auto_stop);
9734
+ }
9735
+
9736
+ // src/services/audit-log.ts
9737
+ import {
9738
+ existsSync as existsSync25,
9739
+ mkdirSync as mkdirSync15,
9740
+ appendFileSync as appendFileSync6,
9741
+ readFileSync as readFileSync25,
9742
+ writeFileSync as writeFileSync15,
9743
+ renameSync,
9744
+ unlinkSync
9745
+ } from "fs";
9746
+ import { join as join25 } from "path";
9747
+ var AUDIT_FILE = "AUDIT.jsonl";
9748
+ var ROTATE_LINE_COUNT = 1000;
9749
+ function auditPath(directory) {
9750
+ return join25(codebaseDir(directory), AUDIT_FILE);
9751
+ }
9752
+ function ensureDirectory(dir) {
9753
+ const base = codebaseDir(dir);
9754
+ if (!existsSync25(base)) {
9755
+ mkdirSync15(base, { recursive: true });
9353
9756
  }
9354
- const suppress = shouldSuppressQuestion(clarificationPrompt, result, sessionHistory);
9355
- if (suppress.suppress) {
9356
- return {
9357
- clarificationStillNeeded: false,
9358
- resolvedReason: suppress.reason
9359
- };
9757
+ }
9758
+ function rotateIfNeeded(path, appLog) {
9759
+ try {
9760
+ const content = readFileSync25(path, "utf-8");
9761
+ const lines = content.split(`
9762
+ `).filter((line) => line.trim().length > 0);
9763
+ if (lines.length <= ROTATE_LINE_COUNT)
9764
+ return;
9765
+ const backupPath = `${path}.backup`;
9766
+ renameSync(path, backupPath);
9767
+ const keep = lines.slice(-ROTATE_LINE_COUNT);
9768
+ writeFileSync15(path, keep.join(`
9769
+ `) + `
9770
+ `, "utf-8");
9771
+ try {
9772
+ unlinkSync(backupPath);
9773
+ } catch {}
9774
+ } catch (error) {
9775
+ if (appLog) {
9776
+ const message = error instanceof Error ? error.message : String(error);
9777
+ appLog(`[audit-log] rotation failed: ${message}`);
9778
+ }
9360
9779
  }
9361
- const lines = [];
9362
- if (result.hasProjectMD)
9363
- lines.push("PROJECT.md is present (project is initialized).");
9364
- if (result.hasStateMD)
9365
- lines.push("STATE.md is present (project has active session).");
9366
- if (result.hasPriorDiscussions)
9367
- lines.push("Prior DISCUSS.md files exist.");
9368
- if (result.techStack.length > 0)
9369
- lines.push(`Tech stack: ${result.techStack.join(", ")}.`);
9370
- if (result.implementationPatterns.length > 0) {
9371
- lines.push(`Implementation patterns: ${result.implementationPatterns.join(", ")}.`);
9780
+ }
9781
+ function appendAuditEntry(directory, entry, appLog) {
9782
+ ensureDirectory(directory);
9783
+ const path = auditPath(directory);
9784
+ appendFileSync6(path, JSON.stringify(entry) + `
9785
+ `, "utf-8");
9786
+ rotateIfNeeded(path, appLog);
9787
+ }
9788
+ function queryAudit(directory, filter) {
9789
+ const path = auditPath(directory);
9790
+ if (!existsSync25(path))
9791
+ return [];
9792
+ try {
9793
+ const lines = readFileSync25(path, "utf-8").split(`
9794
+ `).filter((line) => line.trim().length > 0);
9795
+ const results = [];
9796
+ for (const line of lines) {
9797
+ try {
9798
+ const entry = JSON.parse(line);
9799
+ if (filter.run_id && entry.run_id !== filter.run_id)
9800
+ continue;
9801
+ if (filter.session_id && entry.session_id !== filter.session_id)
9802
+ continue;
9803
+ if (filter.tool && entry.tool !== filter.tool)
9804
+ continue;
9805
+ if (filter.verdict && entry.verdict !== filter.verdict)
9806
+ continue;
9807
+ results.push(entry);
9808
+ } catch {}
9809
+ }
9810
+ return results;
9811
+ } catch {
9812
+ return [];
9372
9813
  }
9814
+ }
9815
+ function createAuditLog(directory, appLog) {
9373
9816
  return {
9374
- clarificationStillNeeded: true,
9375
- supervisorContext: lines.length > 0 ? lines.join(" ") : undefined
9817
+ append: (entry) => appendAuditEntry(directory, entry, appLog),
9818
+ query: (filter) => queryAudit(directory, filter)
9376
9819
  };
9377
9820
  }
9378
- var STOP_WORDS = new Set([
9379
- "with",
9380
- "that",
9381
- "this",
9382
- "from",
9383
- "into",
9384
- "when",
9385
- "then",
9386
- "will",
9387
- "have",
9388
- "been",
9389
- "does",
9390
- "should",
9391
- "would",
9392
- "could",
9393
- "after",
9394
- "before",
9395
- "about"
9396
- ]);
9397
- function classifyQuestionKind(questionLower) {
9398
- for (const { kind, patterns } of QUESTION_KIND_PATTERNS) {
9399
- if (patterns.some((p) => questionLower.includes(p)))
9400
- return kind;
9401
- }
9402
- return null;
9403
- }
9404
9821
 
9405
- // src/services/workflow-router.ts
9406
- function stage(name, command, requiresApproval, skippable, args) {
9407
- return { name, command, args, requiresApproval, skippable };
9822
+ // src/services/harness-policy.ts
9823
+ function allow4(reason, source, riskFlags = []) {
9824
+ return { verdict: "allow", reason, riskFlags, source };
9408
9825
  }
9409
- function scoreTaskForRouting(criteria) {
9410
- const simplicity = (criteria.taskType === "simple" ? 1 : 0) * 0.3;
9411
- const confidence = criteria.confidence * 0.2;
9412
- const lowRisk = !criteria.isSensitive && criteria.blastRadius < 3 ? 0.2 : 0;
9413
- const knownCodebase = criteria.codebaseFreshness === "fresh" ? 0.15 : 0;
9414
- const cheapComplexity = criteria.complexity === "cheap" ? 0.15 : 0;
9415
- const total = simplicity + confidence + lowRisk + knownCodebase + cheapComplexity;
9826
+ function deny3(reason, source, escalationMessage, riskFlags = []) {
9416
9827
  return {
9417
- simplicity,
9418
- confidence,
9419
- lowRisk,
9420
- knownCodebase,
9421
- cheapComplexity,
9422
- total
9828
+ verdict: "deny",
9829
+ reason,
9830
+ riskFlags,
9831
+ source,
9832
+ escalationMessage
9423
9833
  };
9424
9834
  }
9425
- function buildAdaptiveStageSequence(criteria) {
9426
- const scores = scoreTaskForRouting(criteria);
9427
- const totalScore = scores.total;
9428
- let workflowClass;
9429
- let stages;
9430
- let reason;
9431
- if (totalScore >= 0.75 && (criteria.taskType === "simple" || criteria.taskType === "docs")) {
9432
- workflowClass = "quick";
9433
- stages = [
9434
- stage("execute", "fd-execute", false, true),
9435
- stage("verify", "fd-verify", false, true)
9436
- ];
9437
- reason = `Quick workflow: score ${totalScore.toFixed(2)} >= 0.75 for ${criteria.taskType} task`;
9438
- } else if (criteria.taskType === "bugfix") {
9439
- workflowClass = "bugfix";
9440
- stages = [
9441
- stage("discuss", "fd-discuss", false, false),
9442
- stage("fix-bug", "fd-fix-bug", false, false),
9443
- stage("verify", "fd-verify", false, false)
9444
- ];
9445
- reason = "Bugfix workflow: task type is bugfix";
9446
- } else if (criteria.taskType === "docs" && totalScore < 0.75) {
9447
- workflowClass = "docs-only";
9448
- stages = [
9449
- stage("write-docs", "fd-write-docs", false, false),
9450
- stage("verify", "fd-verify", false, true)
9451
- ];
9452
- reason = `Docs-only workflow: score ${totalScore.toFixed(2)} < 0.75 for docs task`;
9453
- } else if (criteria.taskType === "ui-feature") {
9454
- workflowClass = "ui-heavy";
9455
- stages = [
9456
- stage("discuss", "fd-discuss", false, false),
9457
- stage("design", "fd-design", false, false, "--mode=draft"),
9458
- stage("plan", "fd-plan", true, false),
9459
- stage("execute", "fd-execute", false, false),
9460
- stage("verify", "fd-verify", false, false)
9461
- ];
9462
- reason = "UI-heavy workflow: task type indicates UI-heavy work";
9463
- } else if (criteria.blastRadius >= 5 || criteria.isSensitive) {
9464
- workflowClass = "verify-heavy";
9465
- stages = [
9466
- stage("plan", "fd-plan", true, false),
9467
- stage("execute", "fd-execute", false, false),
9468
- stage("verify", "fd-verify", false, false)
9469
- ];
9470
- reason = `Verify-heavy workflow: blastRadius=${criteria.blastRadius}, isSensitive=${criteria.isSensitive}`;
9471
- } else if (criteria.confidence < 0.6 || criteria.taskType === "ambiguous") {
9472
- workflowClass = "explore";
9473
- stages = [
9474
- stage("discuss", "fd-discuss", false, false),
9475
- stage("plan", "fd-plan", true, false),
9476
- stage("execute", "fd-execute", false, false),
9477
- stage("verify", "fd-verify", false, false)
9478
- ];
9479
- reason = `Explore workflow: confidence=${criteria.confidence}, taskType=${criteria.taskType}`;
9480
- } else {
9481
- workflowClass = "standard";
9482
- stages = [
9483
- stage("plan", "fd-plan", true, false),
9484
- stage("execute", "fd-execute", false, false),
9485
- stage("verify", "fd-verify", false, false)
9486
- ];
9487
- reason = `Standard workflow: score ${totalScore.toFixed(2)} with taskType ${criteria.taskType}`;
9835
+ function ask2(reason, source, approvalRequestId, riskFlags = []) {
9836
+ return {
9837
+ verdict: "ask",
9838
+ reason,
9839
+ riskFlags,
9840
+ source,
9841
+ approvalRequestId
9842
+ };
9843
+ }
9844
+ function validationToDecision(result) {
9845
+ const riskFlags = result.violations.map((v) => v.rule);
9846
+ if (result.action === "block") {
9847
+ return deny3(result.message ?? "Agent contract violation", "agent-contract", result.message ?? "This tool call violates the agent's capability contract", riskFlags);
9488
9848
  }
9849
+ if (result.action === "escalate") {
9850
+ return ask2(result.message ?? "Agent contract requires escalation", "agent-contract", undefined, riskFlags);
9851
+ }
9852
+ if (result.action === "warn") {
9853
+ return allow4(result.message ?? "Agent contract advisory", "agent-contract", riskFlags);
9854
+ }
9855
+ return allow4("Agent contract passed", "agent-contract");
9856
+ }
9857
+ function buildAuditEntry(input, decision) {
9489
9858
  return {
9490
- workflowClass,
9491
- stages,
9492
- criteria,
9493
- scores,
9494
- reason
9859
+ id: randomUUID4(),
9860
+ timestamp: new Date().toISOString(),
9861
+ run_id: input.runId,
9862
+ session_id: input.sessionID,
9863
+ agent: input.agent,
9864
+ tool: input.tool,
9865
+ command: input.command,
9866
+ verdict: decision.verdict,
9867
+ reason: decision.reason,
9868
+ risk_flags: decision.riskFlags,
9869
+ source: decision.source,
9870
+ approval_request_id: decision.approvalRequestId
9495
9871
  };
9496
9872
  }
9497
-
9498
- // src/services/quick-router.ts
9499
- var BUG_SIGNALS = [
9500
- "fix",
9501
- "bug",
9502
- "broken",
9503
- "not working",
9504
- "doesn't work",
9505
- "does not work",
9506
- "error",
9507
- "crash",
9508
- "regression",
9509
- "debug",
9510
- "exception",
9511
- "failing",
9512
- "fails",
9513
- "incorrect",
9514
- "wrong output",
9515
- "infinite loop",
9516
- "null pointer",
9517
- "undefined",
9518
- "404",
9519
- "500",
9520
- "stack trace",
9521
- "traceback",
9522
- "root cause",
9523
- "why is"
9524
- ];
9525
- var UI_SIGNALS = [
9526
- "landing page",
9527
- "dashboard",
9528
- "admin panel",
9529
- "admin page",
9530
- "app screen",
9531
- "onboarding",
9532
- "onboard",
9533
- "wireframe",
9534
- "mockup",
9535
- "design system",
9536
- "component library",
9537
- "ui component",
9538
- "ux flow",
9539
- "user interface",
9540
- "web app",
9541
- "web application",
9542
- "website",
9543
- "frontend page",
9544
- "mobile screen",
9545
- "login page",
9546
- "signup page",
9547
- "settings page",
9548
- "profile page",
9549
- "modal",
9550
- "dialog",
9551
- "sidebar",
9552
- "navigation",
9553
- "navbar",
9554
- "header",
9555
- "footer",
9556
- "layout",
9557
- "responsive",
9558
- "accessibility",
9559
- "a11y",
9560
- "dark mode",
9561
- "theme"
9562
- ];
9563
- var DOCS_SIGNALS = [
9564
- "docs",
9565
- "documentation",
9566
- "readme",
9567
- "api docs",
9568
- "usage guide",
9569
- "write docs",
9570
- "document",
9571
- "document the",
9572
- "how to use",
9573
- "tutorial",
9574
- "changelog",
9575
- "contributing guide",
9576
- "docstring",
9577
- "jsdoc",
9578
- "tsdoc"
9579
- ];
9580
- var SIMPLE_SIGNALS = [
9581
- "rename",
9582
- "move file",
9583
- "quick",
9584
- "minor",
9585
- "small change",
9586
- "one-liner",
9587
- "typo",
9588
- "update constant",
9589
- "update config",
9590
- "bump version"
9591
- ];
9592
- var AMBIGUOUS_PATTERNS = [
9593
- /^(improve|make|update|change|add|remove|help|do|run|check|use)\s+\w+$/i
9594
- ];
9595
- function classifyTask(description) {
9596
- const lower = description.toLowerCase().trim();
9597
- if (!lower) {
9598
- return _ambiguous([], "What task do you want to run? Please describe what you need done.");
9599
- }
9600
- const bugHits = BUG_SIGNALS.filter((s) => lower.includes(s));
9601
- const uiHits = UI_SIGNALS.filter((s) => lower.includes(s));
9602
- const docsHits = DOCS_SIGNALS.filter((s) => lower.includes(s));
9603
- const simpleHits = SIMPLE_SIGNALS.filter((s) => lower.includes(s));
9604
- const bugScore = Math.min(bugHits.length * 0.35, 1);
9605
- const uiScore = Math.min(uiHits.length * 0.3, 1);
9606
- const docsScore = Math.min(docsHits.length * 0.4, 1);
9607
- const simpleScore = Math.min(simpleHits.length * 0.45, 1);
9608
- if (bugScore >= 0.35 && bugScore >= uiScore && bugScore >= docsScore) {
9609
- return {
9610
- taskType: "bugfix",
9611
- confidence: Math.min(0.5 + bugScore * 0.5, 0.98),
9612
- signals: bugHits,
9613
- requiresDesign: false,
9614
- requiresTDD: true,
9615
- stageSequence: buildStageSequence("bugfix"),
9616
- clarificationNeeded: bugScore < 0.5,
9617
- clarificationPrompt: bugScore < 0.5 ? "Can you describe the specific bug? What is the expected vs actual behavior?" : undefined
9618
- };
9873
+ function createHarnessPolicy(directory, appLog) {
9874
+ const config = loadFlowDeckConfig(directory);
9875
+ const auditLog = createAuditLog(directory, appLog);
9876
+ const loopDetector = new LoopDetector({
9877
+ enabled: config.governance?.loopDetection?.enabled ?? true,
9878
+ maxRepeats: config.governance?.loopDetection?.maxRepeats ?? 2,
9879
+ similarityThreshold: config.governance?.loopDetection?.similarityThreshold ?? 0.9,
9880
+ historySize: config.governance?.loopDetection?.historySize ?? 20
9881
+ }, appLog);
9882
+ function appendAudit(input, decision) {
9883
+ try {
9884
+ auditLog.append(buildAuditEntry(input, decision));
9885
+ } catch (error) {
9886
+ if (appLog) {
9887
+ const message = error instanceof Error ? error.message : String(error);
9888
+ appLog(`[harness-policy] audit append failed: ${message}`);
9889
+ }
9890
+ }
9619
9891
  }
9620
- if (uiScore >= 0.3) {
9621
- return {
9622
- taskType: "ui-feature",
9623
- confidence: Math.min(0.5 + uiScore * 0.45, 0.95),
9624
- signals: uiHits,
9625
- requiresDesign: true,
9626
- requiresTDD: true,
9627
- stageSequence: buildStageSequence("ui-feature"),
9628
- clarificationNeeded: false
9629
- };
9892
+ function checkRuntimeLimits(input) {
9893
+ try {
9894
+ const loop = loopDetector.checkBefore(input.tool, input.args, input.sessionID);
9895
+ if (loop.action === "block") {
9896
+ return deny3(loop.reason, "loop-detector", loop.escalationMessage, ["loop-detected"]);
9897
+ }
9898
+ if (loop.action === "warn") {
9899
+ return allow4(loop.message, "loop-detector", ["loop-warning"]);
9900
+ }
9901
+ if (isTraceStuck(input.directory, input.runId)) {
9902
+ return deny3("Run is stuck (deadlock signal with auto_stop)", "deadlock-detector", "Deadlock detection flagged this run for auto-stop. Escalate to the human.", ["deadlock"]);
9903
+ }
9904
+ return allow4("Runtime limits passed", "runtime-limits");
9905
+ } catch (error) {
9906
+ const message = error instanceof Error ? error.message : String(error);
9907
+ if (appLog)
9908
+ appLog(`[harness-policy] runtime limits error: ${message}`);
9909
+ return deny3(`Runtime limits check failed: ${message}`, "runtime-limits", `Runtime limits check failed: ${message}`, ["runtime-check-error"]);
9910
+ }
9630
9911
  }
9631
- if (docsScore >= 0.4 && docsScore >= bugScore) {
9632
- return {
9633
- taskType: "docs",
9634
- confidence: Math.min(0.55 + docsScore * 0.4, 0.95),
9635
- signals: docsHits,
9636
- requiresDesign: false,
9637
- requiresTDD: false,
9638
- stageSequence: buildStageSequence("docs"),
9639
- clarificationNeeded: false
9640
- };
9912
+ function checkAgentContract(input) {
9913
+ try {
9914
+ const result = validateToolAccess(input.directory, input.agent, input.tool, { run_id: input.runId, session_id: input.sessionID });
9915
+ return validationToDecision(result);
9916
+ } catch (error) {
9917
+ const message = error instanceof Error ? error.message : String(error);
9918
+ if (appLog)
9919
+ appLog(`[harness-policy] agent contract error: ${message}`);
9920
+ return deny3(`Agent contract check failed: ${message}`, "agent-contract", `Agent contract check failed: ${message}`, ["agent-contract-error"]);
9921
+ }
9641
9922
  }
9642
- if (simpleScore >= 0.45) {
9643
- return {
9644
- taskType: "simple",
9645
- confidence: Math.min(0.55 + simpleScore * 0.35, 0.9),
9646
- signals: simpleHits,
9647
- requiresDesign: false,
9648
- requiresTDD: false,
9649
- stageSequence: buildStageSequence("simple"),
9650
- clarificationNeeded: false
9651
- };
9923
+ function evaluatePolicyEngine(input) {
9924
+ try {
9925
+ const store = readStore2(input.directory);
9926
+ const filePath = extractTargetPath(input.args);
9927
+ const active = store.policies.filter((p) => p.active);
9928
+ for (const policy of active) {
9929
+ const trigger = policy.trigger.toLowerCase();
9930
+ if (!trigger.trim())
9931
+ continue;
9932
+ if (input.tool.toLowerCase().includes(trigger) || filePath.toLowerCase().includes(trigger)) {
9933
+ return deny3(`Active policy violation: ${policy.name}`, "policy-engine", policy.rule, ["policy-violation", policy.id]);
9934
+ }
9935
+ }
9936
+ return allow4("No active policy violations", "policy-engine");
9937
+ } catch (error) {
9938
+ const message = error instanceof Error ? error.message : String(error);
9939
+ if (appLog)
9940
+ appLog(`[harness-policy] policy engine error: ${message}`);
9941
+ return allow4(`Policy engine check failed: ${message}`, "policy-engine", [
9942
+ "policy-engine-error"
9943
+ ]);
9944
+ }
9652
9945
  }
9653
- const wordCount = lower.split(/\s+/).filter(Boolean).length;
9654
- if (wordCount >= 5) {
9655
- return {
9656
- taskType: "feature",
9657
- confidence: Math.min(0.5 + wordCount * 0.02, 0.85),
9658
- signals: [],
9659
- requiresDesign: false,
9660
- requiresTDD: true,
9661
- stageSequence: buildStageSequence("feature"),
9662
- clarificationNeeded: wordCount < 8,
9663
- clarificationPrompt: wordCount < 8 ? "Is this a new feature, a bug fix, or a documentation task? A bit more context will help route it correctly." : undefined
9664
- };
9946
+ function checkGovernance(input) {
9947
+ try {
9948
+ const riskFlags = [];
9949
+ if (config.governance?.toolGuard !== false) {
9950
+ const tg = evaluate(input);
9951
+ if (tg.verdict === "deny")
9952
+ return tg;
9953
+ riskFlags.push(...tg.riskFlags);
9954
+ }
9955
+ if (config.governance?.guardRails !== false) {
9956
+ const gr = evaluate2(input);
9957
+ if (gr.verdict === "deny")
9958
+ return gr;
9959
+ riskFlags.push(...gr.riskFlags);
9960
+ }
9961
+ if (config.governance?.approvals !== false) {
9962
+ const ap = evaluate3(input);
9963
+ if (ap.verdict !== "allow")
9964
+ return ap;
9965
+ }
9966
+ const pe = evaluatePolicyEngine(input);
9967
+ if (pe.verdict === "deny")
9968
+ return pe;
9969
+ riskFlags.push(...pe.riskFlags);
9970
+ return allow4("Governance checks passed", "governance", riskFlags);
9971
+ } catch (error) {
9972
+ const message = error instanceof Error ? error.message : String(error);
9973
+ if (appLog)
9974
+ appLog(`[harness-policy] governance error: ${message}`);
9975
+ return deny3(`Governance check failed: ${message}`, "governance", `Governance check failed: ${message}`, ["governance-error"]);
9976
+ }
9665
9977
  }
9666
- const isAmbiguousPattern = AMBIGUOUS_PATTERNS.some((p) => p.test(lower));
9667
- if (isAmbiguousPattern || wordCount < 5) {
9668
- return _ambiguous([], "Can you describe the task in more detail? For example: is it a new feature, a bug fix, a UI change, or documentation?");
9978
+ function checkSupervisor(input, _targetName) {
9979
+ if (config.governance?.supervisor?.enabled === false) {
9980
+ return allow4("Supervisor disabled", "supervisor");
9981
+ }
9982
+ try {
9983
+ return reviewToolCall(input.directory, input);
9984
+ } catch (error) {
9985
+ const message = error instanceof Error ? error.message : String(error);
9986
+ if (appLog)
9987
+ appLog(`[harness-policy] supervisor error: ${message}`);
9988
+ return deny3(`Supervisor review failed: ${message}`, "supervisor", `Supervisor review failed: ${message}`, ["supervisor-error"]);
9989
+ }
9669
9990
  }
9670
- return {
9671
- taskType: "feature",
9672
- confidence: 0.6,
9673
- signals: [],
9674
- requiresDesign: false,
9675
- requiresTDD: true,
9676
- stageSequence: buildStageSequence("feature"),
9677
- clarificationNeeded: false
9678
- };
9679
- }
9680
- function _ambiguous(signals, prompt) {
9681
- return {
9682
- taskType: "ambiguous",
9683
- confidence: 0,
9684
- signals,
9685
- requiresDesign: false,
9686
- requiresTDD: false,
9687
- stageSequence: [],
9688
- clarificationNeeded: true,
9689
- clarificationPrompt: prompt
9690
- };
9691
- }
9692
- function buildStageSequence(taskType) {
9693
- switch (taskType) {
9694
- case "feature":
9695
- return [
9696
- stage2("discuss", "fd-discuss", false, false),
9697
- stage2("plan", "fd-plan", true, false),
9698
- stage2("execute", "fd-execute", false, false),
9699
- stage2("verify", "fd-verify", false, false)
9700
- ];
9701
- case "ui-feature":
9702
- return [
9703
- stage2("discuss", "fd-discuss", false, false),
9704
- stage2("design", "fd-design", false, false, "--mode=draft"),
9705
- stage2("plan", "fd-plan", true, false),
9706
- stage2("execute", "fd-execute", false, false),
9707
- stage2("verify", "fd-verify", false, false)
9708
- ];
9709
- case "bugfix":
9710
- return [
9711
- stage2("discuss", "fd-discuss", false, false),
9712
- stage2("fix-bug", "fd-fix-bug", false, false),
9713
- stage2("verify", "fd-verify", false, false)
9714
- ];
9715
- case "docs":
9716
- return [
9717
- stage2("discuss", "fd-discuss", false, false),
9718
- stage2("write-docs", "fd-write-docs", false, false),
9719
- stage2("verify", "fd-verify", false, true)
9720
- ];
9721
- case "simple":
9722
- return [
9723
- stage2("execute", "fd-execute", false, false),
9724
- stage2("verify", "fd-verify", false, true)
9725
- ];
9726
- case "ambiguous":
9727
- return [];
9728
- default:
9729
- return [];
9991
+ function generateApprovalRequestId(input) {
9992
+ try {
9993
+ const req = requestApprovalForTool(input.directory, input.runId, input.sessionID, input.agent, input.tool, input.args);
9994
+ return req.id;
9995
+ } catch (error) {
9996
+ const message = error instanceof Error ? error.message : String(error);
9997
+ if (appLog)
9998
+ appLog(`[harness-policy] approval request failed: ${message}`);
9999
+ return;
10000
+ }
9730
10001
  }
9731
- }
9732
- function buildAdaptiveWorkflow(description, exploration) {
9733
- const base = exploration ? classifyTaskWithContext(description, exploration) : classifyTask(description);
9734
- const complexityResult = classifyTaskComplexity(description);
9735
- const criteria = {
9736
- taskType: base.taskType,
9737
- complexity: complexityResult.complexity,
9738
- confidence: base.confidence,
9739
- blastRadius: 0,
9740
- isSensitive: false,
9741
- codebaseFreshness: exploration ? "fresh" : "unknown",
9742
- requiresTests: base.requiresTDD
9743
- };
9744
- const route = buildAdaptiveStageSequence(criteria);
9745
- return {
9746
- ...base,
9747
- stageSequence: route.stages,
9748
- workflowClass: route.workflowClass,
9749
- scores: route.scores
9750
- };
9751
- }
9752
- function stage2(name, command, requiresApproval, skippable, args) {
9753
- return { name, command, args, requiresApproval, skippable };
9754
- }
9755
- function classifyTaskWithContext(description, exploration, sessionHistory = []) {
9756
- const base = classifyTask(description);
9757
- if (!base.clarificationNeeded) {
9758
- return base;
10002
+ function combineDecisions(decisions, input) {
10003
+ const denied = decisions.find((d) => d.verdict === "deny");
10004
+ if (denied)
10005
+ return denied;
10006
+ const asked = decisions.find((d) => d.verdict === "ask");
10007
+ if (asked) {
10008
+ return {
10009
+ ...asked,
10010
+ approvalRequestId: asked.approvalRequestId ?? generateApprovalRequestId(input)
10011
+ };
10012
+ }
10013
+ const riskFlags = decisions.flatMap((d) => d.riskFlags);
10014
+ const source = decisions.findLast((d) => d.source !== "harness.policy")?.source ?? "harness.policy";
10015
+ return allow4("All policy checks passed", source, riskFlags);
9759
10016
  }
9760
- const refinement = refineClassification(base.clarificationPrompt ?? "", exploration, sessionHistory);
9761
- if (!refinement.clarificationStillNeeded) {
9762
- const resolvedType = base.taskType === "ambiguous" ? "feature" : base.taskType;
9763
- return {
9764
- ...base,
9765
- taskType: resolvedType,
9766
- stageSequence: buildStageSequence(resolvedType),
9767
- clarificationNeeded: false,
9768
- clarificationPrompt: undefined,
9769
- confidence: Math.max(base.confidence, 0.55)
9770
- };
10017
+ function evaluate4(input) {
10018
+ if (config.harness?.enabled === false) {
10019
+ const envDisabled = process.env.FLOWDECK_HARNESS_DISABLED === "1";
10020
+ if (!envDisabled) {
10021
+ if (appLog) {
10022
+ appLog("[harness-policy] SECURITY WARNING: harness.enabled=false without FLOWDECK_HARNESS_DISABLED=1. Enforcing minimal safety checks (loop detection, tool-guard dangerous patterns, orchestrator contract).");
10023
+ }
10024
+ const runtime2 = checkRuntimeLimits(input);
10025
+ if (runtime2.verdict === "deny") {
10026
+ appendAudit(input, runtime2);
10027
+ return runtime2;
10028
+ }
10029
+ if (config.governance?.toolGuard !== false) {
10030
+ const tg = evaluate(input);
10031
+ if (tg.verdict === "deny") {
10032
+ appendAudit(input, tg);
10033
+ return tg;
10034
+ }
10035
+ }
10036
+ const decision2 = allow4("Harness disabled — minimal safety checks passed", "harness.policy", ["harness-disabled-minimal"]);
10037
+ appendAudit(input, decision2);
10038
+ return decision2;
10039
+ }
10040
+ const decision = allow4("Harness disabled by config and env override", "harness.policy");
10041
+ appendAudit(input, decision);
10042
+ return decision;
10043
+ }
10044
+ const decisions = [];
10045
+ const runtime = checkRuntimeLimits(input);
10046
+ decisions.push(runtime);
10047
+ if (runtime.verdict === "deny") {
10048
+ appendAudit(input, runtime);
10049
+ return runtime;
10050
+ }
10051
+ const contract = checkAgentContract(input);
10052
+ decisions.push(contract);
10053
+ if (contract.verdict === "deny") {
10054
+ appendAudit(input, contract);
10055
+ return contract;
10056
+ }
10057
+ const governance = checkGovernance(input);
10058
+ decisions.push(governance);
10059
+ if (governance.verdict === "deny") {
10060
+ appendAudit(input, governance);
10061
+ return governance;
10062
+ }
10063
+ const supervisor = checkSupervisor(input, input.agent);
10064
+ decisions.push(supervisor);
10065
+ const final = combineDecisions(decisions, input);
10066
+ appendAudit(input, final);
10067
+ return final;
9771
10068
  }
9772
- const enrichedPrompt = refinement.supervisorContext ? `${base.clarificationPrompt ?? ""} (Context: ${refinement.supervisorContext})` : base.clarificationPrompt;
9773
10069
  return {
9774
- ...base,
9775
- clarificationPrompt: enrichedPrompt
10070
+ evaluate: evaluate4,
10071
+ checkAgentContract,
10072
+ checkRuntimeLimits,
10073
+ checkGovernance,
10074
+ checkSupervisor
9776
10075
  };
9777
10076
  }
9778
10077
 
10078
+ // src/services/harness-controller.ts
10079
+ import { randomUUID as randomUUID7 } from "crypto";
10080
+
9779
10081
  // src/services/run-trace.ts
9780
10082
  import { existsSync as existsSync26, readFileSync as readFileSync26, appendFileSync as appendFileSync7, writeFileSync as writeFileSync16, mkdirSync as mkdirSync16 } from "fs";
9781
10083
  import { join as join26 } from "path";
@@ -10468,28 +10770,33 @@ var DISABLED = process.env.FLOWDECK_ORCHESTRATOR_GUARD === "off";
10468
10770
  function normalizeToolName(name) {
10469
10771
  return name.toLowerCase().replace(/[-_]/g, "");
10470
10772
  }
10773
+ function buildRoutingOptions() {
10774
+ return getAllContracts().filter((c) => c.agent !== "orchestrator").map((c) => ` @${c.agent.padEnd(20)} — ${c.role}`).join(`
10775
+ `);
10776
+ }
10471
10777
  function blockMessage(toolName) {
10472
10778
  const contract = getContract("orchestrator");
10473
10779
  const allowed = contract?.allowedTools ?? [];
10474
- return `[Orchestrator Guard] The orchestrator cannot use \`${toolName}\` directly.
10475
-
10476
- ` + `The orchestrator is a coordinator, not an executor.
10477
-
10478
- ` + `Routing options:
10479
- ` + ` @default-executor — simple direct tasks (rename, typo fix, quick edit)
10480
- ` + ` @backend-coder — backend code writing and editing
10481
- ` + ` @frontend-coder — frontend code writing and editing
10482
- ` + ` @devops — CI/CD, deploy, and infrastructure changes
10483
- ` + ` @mapper — codebase mapping
10484
- ` + ` @researcher — focused research and file analysis
10485
- ` + ` @tester — tests, builds, and shell-heavy verification
10486
- ` + ` @writer — documentation writing
10487
-
10488
- ` + `Allowed tools for orchestrator: ${allowed.join(", ")}.
10489
-
10490
- ` + `To route execution, mention the agent directly: @default-executor, @backend-coder, etc.
10491
-
10492
- ` + `To disable this guard: set FLOWDECK_ORCHESTRATOR_GUARD=off`;
10780
+ return [
10781
+ `[Orchestrator Guard] \`${toolName}\` is not in the orchestrator's allowed tools.`,
10782
+ "",
10783
+ "The orchestrator coordinates — it does not execute directly.",
10784
+ "To route work, mention the agent inline with FULL task context:",
10785
+ "",
10786
+ " @backend-coder",
10787
+ " Task: <exact task description>",
10788
+ " Files: <file targets>",
10789
+ " Constraints: <constraints>",
10790
+ " Acceptance criteria: <what done looks like>",
10791
+ "",
10792
+ "Available agents:",
10793
+ buildRoutingOptions(),
10794
+ "",
10795
+ `Orchestrator allowed tools: ${allowed.filter((t) => t !== "delegate").join(", ")}.`,
10796
+ "",
10797
+ "To disable this guard: set FLOWDECK_ORCHESTRATOR_GUARD=off"
10798
+ ].join(`
10799
+ `);
10493
10800
  }
10494
10801
 
10495
10802
  class OrchestratorGuard {
@@ -10520,21 +10827,20 @@ class OrchestratorGuard {
10520
10827
  }
10521
10828
  check(sessionId, toolName) {
10522
10829
  if (DISABLED)
10523
- return;
10830
+ return { allowed: true };
10524
10831
  if (this.primarySessionId === null)
10525
- return;
10832
+ return { allowed: true };
10526
10833
  if (sessionId !== this.primarySessionId)
10527
- return;
10834
+ return { allowed: true };
10528
10835
  const contract = getContract("orchestrator");
10529
10836
  if (!contract) {
10530
- throw new Error(blockMessage(toolName));
10837
+ return { allowed: false, message: blockMessage(toolName) };
10531
10838
  }
10532
10839
  const normalizedTool = normalizeToolName(toolName);
10533
- const allowed = contract.allowedTools.some((t) => normalizeToolName(t) === normalizedTool);
10534
- if (allowed)
10535
- return;
10536
- if (this.policy) {}
10537
- throw new Error(blockMessage(toolName));
10840
+ const isAllowed = contract.allowedTools.some((t) => normalizeToolName(t) === normalizedTool);
10841
+ if (isAllowed)
10842
+ return { allowed: true };
10843
+ return { allowed: false, message: blockMessage(toolName) };
10538
10844
  }
10539
10845
  _isBlockedForTest(name) {
10540
10846
  const contract = getContract("orchestrator");
@@ -11022,6 +11328,7 @@ function createHarnessController(config) {
11022
11328
  const maxToolCalls = delegationConfig?.maxToolCalls ?? 200;
11023
11329
  const sessionDepthMap = new Map;
11024
11330
  const blockedSessions = new Set;
11331
+ const pendingDepthBlockNotifications = new Map;
11025
11332
  const state = {
11026
11333
  loopDetector,
11027
11334
  eventLog,
@@ -11059,7 +11366,8 @@ function createHarnessController(config) {
11059
11366
  projectRoot: directory,
11060
11367
  command,
11061
11368
  description,
11062
- currentStage
11369
+ currentStage,
11370
+ workflowDecision: route
11063
11371
  });
11064
11372
  const budget = assembledContext.tokenBudget;
11065
11373
  log(`[context-ingress] run=${sessionID} trivial=${assembledContext.isTrivialChat} ` + `tokens=${budget.usedTokens}/${budget.limitTokens} (${budget.component})`);
@@ -11162,16 +11470,14 @@ function createHarnessController(config) {
11162
11470
  });
11163
11471
  state.lastActiveSessionID = req.sessionID;
11164
11472
  const agent = req.agent ?? "orchestrator";
11165
- try {
11166
- orchestratorGuard.check(req.sessionID, req.tool);
11167
- } catch (error) {
11168
- const message = error instanceof Error ? error.message : String(error);
11473
+ const guardResult = orchestratorGuard.check(req.sessionID, req.tool);
11474
+ if (!guardResult.allowed) {
11169
11475
  return {
11170
11476
  verdict: "deny",
11171
- reason: message,
11477
+ reason: guardResult.message,
11172
11478
  riskFlags: ["orchestrator-contract"],
11173
11479
  source: "orchestrator-guard",
11174
- escalationMessage: message
11480
+ escalationMessage: guardResult.message
11175
11481
  };
11176
11482
  }
11177
11483
  executionSubstrate.start({
@@ -11236,6 +11542,9 @@ function createHarnessController(config) {
11236
11542
  if (childDepth > maxDepth) {
11237
11543
  log(`[harness] delegation depth ${childDepth} exceeds maxDepth ${maxDepth} — session ${sessionID} will be policy-blocked`);
11238
11544
  blockedSessions.add(sessionID);
11545
+ if (parentSessionID) {
11546
+ pendingDepthBlockNotifications.set(sessionID, parentSessionID);
11547
+ }
11239
11548
  }
11240
11549
  } else {
11241
11550
  sessionDepthMap.set(sessionID, 0);
@@ -11244,6 +11553,7 @@ function createHarnessController(config) {
11244
11553
  if ((type === "session.idle" || type === "session.error") && sessionID) {
11245
11554
  sessionDepthMap.delete(sessionID);
11246
11555
  blockedSessions.delete(sessionID);
11556
+ pendingDepthBlockNotifications.delete(sessionID);
11247
11557
  }
11248
11558
  orchestratorGuard.onEvent({ type, properties });
11249
11559
  if (type === "session.created" || type === "session.started") {
@@ -11288,6 +11598,14 @@ function createHarnessController(config) {
11288
11598
  },
11289
11599
  getContextMonitor() {
11290
11600
  return contextMonitor.get();
11601
+ },
11602
+ getPendingDepthBlockNotifications() {
11603
+ const notifications = [];
11604
+ for (const [sessionID, parentSessionID] of pendingDepthBlockNotifications) {
11605
+ notifications.push({ sessionID, parentSessionID });
11606
+ }
11607
+ pendingDepthBlockNotifications.clear();
11608
+ return notifications;
11291
11609
  }
11292
11610
  };
11293
11611
  }
@@ -11503,6 +11821,9 @@ var plugin = async (input, _options) => {
11503
11821
  type: event.type,
11504
11822
  properties: event.properties
11505
11823
  });
11824
+ for (const { sessionID: blockedSessionID, parentSessionID } of harness.getPendingDepthBlockNotifications()) {
11825
+ notify("FlowDeck: Delegation depth limit reached", `Session ${blockedSessionID} (parent ${parentSessionID}) exceeded max delegation depth. Stop further delegation from this subagent.`, "critical");
11826
+ }
11506
11827
  const type = event?.type ?? "";
11507
11828
  if (type === "command.executed") {
11508
11829
  const commandName = String(event?.properties?.name ?? "");
@@ -11533,6 +11854,11 @@ var plugin = async (input, _options) => {
11533
11854
  },
11534
11855
  "tool.execute.before": async (toolInput, toolOutput) => {
11535
11856
  const sessionID = toolInput.sessionID ?? "";
11857
+ harness.ensureRunContext({
11858
+ sessionID,
11859
+ description: `${toolInput.tool ?? toolInput.name ?? "unknown"} ${JSON.stringify(toolOutput?.args ?? toolInput?.args ?? {})} auto-invoked`,
11860
+ agent: toolInput.agent
11861
+ });
11536
11862
  const budgetCheck = harness.checkToolCallBudget(sessionID);
11537
11863
  if (!budgetCheck.allow) {
11538
11864
  throw new Error(budgetCheck.reason ?? `Tool blocked: ${budgetCheck.reason}`);