fifony 0.1.42 → 0.1.43

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.
Files changed (58) hide show
  1. package/app/dist/assets/{CommandPalette-DNR5umI1.js → CommandPalette-M4VAMxCU.js} +1 -1
  2. package/app/dist/assets/{KeyboardShortcutsHelp-Dpl19F20.js → KeyboardShortcutsHelp-DkvPUXQq.js} +1 -1
  3. package/app/dist/assets/OnboardingWizard-B7V9hoCR.js +1 -0
  4. package/app/dist/assets/analytics.lazy-zVJdF880.js +1 -0
  5. package/app/dist/assets/{api-ChEctgc5.js → api-CkVfYg_m.js} +1 -1
  6. package/app/dist/assets/{createLucideIcon-R47sXufx.js → createLucideIcon-Dfk_Hxud.js} +1 -1
  7. package/app/dist/assets/index-BpiCi7Ew.css +1 -0
  8. package/app/dist/assets/index-D2INW0zc.js +47 -0
  9. package/app/dist/assets/vendor-BEoYbFV1.js +9 -0
  10. package/app/dist/index.html +5 -5
  11. package/app/dist/service-worker.js +9 -4
  12. package/bin/fifony.js +3 -0
  13. package/dist/agent/pty-daemon.js +177 -0
  14. package/dist/agent/run-local.js +177 -43
  15. package/dist/{agent-NNGZEKZH.js → agent-RMQTTUEC.js} +37 -16
  16. package/dist/analytics-broadcaster-O6YBP66L.js +145 -0
  17. package/dist/chunk-3NE23NYW.js +82 -0
  18. package/dist/chunk-42AMQAJG.js +404 -0
  19. package/dist/{chunk-H5N7O5NP.js → chunk-AILXZ2TD.js} +79 -147
  20. package/dist/{chunk-I2UHVKHS.js → chunk-BRSR26VK.js} +2 -2
  21. package/dist/chunk-E2EWEYA4.js +1302 -0
  22. package/dist/chunk-ESWHDHH6.js +102 -0
  23. package/dist/{chunk-NB44PCD2.js → chunk-FJNH3G2Z.js} +1061 -1138
  24. package/dist/chunk-MVTGAKQK.js +493 -0
  25. package/dist/chunk-QQQLP3PL.js +155 -0
  26. package/dist/chunk-SOBLO4YZ.js +2016 -0
  27. package/dist/chunk-YRSH2CLW.js +13784 -0
  28. package/dist/cli.js +335 -44
  29. package/dist/{issue-state-machine-GPQNZYUZ.js → fsm-issue-YGGF7SIL.js} +9 -5
  30. package/dist/helpers-L7NYO5XS.js +53 -0
  31. package/dist/issue-log-broadcaster-WZAHISYB.js +84 -0
  32. package/dist/{issues-MZLRSXD6.js → issues-3QRR7KM6.js} +10 -8
  33. package/dist/log-analyzer-K7MXQB4T.js +287 -0
  34. package/dist/mcp/server.js +109 -137
  35. package/dist/parallel-executor-6INE6NDO.js +118 -0
  36. package/dist/pid-manager-UBWXVSMD.js +21 -0
  37. package/dist/queue-workers-XFZK3TT5.js +32 -0
  38. package/dist/replan-issue.command-4UCWYHGZ.js +15 -0
  39. package/dist/scheduler-ZP7GOZDW.js +26 -0
  40. package/dist/{settings-NGY33WQE.js → settings-ZAWDCFP2.js} +32 -8
  41. package/dist/settings.resource-5CW456AZ.js +24 -0
  42. package/dist/store-M6NCKMZY.js +97 -0
  43. package/dist/{web-push-CRVDJKWR.js → web-push-AX5IIK3P.js} +2 -2
  44. package/dist/{workspace-D3F3XGSI.js → workspace-CJTWFWTJ.js} +5 -4
  45. package/package.json +8 -7
  46. package/app/dist/assets/OnboardingWizard-CijMhJDW.js +0 -1
  47. package/app/dist/assets/analytics.lazy-Dq90a756.js +0 -1
  48. package/app/dist/assets/index-Dy_fM427.js +0 -54
  49. package/app/dist/assets/index-Q9jBP0Pz.css +0 -1
  50. package/app/dist/assets/vendor-DkWeBvNl.js +0 -9
  51. package/dist/chunk-2CVTK5F2.js +0 -288
  52. package/dist/chunk-37N5OFHM.js +0 -125
  53. package/dist/chunk-JTKUWIQD.js +0 -8406
  54. package/dist/chunk-RBDBGU2C.js +0 -303
  55. package/dist/issue-runner-CMZPSVC7.js +0 -16
  56. package/dist/queue-workers-XZ6DGH4W.js +0 -23
  57. package/dist/scheduler-NVE6L3P7.js +0 -22
  58. package/dist/store-4HCGBN4L.js +0 -65
@@ -1,48 +1,74 @@
1
- import {
2
- appendFileTail,
3
- idToSafePath,
4
- now,
5
- renderPrompt
6
- } from "./chunk-2CVTK5F2.js";
7
- import {
8
- SOURCE_MARKER,
9
- SOURCE_ROOT,
10
- TARGET_ROOT,
11
- WORKSPACE_ROOT
12
- } from "./chunk-37N5OFHM.js";
13
1
  import {
14
2
  logger
15
3
  } from "./chunk-DVU3CXWA.js";
16
-
17
- // src/domains/workspace.ts
18
4
  import {
19
- existsSync as existsSync7,
20
- mkdirSync,
21
- readdirSync,
22
- readFileSync as readFileSync4,
23
- rmSync as rmSync2,
24
- statSync,
25
- writeFileSync as writeFileSync2
26
- } from "fs";
27
- import { copyFile, mkdir, readdir, stat, writeFile } from "fs/promises";
28
- import { extname as extname2, join as join7, resolve } from "path";
29
- import { execSync } from "child_process";
30
-
31
- // src/agents/command-executor.ts
5
+ renderPrompt
6
+ } from "./chunk-ESWHDHH6.js";
32
7
  import {
33
- appendFileSync,
34
- rmSync,
35
- writeFileSync
36
- } from "fs";
37
- import { join as join6 } from "path";
38
- import { env as env2 } from "process";
39
- import { spawn } from "child_process";
8
+ sleep
9
+ } from "./chunk-42AMQAJG.js";
40
10
 
41
- // src/agents/providers.ts
42
- import { execFileSync as execFileSync2 } from "child_process";
43
- import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
44
- import { join as join5 } from "path";
45
- import { homedir as homedir2 } from "os";
11
+ // src/persistence/dirty-tracker.ts
12
+ var dirtyIssueIds = /* @__PURE__ */ new Set();
13
+ var dirtyMilestoneIds = /* @__PURE__ */ new Set();
14
+ var dirtyIssuePlanIds = /* @__PURE__ */ new Set();
15
+ var dirtyEventIds = /* @__PURE__ */ new Set();
16
+ function markIssueDirty(id) {
17
+ dirtyIssueIds.add(id);
18
+ }
19
+ function markMilestoneDirty(id) {
20
+ dirtyMilestoneIds.add(id);
21
+ }
22
+ function markIssuePlanDirty(id) {
23
+ dirtyIssuePlanIds.add(id);
24
+ }
25
+ function markEventDirty(id) {
26
+ dirtyEventIds.add(id);
27
+ }
28
+ function hasDirtyState() {
29
+ return dirtyIssueIds.size > 0 || dirtyMilestoneIds.size > 0 || dirtyEventIds.size > 0;
30
+ }
31
+ function getDirtyIssueIds() {
32
+ return dirtyIssueIds;
33
+ }
34
+ function getDirtyMilestoneIds() {
35
+ return dirtyMilestoneIds;
36
+ }
37
+ function getDirtyEventIds() {
38
+ return dirtyEventIds;
39
+ }
40
+ function snapshotAndClearDirtyIssueIds() {
41
+ const snapshot = new Set(dirtyIssueIds);
42
+ for (const id of snapshot) dirtyIssueIds.delete(id);
43
+ return snapshot;
44
+ }
45
+ function snapshotAndClearDirtyMilestoneIds() {
46
+ const snapshot = new Set(dirtyMilestoneIds);
47
+ for (const id of snapshot) dirtyMilestoneIds.delete(id);
48
+ return snapshot;
49
+ }
50
+ function snapshotAndClearDirtyIssuePlanIds() {
51
+ const snapshot = new Set(dirtyIssuePlanIds);
52
+ for (const id of snapshot) dirtyIssuePlanIds.delete(id);
53
+ return snapshot;
54
+ }
55
+ function snapshotAndClearDirtyEventIds() {
56
+ const snapshot = new Set(dirtyEventIds);
57
+ for (const id of snapshot) dirtyEventIds.delete(id);
58
+ return snapshot;
59
+ }
60
+ function markAllIssuesDirty(ids) {
61
+ for (const id of ids) dirtyIssueIds.add(id);
62
+ }
63
+ function markAllMilestonesDirty(ids) {
64
+ for (const id of ids) dirtyMilestoneIds.add(id);
65
+ }
66
+ function markAllIssuePlansDirty(ids) {
67
+ for (const id of ids) dirtyIssuePlanIds.add(id);
68
+ }
69
+ function markAllEventsDirty(ids) {
70
+ for (const id of ids) dirtyEventIds.add(id);
71
+ }
46
72
 
47
73
  // src/agents/adapters/claude.ts
48
74
  import { existsSync as existsSync2 } from "fs";
@@ -52,7 +78,7 @@ import { join } from "path";
52
78
  import { existsSync, readFileSync } from "fs";
53
79
  import { basename, extname } from "path";
54
80
  function buildPlanContextSection(plan) {
55
- const parts = ["## Plan Context", "", `**Summary:** ${plan.summary}`];
81
+ const parts = ["## Plan Context", "", `**Summary:** ${plan.summary}`, `**Harness mode:** ${plan.harnessMode}`];
56
82
  if (plan.assumptions?.length) {
57
83
  parts.push("", "**Assumptions:**");
58
84
  plan.assumptions.forEach((a) => parts.push(`- ${a}`));
@@ -105,11 +131,43 @@ function buildRiskSection(plan) {
105
131
  }
106
132
  return parts.join("\n");
107
133
  }
134
+ function normalizeAcceptanceCriteria(plan) {
135
+ return plan.acceptanceCriteria.map((criterion, index) => ({
136
+ id: criterion.id || `AC-${index + 1}`,
137
+ description: criterion.description,
138
+ category: criterion.category,
139
+ verificationMethod: criterion.verificationMethod,
140
+ evidenceExpected: criterion.evidenceExpected,
141
+ blocking: criterion.blocking,
142
+ weight: criterion.weight
143
+ }));
144
+ }
145
+ function deriveExecutionContract(plan) {
146
+ const ec = plan.executionContract;
147
+ return {
148
+ summary: ec.summary,
149
+ deliverables: Array.isArray(ec.deliverables) ? ec.deliverables.slice() : [],
150
+ requiredChecks: Array.isArray(ec.requiredChecks) ? ec.requiredChecks.slice() : [],
151
+ requiredEvidence: Array.isArray(ec.requiredEvidence) ? ec.requiredEvidence.slice() : [],
152
+ focusAreas: Array.isArray(ec.focusAreas) ? ec.focusAreas.slice() : [],
153
+ checkpointPolicy: ec.checkpointPolicy === "checkpointed" ? "checkpointed" : "final_only",
154
+ blueprintId: ec.blueprintId,
155
+ delegationPolicy: ec.delegationPolicy,
156
+ budgetPolicy: ec.budgetPolicy
157
+ };
158
+ }
108
159
  function buildValidationSection(plan) {
109
160
  const parts = [];
110
- if (plan.successCriteria?.length) {
111
- parts.push("## Success Criteria");
112
- plan.successCriteria.forEach((c) => parts.push(`- ${c}`));
161
+ const acceptanceCriteria = normalizeAcceptanceCriteria(plan);
162
+ const executionContract = deriveExecutionContract(plan);
163
+ if (acceptanceCriteria.length) {
164
+ parts.push("## Acceptance Criteria");
165
+ acceptanceCriteria.forEach((criterion) => {
166
+ parts.push(`- **${criterion.id}** [${criterion.category}]${criterion.blocking ? " blocking" : " advisory"} \u2014 ${criterion.description}`);
167
+ parts.push(` Verify via: ${criterion.verificationMethod}`);
168
+ parts.push(` Evidence expected: ${criterion.evidenceExpected}`);
169
+ parts.push(` Weight: ${criterion.weight}`);
170
+ });
113
171
  }
114
172
  if (plan.validation?.length) {
115
173
  parts.push("", "## Validation Checks");
@@ -120,6 +178,25 @@ function buildValidationSection(plan) {
120
178
  parts.push("", "## Deliverables");
121
179
  plan.deliverables.forEach((d) => parts.push(`- ${d}`));
122
180
  }
181
+ parts.push("", "## Execution Contract");
182
+ parts.push(`Summary: ${executionContract.summary}`);
183
+ parts.push(`Checkpoint policy: ${executionContract.checkpointPolicy}`);
184
+ if (executionContract.blueprintId) parts.push(`Blueprint: ${executionContract.blueprintId}`);
185
+ if (executionContract.focusAreas.length) parts.push(`Focus areas: ${executionContract.focusAreas.join(", ")}`);
186
+ if (executionContract.delegationPolicy) {
187
+ parts.push(`Delegation policy: ${executionContract.delegationPolicy.mode} (max fanout ${executionContract.delegationPolicy.maxFanout})`);
188
+ }
189
+ if (executionContract.budgetPolicy) {
190
+ parts.push(`Budget policy: local retries=${executionContract.budgetPolicy.maxLocalRetries}, remote rounds=${executionContract.budgetPolicy.maxRemoteRounds}, wall clock=${executionContract.budgetPolicy.maxWallClockMinutes}m`);
191
+ }
192
+ if (executionContract.requiredChecks.length) {
193
+ parts.push("Required checks:");
194
+ executionContract.requiredChecks.forEach((check) => parts.push(`- ${check}`));
195
+ }
196
+ if (executionContract.requiredEvidence.length) {
197
+ parts.push("Required evidence:");
198
+ executionContract.requiredEvidence.forEach((evidence) => parts.push(`- ${evidence}`));
199
+ }
123
200
  return parts.join("\n");
124
201
  }
125
202
  function buildToolingSection(plan) {
@@ -207,6 +284,8 @@ function buildImagePromptSection(imagePaths) {
207
284
  function buildExecutionPayload(issue, provider, plan, workspacePath) {
208
285
  const strategy = plan.executionStrategy;
209
286
  const hasPhases = Boolean(plan.phases?.length);
287
+ const acceptanceCriteria = normalizeAcceptanceCriteria(plan);
288
+ const executionContract = deriveExecutionContract(plan);
210
289
  return {
211
290
  version: 1,
212
291
  issue: {
@@ -226,6 +305,7 @@ function buildExecutionPayload(issue, provider, plan, workspacePath) {
226
305
  },
227
306
  executionIntent: {
228
307
  complexity: plan.estimatedComplexity,
308
+ harnessMode: plan.harnessMode,
229
309
  approach: strategy?.approach || "",
230
310
  rationale: strategy?.whyThisApproach || "",
231
311
  workPattern: hasPhases ? "phased" : "sequential"
@@ -248,9 +328,10 @@ function buildExecutionPayload(issue, provider, plan, workspacePath) {
248
328
  }))
249
329
  },
250
330
  constraints: plan.constraints || [],
251
- successCriteria: plan.successCriteria || [],
331
+ acceptanceCriteria,
252
332
  validation: plan.validation || [],
253
333
  deliverables: plan.deliverables || [],
334
+ executionContract,
254
335
  assumptions: plan.assumptions || [],
255
336
  unknowns: (plan.unknowns || []).map((u) => ({
256
337
  question: u.question,
@@ -322,9 +403,6 @@ function extractPlanDirs(plan) {
322
403
 
323
404
  // src/agents/adapters/usage.ts
324
405
  import { cwd, env } from "process";
325
- function sleep(ms) {
326
- return new Promise((resolve2) => setTimeout(resolve2, ms));
327
- }
328
406
  async function createPtyProcess(command, args) {
329
407
  try {
330
408
  const nodePty = await import("node-pty");
@@ -415,7 +493,7 @@ function parseResetTime(value) {
415
493
  function parseResetDateFromText(raw) {
416
494
  const text = normalizeResetText(raw);
417
495
  if (!text) return null;
418
- const now2 = /* @__PURE__ */ new Date();
496
+ const now = /* @__PURE__ */ new Date();
419
497
  const explicitDateMatch = text.match(/([A-Za-z]{3,9})\s*(\d{1,2})(?:,?|\s+)(\d{1,2}:\d{2}(?:\s*[AaPp][Mm])?)/i) || text.match(/(\d{1,2})\s+([A-Za-z]{3,9})(?:,\s*)?(\d{1,2}:\d{2}(?:\s*[AaPp][Mm])?)/i);
420
498
  if (explicitDateMatch) {
421
499
  const [, monthRaw, dayRaw, timeRaw] = explicitDateMatch;
@@ -423,12 +501,12 @@ function parseResetDateFromText(raw) {
423
501
  const monthIndex = toMonthIndex(monthRaw);
424
502
  const time2 = parseResetTime(timeRaw);
425
503
  if (!Number.isNaN(day) && monthIndex !== null && time2) {
426
- const candidate = new Date(now2);
504
+ const candidate = new Date(now);
427
505
  candidate.setMonth(monthIndex);
428
506
  candidate.setDate(day);
429
507
  candidate.setHours(time2.hour, time2.minute, 0, 0);
430
- if (candidate.getTime() <= now2.getTime()) {
431
- candidate.setFullYear(now2.getFullYear() + 1);
508
+ if (candidate.getTime() <= now.getTime()) {
509
+ candidate.setFullYear(now.getFullYear() + 1);
432
510
  }
433
511
  return candidate.toISOString();
434
512
  }
@@ -436,9 +514,9 @@ function parseResetDateFromText(raw) {
436
514
  const timeMatch = text.match(/(\d{1,2}:\d{2}(?:\s*[AaPp][Mm])?)/i) || text.match(/(\d{1,2})(?:\s*([AaPp][Mm]))/i);
437
515
  const time = timeMatch?.[1] ? parseResetTime(timeMatch[1]) : null;
438
516
  if (time) {
439
- const candidate = new Date(now2);
517
+ const candidate = new Date(now);
440
518
  candidate.setHours(time.hour, time.minute, 0, 0);
441
- if (candidate.getTime() <= now2.getTime()) {
519
+ if (candidate.getTime() <= now.getTime()) {
442
520
  candidate.setDate(candidate.getDate() + 1);
443
521
  }
444
522
  return candidate.toISOString();
@@ -476,10 +554,10 @@ function initSnapshot(raw) {
476
554
  }
477
555
  function waitForOutput(getOutput, pattern, timeoutMs, pollMs = 100) {
478
556
  const deadline = Date.now() + timeoutMs;
479
- return new Promise((resolve2) => {
557
+ return new Promise((resolve) => {
480
558
  const check = () => {
481
- if (pattern.test(getOutput())) return resolve2(true);
482
- if (Date.now() >= deadline) return resolve2(false);
559
+ if (pattern.test(getOutput())) return resolve(true);
560
+ if (Date.now() >= deadline) return resolve(false);
483
561
  setTimeout(check, pollMs);
484
562
  };
485
563
  check();
@@ -552,7 +630,6 @@ function parseClaudeUsageFromStatus(raw) {
552
630
  const lines = raw.split(/[\r\n]+/).map((line) => line.trim()).filter(Boolean);
553
631
  let currentHeading = null;
554
632
  let lastPercentSection = null;
555
- let lastPercentUsed = null;
556
633
  for (const line of lines) {
557
634
  const normalized = line.toLowerCase();
558
635
  if (/^esc to cancel/i.test(normalized)) continue;
@@ -584,7 +661,6 @@ function parseClaudeUsageFromStatus(raw) {
584
661
  if (percentMatch?.[1] && currentHeading) {
585
662
  const used = parseInt(percentMatch[1], 10);
586
663
  lastPercentSection = currentHeading;
587
- lastPercentUsed = used;
588
664
  if (currentHeading.section === "current-week-all") {
589
665
  base.weeklyPercentUsed = keepLargest(base.weeklyPercentUsed, used);
590
666
  }
@@ -613,7 +689,6 @@ function parseClaudeUsageFromStatus(raw) {
613
689
  last.nextResetAt = nextResetAt;
614
690
  }
615
691
  lastPercentSection = null;
616
- lastPercentUsed = null;
617
692
  }
618
693
  }
619
694
  const allModelsLine = lines.find((line) => parseClaudeUsageHeading(line)?.section === "current-week-all");
@@ -839,6 +914,16 @@ function collectProviderUsageSnapshotFromCli(command, usageCommand, parseSnapsho
839
914
  }
840
915
 
841
916
  // src/agents/adapters/claude.ts
917
+ var CLAUDE_CAPABILITIES = {
918
+ readOnlyExecution: "plan",
919
+ structuredOutput: {
920
+ mode: "json-schema",
921
+ requiresToolDisable: true
922
+ },
923
+ imageInput: "prompt-inline",
924
+ usageReporting: "cli-command",
925
+ nativeSubagents: "native"
926
+ };
842
927
  var CLAUDE_USAGE_COMMAND = "/usage";
843
928
  var collectClaudeUsageFromCli = () => collectProviderUsageSnapshotFromCli("claude", CLAUDE_USAGE_COMMAND, parseClaudeUsageFromStatus, [
844
929
  "--dangerously-skip-permissions"
@@ -852,7 +937,8 @@ function buildClaudeCommand(options) {
852
937
  }
853
938
  parts.push("--no-session-persistence", "--output-format json");
854
939
  if (options.effort) {
855
- parts.push(`--effort ${options.effort}`);
940
+ const claudeEffort = options.effort === "extra-high" ? "max" : options.effort;
941
+ parts.push(`--effort ${claudeEffort}`);
856
942
  }
857
943
  if (options.maxBudgetUsd && options.maxBudgetUsd > 0) {
858
944
  parts.push(`--max-budget-usd ${options.maxBudgetUsd}`);
@@ -882,6 +968,7 @@ async function compile(issue, provider, plan, config, workspacePath, skillContex
882
968
  planPrompt: buildFullPlanPrompt(plan),
883
969
  suggestedSkills: plan.suggestedSkills ?? [],
884
970
  suggestedAgents: plan.suggestedAgents ?? [],
971
+ hasNativeSubagents: CLAUDE_CAPABILITIES.nativeSubagents === "native",
885
972
  suggestedPaths: plan.suggestedPaths ?? [],
886
973
  workspacePath,
887
974
  issueIdentifier: issue.identifier,
@@ -905,20 +992,20 @@ async function compile(issue, provider, plan, config, workspacePath, skillContex
905
992
  readOnly: isReadOnlyRole,
906
993
  maxBudgetUsd: config.maxBudgetUsd
907
994
  });
908
- const env3 = {
995
+ const env2 = {
909
996
  FIFONY_PLAN_COMPLEXITY: plan.estimatedComplexity,
910
997
  FIFONY_PLAN_STEPS: String(plan.steps.length),
911
998
  FIFONY_EXECUTION_PAYLOAD_FILE: "execution-payload.json"
912
999
  };
913
- if (plan.suggestedPaths?.length) env3.FIFONY_PLAN_PATHS = plan.suggestedPaths.join(",");
1000
+ if (plan.suggestedPaths?.length) env2.FIFONY_PLAN_PATHS = plan.suggestedPaths.join(",");
914
1001
  if (plan.suggestedSkills?.length) {
915
- env3.FIFONY_PLAN_SKILLS = plan.suggestedSkills.join(",");
1002
+ env2.FIFONY_PLAN_SKILLS = plan.suggestedSkills.join(",");
916
1003
  }
917
1004
  const { pre, post } = extractValidationCommands(plan);
918
1005
  return {
919
1006
  prompt,
920
1007
  command,
921
- env: env3,
1008
+ env: env2,
922
1009
  preHooks: pre,
923
1010
  postHooks: post,
924
1011
  outputSchema: CLAUDE_RESULT_SCHEMA,
@@ -927,6 +1014,7 @@ async function compile(issue, provider, plan, config, workspacePath, skillContex
927
1014
  adapter: "claude",
928
1015
  reasoningEffort: effort || "default",
929
1016
  model: provider.model || "default",
1017
+ providerCapabilities: CLAUDE_CAPABILITIES,
930
1018
  skillsActivated: plan.suggestedSkills || [],
931
1019
  subagentsRequested: plan.suggestedAgents || [],
932
1020
  phasesCount: plan.phases?.length || 0
@@ -934,6 +1022,7 @@ async function compile(issue, provider, plan, config, workspacePath, skillContex
934
1022
  };
935
1023
  }
936
1024
  var claudeAdapter = {
1025
+ capabilities: CLAUDE_CAPABILITIES,
937
1026
  buildCommand: buildClaudeCommand,
938
1027
  buildReviewCommand: (reviewer, config) => buildClaudeCommand({
939
1028
  model: reviewer.model,
@@ -948,6 +1037,16 @@ var claudeAdapter = {
948
1037
  // src/agents/adapters/codex.ts
949
1038
  import { existsSync as existsSync3 } from "fs";
950
1039
  import { join as join2 } from "path";
1040
+ var CODEX_CAPABILITIES = {
1041
+ readOnlyExecution: "none",
1042
+ structuredOutput: {
1043
+ mode: "prompt-contract",
1044
+ requiresToolDisable: false
1045
+ },
1046
+ imageInput: "cli-flag",
1047
+ usageReporting: "cli-command",
1048
+ nativeSubagents: "runtime-only"
1049
+ };
951
1050
  var CODEX_USAGE_COMMAND = "/status";
952
1051
  var collectCodexUsageFromCli = () => collectProviderUsageSnapshotFromCli("codex", CODEX_USAGE_COMMAND, parseCodexUsageFromStatus, [
953
1052
  "--dangerously-bypass-approvals-and-sandbox"
@@ -1016,6 +1115,8 @@ async function compile2(issue, provider, plan, config, workspacePath, skillConte
1016
1115
  goal: phase.goal,
1017
1116
  outputs: phase.outputs ?? []
1018
1117
  })),
1118
+ suggestedAgents: plan.suggestedAgents ?? [],
1119
+ hasNativeSubagents: CODEX_CAPABILITIES.nativeSubagents === "native",
1019
1120
  suggestedPaths: plan.suggestedPaths ?? [],
1020
1121
  suggestedSkills: plan.suggestedSkills ?? [],
1021
1122
  validationItems: (plan.validation ?? []).map((value) => ({ value })),
@@ -1030,18 +1131,18 @@ async function compile2(issue, provider, plan, config, workspacePath, skillConte
1030
1131
  effort,
1031
1132
  imagePaths: issue.images?.filter((p) => existsSync3(p))
1032
1133
  });
1033
- const env3 = {
1134
+ const env2 = {
1034
1135
  FIFONY_PLAN_COMPLEXITY: plan.estimatedComplexity,
1035
1136
  FIFONY_PLAN_STEPS: String(plan.steps.length),
1036
1137
  FIFONY_PLAN_PHASES: String(plan.phases?.length || 0),
1037
1138
  FIFONY_EXECUTION_PAYLOAD_FILE: "execution-payload.json"
1038
1139
  };
1039
- if (plan.suggestedPaths?.length) env3.FIFONY_PLAN_PATHS = plan.suggestedPaths.join(",");
1140
+ if (plan.suggestedPaths?.length) env2.FIFONY_PLAN_PATHS = plan.suggestedPaths.join(",");
1040
1141
  const { pre, post } = extractValidationCommands(plan);
1041
1142
  return {
1042
1143
  prompt,
1043
1144
  command,
1044
- env: env3,
1145
+ env: env2,
1045
1146
  preHooks: pre,
1046
1147
  postHooks: post,
1047
1148
  outputSchema: "",
@@ -1050,13 +1151,15 @@ async function compile2(issue, provider, plan, config, workspacePath, skillConte
1050
1151
  adapter: "codex",
1051
1152
  reasoningEffort: effort || "default",
1052
1153
  model: provider.model || "default",
1154
+ providerCapabilities: CODEX_CAPABILITIES,
1053
1155
  skillsActivated: plan.suggestedSkills || [],
1054
- subagentsRequested: [],
1156
+ subagentsRequested: plan.suggestedAgents || [],
1055
1157
  phasesCount: plan.phases?.length || 0
1056
1158
  }
1057
1159
  };
1058
1160
  }
1059
1161
  var codexAdapter = {
1162
+ capabilities: CODEX_CAPABILITIES,
1060
1163
  buildCommand: buildCodexCommand,
1061
1164
  buildReviewCommand: (reviewer, _config) => buildCodexCommand({
1062
1165
  model: reviewer.model,
@@ -1069,6 +1172,16 @@ var codexAdapter = {
1069
1172
  // src/agents/adapters/gemini.ts
1070
1173
  import { existsSync as existsSync4 } from "fs";
1071
1174
  import { join as join3 } from "path";
1175
+ var GEMINI_CAPABILITIES = {
1176
+ readOnlyExecution: "approval",
1177
+ structuredOutput: {
1178
+ mode: "prompt-contract",
1179
+ requiresToolDisable: false
1180
+ },
1181
+ imageInput: "prompt-inline",
1182
+ usageReporting: "cli-command",
1183
+ nativeSubagents: "runtime-only"
1184
+ };
1072
1185
  var GEMINI_USAGE_COMMAND = "/stats session";
1073
1186
  var collectGeminiUsageFromCli = () => collectProviderUsageSnapshotFromCli("gemini", GEMINI_USAGE_COMMAND, parseGeminiUsageFromStatus);
1074
1187
  var GEMINI_RESULT_CONTRACT = `
@@ -1124,6 +1237,8 @@ async function compile3(issue, provider, plan, config, workspacePath, skillConte
1124
1237
  goal: phase.goal,
1125
1238
  outputs: phase.outputs ?? []
1126
1239
  })),
1240
+ suggestedAgents: plan.suggestedAgents ?? [],
1241
+ hasNativeSubagents: GEMINI_CAPABILITIES.nativeSubagents === "native",
1127
1242
  suggestedPaths: plan.suggestedPaths ?? [],
1128
1243
  suggestedSkills: plan.suggestedSkills ?? [],
1129
1244
  validationItems: (plan.validation ?? []).map((value) => ({ value })),
@@ -1142,18 +1257,18 @@ async function compile3(issue, provider, plan, config, workspacePath, skillConte
1142
1257
  addDirs: absoluteDirs,
1143
1258
  readOnly: isReadOnlyRole
1144
1259
  });
1145
- const env3 = {
1260
+ const env2 = {
1146
1261
  FIFONY_PLAN_COMPLEXITY: plan.estimatedComplexity,
1147
1262
  FIFONY_PLAN_STEPS: String(plan.steps.length),
1148
1263
  FIFONY_PLAN_PHASES: String(plan.phases?.length || 0),
1149
1264
  FIFONY_EXECUTION_PAYLOAD_FILE: "execution-payload.json"
1150
1265
  };
1151
- if (plan.suggestedPaths?.length) env3.FIFONY_PLAN_PATHS = plan.suggestedPaths.join(",");
1266
+ if (plan.suggestedPaths?.length) env2.FIFONY_PLAN_PATHS = plan.suggestedPaths.join(",");
1152
1267
  const { pre, post } = extractValidationCommands(plan);
1153
1268
  return {
1154
1269
  prompt,
1155
1270
  command,
1156
- env: env3,
1271
+ env: env2,
1157
1272
  preHooks: pre,
1158
1273
  postHooks: post,
1159
1274
  outputSchema: "",
@@ -1162,13 +1277,15 @@ async function compile3(issue, provider, plan, config, workspacePath, skillConte
1162
1277
  adapter: "gemini",
1163
1278
  reasoningEffort: effort || "default",
1164
1279
  model: provider.model || "default",
1280
+ providerCapabilities: GEMINI_CAPABILITIES,
1165
1281
  skillsActivated: plan.suggestedSkills || [],
1166
- subagentsRequested: [],
1282
+ subagentsRequested: plan.suggestedAgents || [],
1167
1283
  phasesCount: plan.phases?.length || 0
1168
1284
  }
1169
1285
  };
1170
1286
  }
1171
1287
  var geminiAdapter = {
1288
+ capabilities: GEMINI_CAPABILITIES,
1172
1289
  buildCommand: buildGeminiCommand,
1173
1290
  buildReviewCommand: (reviewer) => buildGeminiCommand({
1174
1291
  model: reviewer.model,
@@ -1183,6 +1300,700 @@ var ADAPTERS = {
1183
1300
  codex: codexAdapter,
1184
1301
  gemini: geminiAdapter
1185
1302
  };
1303
+ var UNSUPPORTED_CAPABILITIES = {
1304
+ readOnlyExecution: "none",
1305
+ structuredOutput: {
1306
+ mode: "none",
1307
+ requiresToolDisable: false
1308
+ },
1309
+ imageInput: "none",
1310
+ usageReporting: "none",
1311
+ nativeSubagents: "runtime-only"
1312
+ };
1313
+ function getProviderCapabilities(provider, overrides) {
1314
+ if (overrides) return overrides;
1315
+ return ADAPTERS[provider]?.capabilities ?? UNSUPPORTED_CAPABILITIES;
1316
+ }
1317
+ function supportsReadOnlyExecution(capabilities) {
1318
+ return capabilities.readOnlyExecution !== "none";
1319
+ }
1320
+ function usesNativeStructuredOutput(capabilities) {
1321
+ return capabilities.structuredOutput.mode === "json-schema";
1322
+ }
1323
+ function supportsNativeSubagents(capabilities) {
1324
+ return capabilities.nativeSubagents === "native";
1325
+ }
1326
+ function describeProviderCapabilityWarnings(provider, capabilities) {
1327
+ const warnings = [];
1328
+ if (!supportsReadOnlyExecution(capabilities)) {
1329
+ warnings.push(`${provider} does not expose CLI-enforced read-only execution; planner/reviewer runs fall back to prompt/runtime discipline.`);
1330
+ }
1331
+ if (!usesNativeStructuredOutput(capabilities)) {
1332
+ warnings.push(`${provider} does not expose native JSON schema enforcement; structured output falls back to prompt-contract parsing.`);
1333
+ }
1334
+ if (!supportsNativeSubagents(capabilities)) {
1335
+ warnings.push(`${provider} does not expose native subagents; delegation will use Fifony runtime orchestration instead.`);
1336
+ }
1337
+ if (capabilities.usageReporting === "none") {
1338
+ warnings.push(`${provider} does not expose usage reporting; provider budget telemetry may be incomplete.`);
1339
+ }
1340
+ return warnings;
1341
+ }
1342
+
1343
+ // src/agents/providers.ts
1344
+ import { execFileSync as execFileSync2 } from "child_process";
1345
+ import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
1346
+ import { join as join5 } from "path";
1347
+ import { homedir as homedir2 } from "os";
1348
+
1349
+ // src/agents/review-profile.ts
1350
+ var UI_EXTENSIONS = [".jsx", ".tsx", ".css", ".scss", ".vue", ".svelte", ".html"];
1351
+ function collectCandidatePaths(issue) {
1352
+ const planPaths = issue.plan?.suggestedPaths ?? [];
1353
+ const issuePaths = issue.paths ?? [];
1354
+ const contractAreas = issue.plan?.executionContract?.focusAreas ?? [];
1355
+ return [.../* @__PURE__ */ new Set([...issuePaths, ...planPaths, ...contractAreas])];
1356
+ }
1357
+ function collectCandidateText(issue) {
1358
+ return [
1359
+ issue.title,
1360
+ issue.description,
1361
+ issue.issueType,
1362
+ ...issue.labels ?? [],
1363
+ ...issue.plan?.suggestedPaths ?? []
1364
+ ].filter(Boolean).join(" ").toLowerCase();
1365
+ }
1366
+ function hasPath(paths, pattern) {
1367
+ return paths.some((path) => pattern.test(path));
1368
+ }
1369
+ function hasCategory(criteria, category) {
1370
+ return criteria.some((criterion) => criterion.category === category);
1371
+ }
1372
+ function includesAny(text, terms) {
1373
+ return terms.some((term) => text.includes(term));
1374
+ }
1375
+ function buildFocusAreas(paths, fallback) {
1376
+ const combined = [...paths, ...fallback].filter(Boolean);
1377
+ return [...new Set(combined)].slice(0, 6);
1378
+ }
1379
+ function deriveReviewProfile(issue) {
1380
+ const paths = collectCandidatePaths(issue);
1381
+ const text = collectCandidateText(issue);
1382
+ const criteria = issue.plan?.acceptanceCriteria ?? [];
1383
+ const scores = [
1384
+ { name: "general-quality", score: 1, rationale: ["Fallback profile for broad correctness, regression risk, and code quality review."] },
1385
+ { name: "ui-polish", score: 0, rationale: [] },
1386
+ { name: "workflow-fsm", score: 0, rationale: [] },
1387
+ { name: "integration-safety", score: 0, rationale: [] },
1388
+ { name: "api-contract", score: 0, rationale: [] },
1389
+ { name: "security-hardening", score: 0, rationale: [] }
1390
+ ];
1391
+ const uiScore = scores.find((entry) => entry.name === "ui-polish");
1392
+ if (paths.some((path) => UI_EXTENSIONS.some((ext) => path.endsWith(ext)))) {
1393
+ uiScore.score += 4;
1394
+ uiScore.rationale.push("Touched frontend files that can regress visual polish, interaction flow, or responsiveness.");
1395
+ }
1396
+ if (hasCategory(criteria, "design") || includesAny(text, ["frontend", "ui", "ux", "drawer", "onboarding", "layout", "mobile"])) {
1397
+ uiScore.score += 3;
1398
+ uiScore.rationale.push("Issue signals UI/UX work that needs stronger product-behavior and visual scrutiny.");
1399
+ }
1400
+ const workflowScore = scores.find((entry) => entry.name === "workflow-fsm");
1401
+ if (hasPath(paths, /src\/persistence\/plugins\/fsm-|src\/commands\/|src\/domains\/issues\.ts|src\/agents\//)) {
1402
+ workflowScore.score += 5;
1403
+ workflowScore.rationale.push("Touched workflow/FSM/orchestration code where lifecycle invariants and retry semantics are fragile.");
1404
+ }
1405
+ if (includesAny(text, ["fsm", "workflow", "queue", "review gate", "lifecycle", "orchestration", "agent"])) {
1406
+ workflowScore.score += 3;
1407
+ workflowScore.rationale.push("Issue description or labels indicate orchestration semantics rather than isolated implementation.");
1408
+ }
1409
+ const integrationScore = scores.find((entry) => entry.name === "integration-safety");
1410
+ if (hasPath(paths, /workspace|merge|push|rebase|git|dirty-tracker|services?|store\.ts/)) {
1411
+ integrationScore.score += 5;
1412
+ integrationScore.rationale.push("Touched integration or git/workspace code where destructive behavior and state drift must be caught.");
1413
+ }
1414
+ if (hasCategory(criteria, "integration") || hasCategory(criteria, "regression")) {
1415
+ integrationScore.score += 2;
1416
+ integrationScore.rationale.push("Acceptance criteria explicitly call out integration or regression guarantees.");
1417
+ }
1418
+ const apiScore = scores.find((entry) => entry.name === "api-contract");
1419
+ if (hasPath(paths, /src\/routes\/|src\/persistence\/resources\/|src\/mcp\//)) {
1420
+ apiScore.score += 4;
1421
+ apiScore.rationale.push("Touched API/resource surface that can drift from contract or persistence schema.");
1422
+ }
1423
+ if (includesAny(text, ["api", "route", "http", "endpoint", "resource", "schema"])) {
1424
+ apiScore.score += 2;
1425
+ apiScore.rationale.push("Issue language implies request/response or schema contract changes.");
1426
+ }
1427
+ const securityScore = scores.find((entry) => entry.name === "security-hardening");
1428
+ if (hasCategory(criteria, "security") || includesAny(text, ["auth", "security", "token", "permission", "secret"])) {
1429
+ securityScore.score += 5;
1430
+ securityScore.rationale.push("Security-sensitive behavior or criteria are present and should be treated as blocking by default.");
1431
+ }
1432
+ if (hasPath(paths, /auth|permission|secret|credential|shell|command-executor/)) {
1433
+ securityScore.score += 3;
1434
+ securityScore.rationale.push("Touched code paths that can introduce auth, privilege, or command-execution risk.");
1435
+ }
1436
+ const ranked = [...scores].sort((a, b) => b.score - a.score);
1437
+ const primary = ranked[0];
1438
+ const secondary = ranked.filter((entry) => entry.name !== primary.name && entry.score >= 3).slice(0, 2).map((entry) => entry.name);
1439
+ const byName = {
1440
+ "general-quality": {
1441
+ focusAreas: buildFocusAreas(paths, ["Correctness under real usage", "Regression risk", "Code quality and maintainability"]),
1442
+ failureModes: [
1443
+ "Partial implementations that look complete but leave core behavior stubbed",
1444
+ "Missing validation, tests, or evidence for blocking criteria",
1445
+ "Code that technically works but introduces obvious maintainability debt"
1446
+ ],
1447
+ evidencePriorities: [
1448
+ "Run or inspect the most relevant validation commands",
1449
+ "Trace the dominant code path end to end",
1450
+ "Call out unverified assumptions explicitly instead of hand-waving them"
1451
+ ],
1452
+ severityBias: "Bias toward FAIL when behavior is only implied rather than demonstrated."
1453
+ },
1454
+ "ui-polish": {
1455
+ focusAreas: buildFocusAreas(paths, ["Primary interaction flow", "Responsive layout", "Accessibility and clarity of actions"]),
1456
+ failureModes: [
1457
+ "Broken or unintuitive interaction flow, especially onboarding, drawers, and primary actions",
1458
+ "Visual regressions, overflow, spacing collapse, or inaccessible controls",
1459
+ "Interfaces that technically render but feel unfinished or confusing in use"
1460
+ ],
1461
+ evidencePriorities: [
1462
+ "Navigate the affected UI and describe what users can and cannot do",
1463
+ "Verify mobile-width and edge-state behavior, not just the happy path",
1464
+ "Use Playwright evidence when visible behavior is part of the contract"
1465
+ ],
1466
+ severityBias: "Treat usability breaks and visually misleading states as blocking defects, not polish nits."
1467
+ },
1468
+ "workflow-fsm": {
1469
+ focusAreas: buildFocusAreas(paths, ["State transitions", "Retry semantics", "Lifecycle invariants", "Counter reset behavior"]),
1470
+ failureModes: [
1471
+ "Illegal transitions that bypass approval, review, or terminal-state rules",
1472
+ "Retry and checkpoint flows that jump to the wrong phase or double-increment counters",
1473
+ "State cleanup/reset bugs that leave stale error, checkpoint, or lifecycle metadata behind"
1474
+ ],
1475
+ evidencePriorities: [
1476
+ "Trace the exact state path for the critical scenario, including failure paths",
1477
+ "Verify counters and lifecycle fields are reset or preserved intentionally",
1478
+ "Treat ambiguous transition behavior as a defect until proven safe"
1479
+ ],
1480
+ severityBias: "Any lifecycle inconsistency that can misroute an issue or bypass a gate is blocking."
1481
+ },
1482
+ "integration-safety": {
1483
+ focusAreas: buildFocusAreas(paths, ["Git/worktree operations", "Persistence side effects", "Idempotency and cleanup"]),
1484
+ failureModes: [
1485
+ "Destructive workspace behavior that can delete user work or dirty target branches",
1486
+ "Cross-system drift between runtime state, resources, and filesystem artifacts",
1487
+ "Merge/push/service-management flows that work only in the happy path and break under dirty state"
1488
+ ],
1489
+ evidencePriorities: [
1490
+ "Verify failure handling, not just success path behavior",
1491
+ "Check idempotency and cleanup paths explicitly",
1492
+ "Call out any command or filesystem side effect that is not safely guarded"
1493
+ ],
1494
+ severityBias: "Prefer FAIL when integration code assumes a clean environment or safe side effects without enforcing them."
1495
+ },
1496
+ "api-contract": {
1497
+ focusAreas: buildFocusAreas(paths, ["Route handlers", "Resource schema", "Input/output contract"]),
1498
+ failureModes: [
1499
+ "HTTP/API behavior that no longer matches route or resource contract",
1500
+ "Schema drift between persisted fields, normalization, and route responses",
1501
+ "Missing validation or status-code mismatches that break downstream callers"
1502
+ ],
1503
+ evidencePriorities: [
1504
+ "Read the route/resource code and trace request-to-response behavior",
1505
+ "Verify persisted fields, normalization, and response shape stay aligned",
1506
+ "Treat silent contract drift as blocking even if the implementation compiles"
1507
+ ],
1508
+ severityBias: "Contract drift is blocking because it breaks automation and downstream clients silently."
1509
+ },
1510
+ "security-hardening": {
1511
+ focusAreas: buildFocusAreas(paths, ["Authorization boundaries", "Secret handling", "Shell/command safety"]),
1512
+ failureModes: [
1513
+ "Authorization bypass, over-broad permissions, or unsafe defaults",
1514
+ "Leaked secrets, credentials, or unsafe command composition",
1515
+ "Security-sensitive criteria marked as effectively optional or unverified"
1516
+ ],
1517
+ evidencePriorities: [
1518
+ "Look for privilege escalation and shell/filepath injection opportunities",
1519
+ "Verify security checks with concrete evidence, not inference alone",
1520
+ "Escalate uncertainty instead of allowing a soft PASS on security-sensitive paths"
1521
+ ],
1522
+ severityBias: "Security uncertainty should fail closed; do not grant benefit of the doubt."
1523
+ }
1524
+ };
1525
+ return {
1526
+ primary: primary.name,
1527
+ secondary,
1528
+ rationale: primary.rationale.length ? primary.rationale : ["Selected as the highest-risk profile based on touched code and acceptance criteria."],
1529
+ ...byName[primary.name]
1530
+ };
1531
+ }
1532
+
1533
+ // src/agents/harness-policy.ts
1534
+ var HIGH_RISK_PROFILES = /* @__PURE__ */ new Set([
1535
+ "workflow-fsm",
1536
+ "integration-safety",
1537
+ "api-contract",
1538
+ "security-hardening"
1539
+ ]);
1540
+ var HIGH_CHECKPOINT_PROFILES = /* @__PURE__ */ new Set([
1541
+ "workflow-fsm",
1542
+ "integration-safety",
1543
+ "security-hardening"
1544
+ ]);
1545
+ var ROUTE_AFFINITY = {
1546
+ "general-quality": { claude: 2.4, codex: 1.8, gemini: 1.4 },
1547
+ "ui-polish": { claude: 3.2, codex: 1.8, gemini: 1.6 },
1548
+ "workflow-fsm": { codex: 3.1, claude: 2, gemini: 1 },
1549
+ "integration-safety": { codex: 3, claude: 2, gemini: 1 },
1550
+ "api-contract": { codex: 3.1, claude: 1.9, gemini: 1.2 },
1551
+ "security-hardening": { claude: 2.6, codex: 2.6, gemini: 0.8 }
1552
+ };
1553
+ function rate(numerator, denominator) {
1554
+ return denominator > 0 ? numerator / denominator : null;
1555
+ }
1556
+ function isCompletedIssue(issue) {
1557
+ return issue.state === "Approved" || issue.state === "Merged";
1558
+ }
1559
+ function hadReviewRework(issue) {
1560
+ return (issue.previousAttemptSummaries ?? []).some((summary) => summary.phase === "review");
1561
+ }
1562
+ function resolveEffectiveReviewProfile(issue) {
1563
+ return issue.reviewProfile ?? deriveReviewProfile(issue);
1564
+ }
1565
+ function serializeReviewRouteSnapshot(route) {
1566
+ const providerLabel = `${route.provider}${route.model ? `/${route.model}` : ""}`;
1567
+ const effortLabel = route.reasoningEffort ? `[${route.reasoningEffort}]` : "";
1568
+ const overlayLabel = route.overlays?.length ? `overlays:${[...route.overlays].sort().join(",")}` : "";
1569
+ return [providerLabel, effortLabel, overlayLabel].filter(Boolean).join(" | ");
1570
+ }
1571
+ function buildReviewRouteKey(candidate) {
1572
+ return serializeReviewRouteSnapshot({
1573
+ provider: candidate.provider,
1574
+ model: candidate.model,
1575
+ reasoningEffort: candidate.reasoningEffort,
1576
+ overlays: candidate.overlays ?? []
1577
+ });
1578
+ }
1579
+ function applyHarnessModeToPlan(plan, mode) {
1580
+ plan.harnessMode = mode;
1581
+ if (mode !== "contractual") {
1582
+ plan.executionContract.checkpointPolicy = "final_only";
1583
+ } else if (plan.executionContract.checkpointPolicy !== "checkpointed") {
1584
+ plan.executionContract.checkpointPolicy = "final_only";
1585
+ }
1586
+ }
1587
+ function applyCheckpointPolicyToPlan(plan, checkpointPolicy) {
1588
+ plan.executionContract.checkpointPolicy = plan.harnessMode === "contractual" ? checkpointPolicy : "final_only";
1589
+ }
1590
+ function resolveLatestCompletedReviewRun(issue, scope = "final") {
1591
+ const reviewRuns = Array.isArray(issue.reviewRuns) ? issue.reviewRuns : [];
1592
+ const completed = reviewRuns.filter((entry) => entry.status === "completed");
1593
+ const matchingScope = completed.filter((entry) => entry.scope === scope);
1594
+ const pool = matchingScope.length > 0 ? matchingScope : completed;
1595
+ if (pool.length === 0) return null;
1596
+ return [...pool].sort((left, right) => {
1597
+ const leftAt = Date.parse(left.completedAt ?? left.startedAt);
1598
+ const rightAt = Date.parse(right.completedAt ?? right.startedAt);
1599
+ if (!Number.isNaN(leftAt) && !Number.isNaN(rightAt) && leftAt !== rightAt) return rightAt - leftAt;
1600
+ if ((left.planVersion ?? 0) !== (right.planVersion ?? 0)) return (right.planVersion ?? 0) - (left.planVersion ?? 0);
1601
+ return (right.attempt ?? 0) - (left.attempt ?? 0);
1602
+ })[0] ?? null;
1603
+ }
1604
+ function resolveLatestCompletedContractNegotiationRuns(issue) {
1605
+ const runs = Array.isArray(issue.contractNegotiationRuns) ? issue.contractNegotiationRuns : [];
1606
+ const completed = runs.filter((entry) => entry.status === "completed");
1607
+ if (completed.length === 0) return [];
1608
+ const latestPlanVersion = completed.reduce((maxPlanVersion, entry) => Math.max(maxPlanVersion, entry.planVersion ?? 0), 0);
1609
+ return completed.filter((entry) => (entry.planVersion ?? 0) === latestPlanVersion).sort((left, right) => {
1610
+ if ((left.attempt ?? 0) !== (right.attempt ?? 0)) return (left.attempt ?? 0) - (right.attempt ?? 0);
1611
+ const leftAt = Date.parse(left.completedAt ?? left.startedAt);
1612
+ const rightAt = Date.parse(right.completedAt ?? right.startedAt);
1613
+ if (!Number.isNaN(leftAt) && !Number.isNaN(rightAt) && leftAt !== rightAt) return leftAt - rightAt;
1614
+ return left.id.localeCompare(right.id);
1615
+ });
1616
+ }
1617
+ function resolveLatestCompletedScopedReviewRuns(issue, scope) {
1618
+ const reviewRuns = Array.isArray(issue.reviewRuns) ? issue.reviewRuns : [];
1619
+ const completed = reviewRuns.filter((entry) => entry.status === "completed" && entry.scope === scope);
1620
+ if (completed.length === 0) return [];
1621
+ const latestPlanVersion = completed.reduce((maxPlanVersion, entry) => Math.max(maxPlanVersion, entry.planVersion ?? 0), 0);
1622
+ return completed.filter((entry) => (entry.planVersion ?? 0) === latestPlanVersion).sort((left, right) => {
1623
+ if ((left.attempt ?? 0) !== (right.attempt ?? 0)) return (left.attempt ?? 0) - (right.attempt ?? 0);
1624
+ const leftAt = Date.parse(left.completedAt ?? left.startedAt);
1625
+ const rightAt = Date.parse(right.completedAt ?? right.startedAt);
1626
+ if (!Number.isNaN(leftAt) && !Number.isNaN(rightAt) && leftAt !== rightAt) return leftAt - rightAt;
1627
+ return left.id.localeCompare(right.id);
1628
+ });
1629
+ }
1630
+ function computeHarnessModeStats(issues, profileName) {
1631
+ const buckets = {
1632
+ solo: { reviewedIssues: 0, completedReviewedIssues: 0, gatePasses: 0, firstPassPasses: 0, reworkIssues: 0 },
1633
+ standard: { reviewedIssues: 0, completedReviewedIssues: 0, gatePasses: 0, firstPassPasses: 0, reworkIssues: 0 },
1634
+ contractual: { reviewedIssues: 0, completedReviewedIssues: 0, gatePasses: 0, firstPassPasses: 0, reworkIssues: 0 }
1635
+ };
1636
+ for (const issue of issues) {
1637
+ const reviewRun = resolveLatestCompletedReviewRun(issue, "final");
1638
+ if (!reviewRun) continue;
1639
+ const effectiveProfile = reviewRun.reviewProfile ?? resolveEffectiveReviewProfile(issue);
1640
+ if (effectiveProfile.primary !== profileName) continue;
1641
+ const mode = issue.plan?.harnessMode ?? "standard";
1642
+ const bucket = buckets[mode];
1643
+ bucket.reviewedIssues += 1;
1644
+ if (isCompletedIssue(issue)) bucket.completedReviewedIssues += 1;
1645
+ if (reviewRun.blockingVerdict === "PASS") bucket.gatePasses += 1;
1646
+ if ((issue.reviewAttempt ?? 0) <= 1 && !hadReviewRework(issue) && isCompletedIssue(issue)) bucket.firstPassPasses += 1;
1647
+ if (hadReviewRework(issue)) bucket.reworkIssues += 1;
1648
+ }
1649
+ return {
1650
+ solo: {
1651
+ ...buckets.solo,
1652
+ gatePassRate: rate(buckets.solo.gatePasses, buckets.solo.reviewedIssues),
1653
+ firstPassPassRate: rate(buckets.solo.firstPassPasses, buckets.solo.completedReviewedIssues),
1654
+ reviewReworkRate: rate(buckets.solo.reworkIssues, buckets.solo.reviewedIssues)
1655
+ },
1656
+ standard: {
1657
+ ...buckets.standard,
1658
+ gatePassRate: rate(buckets.standard.gatePasses, buckets.standard.reviewedIssues),
1659
+ firstPassPassRate: rate(buckets.standard.firstPassPasses, buckets.standard.completedReviewedIssues),
1660
+ reviewReworkRate: rate(buckets.standard.reworkIssues, buckets.standard.reviewedIssues)
1661
+ },
1662
+ contractual: {
1663
+ ...buckets.contractual,
1664
+ gatePassRate: rate(buckets.contractual.gatePasses, buckets.contractual.reviewedIssues),
1665
+ firstPassPassRate: rate(buckets.contractual.firstPassPasses, buckets.contractual.completedReviewedIssues),
1666
+ reviewReworkRate: rate(buckets.contractual.reworkIssues, buckets.contractual.reviewedIssues)
1667
+ }
1668
+ };
1669
+ }
1670
+ function computeCheckpointPolicyStats(issues, profileName) {
1671
+ const buckets = {
1672
+ final_only: {
1673
+ reviewedIssues: 0,
1674
+ completedReviewedIssues: 0,
1675
+ gatePasses: 0,
1676
+ firstPassPasses: 0,
1677
+ reworkIssues: 0,
1678
+ checkpointFailures: 0,
1679
+ checkpointPasses: 0,
1680
+ checkpointRuns: 0
1681
+ },
1682
+ checkpointed: {
1683
+ reviewedIssues: 0,
1684
+ completedReviewedIssues: 0,
1685
+ gatePasses: 0,
1686
+ firstPassPasses: 0,
1687
+ reworkIssues: 0,
1688
+ checkpointFailures: 0,
1689
+ checkpointPasses: 0,
1690
+ checkpointRuns: 0
1691
+ }
1692
+ };
1693
+ for (const issue of issues) {
1694
+ if ((issue.plan?.harnessMode ?? "standard") !== "contractual") continue;
1695
+ const finalReviewRun = resolveLatestCompletedReviewRun(issue, "final");
1696
+ if (!finalReviewRun) continue;
1697
+ const effectiveProfile = finalReviewRun.reviewProfile ?? resolveEffectiveReviewProfile(issue);
1698
+ if (effectiveProfile.primary !== profileName) continue;
1699
+ const checkpointPolicy = issue.plan?.executionContract?.checkpointPolicy === "checkpointed" ? "checkpointed" : "final_only";
1700
+ const bucket = buckets[checkpointPolicy];
1701
+ bucket.reviewedIssues += 1;
1702
+ if (isCompletedIssue(issue)) bucket.completedReviewedIssues += 1;
1703
+ if (finalReviewRun.blockingVerdict === "PASS") bucket.gatePasses += 1;
1704
+ if ((issue.reviewAttempt ?? 0) <= 1 && !hadReviewRework(issue) && isCompletedIssue(issue)) bucket.firstPassPasses += 1;
1705
+ if (hadReviewRework(issue)) bucket.reworkIssues += 1;
1706
+ if (checkpointPolicy === "checkpointed") {
1707
+ const checkpointRuns = resolveLatestCompletedScopedReviewRuns(issue, "checkpoint");
1708
+ bucket.checkpointRuns += checkpointRuns.length;
1709
+ if (checkpointRuns.some((entry) => entry.blockingVerdict === "FAIL")) bucket.checkpointFailures += 1;
1710
+ if (checkpointRuns.some((entry) => entry.blockingVerdict === "PASS")) bucket.checkpointPasses += 1;
1711
+ }
1712
+ }
1713
+ return {
1714
+ final_only: {
1715
+ ...buckets.final_only,
1716
+ gatePassRate: rate(buckets.final_only.gatePasses, buckets.final_only.reviewedIssues),
1717
+ firstPassPassRate: rate(buckets.final_only.firstPassPasses, buckets.final_only.completedReviewedIssues),
1718
+ reviewReworkRate: rate(buckets.final_only.reworkIssues, buckets.final_only.reviewedIssues),
1719
+ checkpointFailureRate: rate(buckets.final_only.checkpointFailures, buckets.final_only.reviewedIssues),
1720
+ checkpointPassRate: rate(buckets.final_only.checkpointPasses, buckets.final_only.reviewedIssues),
1721
+ avgCheckpointRunsPerIssue: rate(buckets.final_only.checkpointRuns, buckets.final_only.reviewedIssues)
1722
+ },
1723
+ checkpointed: {
1724
+ ...buckets.checkpointed,
1725
+ gatePassRate: rate(buckets.checkpointed.gatePasses, buckets.checkpointed.reviewedIssues),
1726
+ firstPassPassRate: rate(buckets.checkpointed.firstPassPasses, buckets.checkpointed.completedReviewedIssues),
1727
+ reviewReworkRate: rate(buckets.checkpointed.reworkIssues, buckets.checkpointed.reviewedIssues),
1728
+ checkpointFailureRate: rate(buckets.checkpointed.checkpointFailures, buckets.checkpointed.reviewedIssues),
1729
+ checkpointPassRate: rate(buckets.checkpointed.checkpointPasses, buckets.checkpointed.reviewedIssues),
1730
+ avgCheckpointRunsPerIssue: rate(buckets.checkpointed.checkpointRuns, buckets.checkpointed.reviewedIssues)
1731
+ }
1732
+ };
1733
+ }
1734
+ function computeContractNegotiationStats(issues, profileName) {
1735
+ const bucket = {
1736
+ negotiatedIssues: 0,
1737
+ approvedIssues: 0,
1738
+ firstPassApprovals: 0,
1739
+ revisedIssues: 0,
1740
+ blockingConcernIssues: 0,
1741
+ totalRounds: 0
1742
+ };
1743
+ for (const issue of issues) {
1744
+ const planRuns = resolveLatestCompletedContractNegotiationRuns(issue);
1745
+ if (planRuns.length === 0) continue;
1746
+ const latestRun = planRuns[planRuns.length - 1];
1747
+ const effectiveProfile = latestRun.reviewProfile ?? resolveEffectiveReviewProfile(issue);
1748
+ if (effectiveProfile.primary !== profileName) continue;
1749
+ bucket.negotiatedIssues += 1;
1750
+ bucket.totalRounds += planRuns.length;
1751
+ if (latestRun.decisionStatus === "approved") bucket.approvedIssues += 1;
1752
+ if (planRuns.length === 1 && planRuns[0]?.decisionStatus === "approved") bucket.firstPassApprovals += 1;
1753
+ if (planRuns.some((entry) => entry.decisionStatus === "revise" || entry.appliedRefinement)) bucket.revisedIssues += 1;
1754
+ if (planRuns.some((entry) => (entry.blockingConcernsCount ?? 0) > 0)) bucket.blockingConcernIssues += 1;
1755
+ }
1756
+ return {
1757
+ ...bucket,
1758
+ approvalRate: rate(bucket.approvedIssues, bucket.negotiatedIssues),
1759
+ firstPassApprovalRate: rate(bucket.firstPassApprovals, bucket.negotiatedIssues),
1760
+ revisionRate: rate(bucket.revisedIssues, bucket.negotiatedIssues),
1761
+ blockingConcernRate: rate(bucket.blockingConcernIssues, bucket.negotiatedIssues),
1762
+ avgRoundsPerIssue: rate(bucket.totalRounds, bucket.negotiatedIssues)
1763
+ };
1764
+ }
1765
+ function recommendCheckpointPolicyForIssue(issues, issue, currentCheckpointPolicy, minSamples = 3) {
1766
+ if (issue.plan?.harnessMode !== "contractual") {
1767
+ if (currentCheckpointPolicy !== "final_only") {
1768
+ const profile2 = resolveEffectiveReviewProfile(issue);
1769
+ return {
1770
+ checkpointPolicy: "final_only",
1771
+ profile: profile2,
1772
+ basis: "heuristic",
1773
+ rationale: "Non-contractual plans must not request checkpoint review."
1774
+ };
1775
+ }
1776
+ return null;
1777
+ }
1778
+ const profile = resolveEffectiveReviewProfile(issue);
1779
+ const complexity = issue.plan?.estimatedComplexity ?? "medium";
1780
+ const lowScope = complexity === "trivial" || complexity === "low";
1781
+ const highCheckpointRisk = HIGH_CHECKPOINT_PROFILES.has(profile.primary) && !lowScope;
1782
+ const stats = computeCheckpointPolicyStats(issues, profile.primary);
1783
+ const finalOnly = stats.final_only;
1784
+ const checkpointed = stats.checkpointed;
1785
+ const checkpointedSamplesReady = checkpointed.reviewedIssues >= minSamples;
1786
+ const finalOnlySamplesReady = finalOnly.reviewedIssues >= minSamples;
1787
+ if (currentCheckpointPolicy !== "checkpointed" && highCheckpointRisk) {
1788
+ if (checkpointedSamplesReady && (checkpointed.checkpointFailureRate ?? 0) >= 0.15) {
1789
+ return {
1790
+ checkpointPolicy: "checkpointed",
1791
+ profile,
1792
+ basis: "historical",
1793
+ rationale: `Enabled checkpoint review for ${profile.primary}: checkpointed runs caught blocking issues before final review in ${Math.round((checkpointed.checkpointFailureRate ?? 0) * 100)}% of ${checkpointed.reviewedIssues} comparable issue(s).`
1794
+ };
1795
+ }
1796
+ if (!checkpointedSamplesReady) {
1797
+ return {
1798
+ checkpointPolicy: "checkpointed",
1799
+ profile,
1800
+ basis: "heuristic",
1801
+ rationale: `Enabled checkpoint review because ${profile.primary} changes are high-risk enough to benefit from an intermediate gate before final review.`
1802
+ };
1803
+ }
1804
+ }
1805
+ if (checkpointedSamplesReady && finalOnlySamplesReady) {
1806
+ const gateLift = (checkpointed.gatePassRate ?? 0) - (finalOnly.gatePassRate ?? 0);
1807
+ const firstPassLift = (checkpointed.firstPassPassRate ?? 0) - (finalOnly.firstPassPassRate ?? 0);
1808
+ const checkpointCatchRate = checkpointed.checkpointFailureRate ?? 0;
1809
+ if (currentCheckpointPolicy !== "checkpointed" && (checkpointCatchRate >= 0.18 || gateLift >= 0.08 || firstPassLift >= 0.1)) {
1810
+ return {
1811
+ checkpointPolicy: "checkpointed",
1812
+ profile,
1813
+ basis: "historical",
1814
+ rationale: `Enabled checkpoint review for ${profile.primary}: checkpointed runs show ${Math.round(checkpointCatchRate * 100)}% checkpoint catch rate, ${Math.round(gateLift * 100)}pp final gate lift, and ${Math.round(firstPassLift * 100)}pp first-pass lift over final-only contractual runs.`
1815
+ };
1816
+ }
1817
+ if (currentCheckpointPolicy === "checkpointed" && !highCheckpointRisk && checkpointCatchRate <= 0.05 && (finalOnly.gatePassRate ?? 0) >= (checkpointed.gatePassRate ?? 0) - 0.05 && (finalOnly.firstPassPassRate ?? 0) >= (checkpointed.firstPassPassRate ?? 0) - 0.05) {
1818
+ return {
1819
+ checkpointPolicy: "final_only",
1820
+ profile,
1821
+ basis: "historical",
1822
+ rationale: `Disabled checkpoint review for ${profile.primary}: checkpointed runs almost never catch issues before final review (${Math.round(checkpointCatchRate * 100)}%), while final-only contractual runs stay within 5pp on final gate and first-pass outcomes.`
1823
+ };
1824
+ }
1825
+ }
1826
+ if (currentCheckpointPolicy === "checkpointed" && lowScope) {
1827
+ return {
1828
+ checkpointPolicy: "final_only",
1829
+ profile,
1830
+ basis: "heuristic",
1831
+ rationale: `Disabled checkpoint review because ${complexity} contractual work should keep the contract gate but avoid an intermediate review pass.`
1832
+ };
1833
+ }
1834
+ return null;
1835
+ }
1836
+ function recommendHarnessModeForIssue(issues, issue, currentMode, minSamples = 3) {
1837
+ const profile = resolveEffectiveReviewProfile(issue);
1838
+ const complexity = issue.plan?.estimatedComplexity ?? "medium";
1839
+ const stats = computeHarnessModeStats(issues, profile.primary);
1840
+ const negotiation = computeContractNegotiationStats(issues, profile.primary);
1841
+ const highRisk = HIGH_RISK_PROFILES.has(profile.primary);
1842
+ const lowScope = complexity === "trivial" || complexity === "low";
1843
+ const negotiationSamplesReady = negotiation.negotiatedIssues >= minSamples;
1844
+ const highRiskNegotiationPressure = negotiationSamplesReady && ((negotiation.blockingConcernRate ?? 0) >= 0.15 || (negotiation.revisionRate ?? 0) >= 0.3);
1845
+ const generalNegotiationPressure = negotiationSamplesReady && !lowScope && ((negotiation.blockingConcernRate ?? 0) >= 0.25 || (negotiation.revisionRate ?? 0) >= 0.4 || (negotiation.avgRoundsPerIssue ?? 0) >= 1.6);
1846
+ const negotiationLowValue = negotiationSamplesReady && (negotiation.firstPassApprovalRate ?? 0) >= 0.9 && (negotiation.blockingConcernRate ?? 1) <= 0.08 && (negotiation.revisionRate ?? 1) <= 0.15;
1847
+ if (highRisk && currentMode !== "contractual") {
1848
+ if (highRiskNegotiationPressure) {
1849
+ return {
1850
+ mode: "contractual",
1851
+ profile,
1852
+ basis: "historical",
1853
+ rationale: `Switched to contractual for ${profile.primary}: contract negotiation found blocking concerns in ${Math.round((negotiation.blockingConcernRate ?? 0) * 100)}% of ${negotiation.negotiatedIssues} comparable issue(s) and forced revisions in ${Math.round((negotiation.revisionRate ?? 0) * 100)}%.`
1854
+ };
1855
+ }
1856
+ const contractual2 = stats.contractual;
1857
+ if (contractual2.reviewedIssues >= minSamples) {
1858
+ return {
1859
+ mode: "contractual",
1860
+ profile,
1861
+ basis: "historical",
1862
+ rationale: `Switched to contractual for ${profile.primary}: historical gate pass ${Math.round((contractual2.gatePassRate ?? 0) * 100)}% across ${contractual2.reviewedIssues} reviewed issue(s).`
1863
+ };
1864
+ }
1865
+ return {
1866
+ mode: "contractual",
1867
+ profile,
1868
+ basis: "heuristic",
1869
+ rationale: `Switched to contractual because ${profile.primary} is a high-risk profile and needs stronger contract negotiation plus skeptical review semantics.`
1870
+ };
1871
+ }
1872
+ if (profile.primary === "general-quality" && lowScope) {
1873
+ const solo = stats.solo;
1874
+ if (solo.reviewedIssues >= minSamples && (solo.gatePassRate ?? 0) >= 0.95 && (solo.reviewReworkRate ?? 1) <= 0.1) {
1875
+ if (currentMode !== "solo") {
1876
+ return {
1877
+ mode: "solo",
1878
+ profile,
1879
+ basis: "historical",
1880
+ rationale: `Downgraded to solo for low-scope general work: solo gate pass ${Math.round((solo.gatePassRate ?? 0) * 100)}% with low rework over ${solo.reviewedIssues} reviewed issue(s).`
1881
+ };
1882
+ }
1883
+ return null;
1884
+ }
1885
+ }
1886
+ if (currentMode !== "contractual" && generalNegotiationPressure) {
1887
+ return {
1888
+ mode: "contractual",
1889
+ profile,
1890
+ basis: "historical",
1891
+ rationale: `Switched to contractual for ${profile.primary}: contract negotiation found blocking concerns in ${Math.round((negotiation.blockingConcernRate ?? 0) * 100)}% of ${negotiation.negotiatedIssues} comparable issue(s), with revisions required in ${Math.round((negotiation.revisionRate ?? 0) * 100)}% and ${Math.round((negotiation.avgRoundsPerIssue ?? 0) * 10) / 10} rounds per issue on average.`
1892
+ };
1893
+ }
1894
+ const standard = stats.standard;
1895
+ const contractual = stats.contractual;
1896
+ const contractualSamplesReady = contractual.reviewedIssues >= minSamples;
1897
+ const standardSamplesReady = standard.reviewedIssues >= minSamples;
1898
+ if (contractualSamplesReady && standardSamplesReady) {
1899
+ const contractualGateLift = (contractual.gatePassRate ?? 0) - (standard.gatePassRate ?? 0);
1900
+ const contractualFirstPassLift = (contractual.firstPassPassRate ?? 0) - (standard.firstPassPassRate ?? 0);
1901
+ if (currentMode !== "contractual" && (contractualGateLift >= 0.12 || contractualFirstPassLift >= 0.15)) {
1902
+ return {
1903
+ mode: "contractual",
1904
+ profile,
1905
+ basis: "historical",
1906
+ rationale: `Switched to contractual for ${profile.primary}: first-pass lift ${Math.round(contractualFirstPassLift * 100)}pp and gate lift ${Math.round(contractualGateLift * 100)}pp over standard.`
1907
+ };
1908
+ }
1909
+ if (currentMode === "contractual" && !highRisk && !lowScope && negotiationLowValue && (standard.gatePassRate ?? 0) >= (contractual.gatePassRate ?? 0) - 0.05 && (standard.firstPassPassRate ?? 0) >= (contractual.firstPassPassRate ?? 0) - 0.05) {
1910
+ return {
1911
+ mode: "standard",
1912
+ profile,
1913
+ basis: "historical",
1914
+ rationale: `Downgraded to standard for ${profile.primary}: contract negotiation approved on first pass in ${Math.round((negotiation.firstPassApprovalRate ?? 0) * 100)}% of ${negotiation.negotiatedIssues} comparable issue(s), and standard review performance stays within 5pp of contractual.`
1915
+ };
1916
+ }
1917
+ }
1918
+ if (!highRisk && currentMode === "solo" && !lowScope) {
1919
+ return {
1920
+ mode: "standard",
1921
+ profile,
1922
+ basis: "heuristic",
1923
+ rationale: `Upgraded from solo to standard because ${complexity} complexity should keep an automated reviewer in the loop.`
1924
+ };
1925
+ }
1926
+ return null;
1927
+ }
1928
+ function computeReviewRouteStats(issues, profileName) {
1929
+ const buckets = {};
1930
+ for (const issue of issues) {
1931
+ const reviewRun = resolveLatestCompletedReviewRun(issue, "final");
1932
+ if (!reviewRun) continue;
1933
+ const effectiveProfile = reviewRun.reviewProfile ?? resolveEffectiveReviewProfile(issue);
1934
+ if (effectiveProfile.primary !== profileName) continue;
1935
+ const routeKey = serializeReviewRouteSnapshot(reviewRun.routing);
1936
+ const bucket = buckets[routeKey] ||= {
1937
+ reviewedIssues: 0,
1938
+ completedReviewedIssues: 0,
1939
+ gatePasses: 0,
1940
+ blockingFailRuns: 0,
1941
+ advisoryFailRuns: 0
1942
+ };
1943
+ bucket.reviewedIssues += 1;
1944
+ if (isCompletedIssue(issue)) bucket.completedReviewedIssues += 1;
1945
+ if (reviewRun.blockingVerdict === "PASS") bucket.gatePasses += 1;
1946
+ if (reviewRun.blockingVerdict === "FAIL") bucket.blockingFailRuns += 1;
1947
+ if ((reviewRun.advisoryFailedCriteriaCount ?? 0) > 0) bucket.advisoryFailRuns += 1;
1948
+ }
1949
+ return Object.fromEntries(
1950
+ Object.entries(buckets).map(([routeKey, bucket]) => [
1951
+ routeKey,
1952
+ {
1953
+ ...bucket,
1954
+ gatePassRate: rate(bucket.gatePasses, bucket.reviewedIssues),
1955
+ blockingFailRate: rate(bucket.blockingFailRuns, bucket.reviewedIssues)
1956
+ }
1957
+ ])
1958
+ );
1959
+ }
1960
+ function recommendReviewRouteForIssue(issues, issue, candidates, minSamples = 3) {
1961
+ if (candidates.length === 0) return null;
1962
+ const profile = resolveEffectiveReviewProfile(issue);
1963
+ const routeStats = computeReviewRouteStats(issues, profile.primary);
1964
+ const scored = candidates.map((candidate) => {
1965
+ const routeKey = buildReviewRouteKey(candidate);
1966
+ const stats = routeStats[routeKey];
1967
+ const affinity = ROUTE_AFFINITY[profile.primary][candidate.provider] ?? 0;
1968
+ const historicalScore = stats ? (stats.gatePassRate ?? 0) * 4 - (stats.blockingFailRate ?? 0) * 3 + Math.min(stats.reviewedIssues, 6) * 0.15 : 0;
1969
+ return {
1970
+ candidate,
1971
+ routeKey,
1972
+ stats,
1973
+ score: affinity + historicalScore,
1974
+ affinity
1975
+ };
1976
+ }).sort((left, right) => {
1977
+ if (right.score !== left.score) return right.score - left.score;
1978
+ return left.routeKey.localeCompare(right.routeKey);
1979
+ });
1980
+ const current = scored[0];
1981
+ if (!current) return null;
1982
+ if (current.stats && current.stats.reviewedIssues >= minSamples) {
1983
+ return {
1984
+ candidate: current.candidate,
1985
+ profile,
1986
+ basis: "historical",
1987
+ rationale: `Adaptive reviewer route for ${profile.primary}: ${current.routeKey} has ${Math.round((current.stats.gatePassRate ?? 0) * 100)}% gate pass over ${current.stats.reviewedIssues} reviewed issue(s).`
1988
+ };
1989
+ }
1990
+ return {
1991
+ candidate: current.candidate,
1992
+ profile,
1993
+ basis: "heuristic",
1994
+ rationale: `Adaptive reviewer route for ${profile.primary}: preferred ${current.candidate.provider} based on profile affinity while historical samples are still sparse.`
1995
+ };
1996
+ }
1186
1997
 
1187
1998
  // src/agents/model-discovery.ts
1188
1999
  import { execFileSync } from "child_process";
@@ -1191,17 +2002,6 @@ import { join as join4, dirname } from "path";
1191
2002
  import { homedir } from "os";
1192
2003
  var modelCache = /* @__PURE__ */ new Map();
1193
2004
  var MODEL_CACHE_TTL_MS = 5 * 60 * 1e3;
1194
- function readClaudeConfig() {
1195
- try {
1196
- const settingsPath = join4(homedir(), ".claude", "settings.json");
1197
- if (!existsSync5(settingsPath)) return {};
1198
- const raw = readFileSync2(settingsPath, "utf8");
1199
- const settings = JSON.parse(raw);
1200
- return { model: typeof settings.model === "string" ? settings.model : void 0 };
1201
- } catch {
1202
- return {};
1203
- }
1204
- }
1205
2005
  function resolveGeminiModelsFile() {
1206
2006
  try {
1207
2007
  const binPath = execFileSync("which", ["gemini"], { encoding: "utf8", timeout: 3e3 }).trim();
@@ -1343,6 +2143,18 @@ function normalizeAgentProvider(value) {
1343
2143
  if (!normalized) return "codex";
1344
2144
  return normalized;
1345
2145
  }
2146
+ function resolveProviderCapabilities(provider, overrides) {
2147
+ return getProviderCapabilities(provider, overrides ?? null);
2148
+ }
2149
+ function getProviderCapabilityWarnings(provider, overrides) {
2150
+ return describeProviderCapabilityWarnings(provider, resolveProviderCapabilities(provider, overrides));
2151
+ }
2152
+ function resolveAgentCommand(provider, explicitCommand, codexCommand, claudeCommand, reasoningEffort) {
2153
+ if (explicitCommand.trim()) return explicitCommand.trim();
2154
+ if (provider === "claude" && claudeCommand.trim()) return claudeCommand.trim();
2155
+ if (provider === "codex" && codexCommand.trim()) return codexCommand.trim();
2156
+ return getProviderDefaultCommand(provider, reasoningEffort);
2157
+ }
1346
2158
  function resolveEffort(role, issueEffort, globalEffort) {
1347
2159
  const roleKey = role;
1348
2160
  if (issueEffort?.[roleKey]) return issueEffort[roleKey];
@@ -1353,7 +2165,8 @@ function resolveEffort(role, issueEffort, globalEffort) {
1353
2165
  function getProviderDefaultCommand(provider, reasoningEffort, model) {
1354
2166
  const adapter = ADAPTERS[provider];
1355
2167
  if (!adapter) return "";
1356
- const jsonSchema = provider === "claude" ? CLAUDE_RESULT_SCHEMA : void 0;
2168
+ const capabilities = resolveProviderCapabilities(provider);
2169
+ const jsonSchema = usesNativeStructuredOutput(capabilities) ? CLAUDE_RESULT_SCHEMA : void 0;
1357
2170
  return adapter.buildCommand({ model, effort: reasoningEffort, jsonSchema });
1358
2171
  }
1359
2172
  var cachedProviders = null;
@@ -1365,11 +2178,12 @@ function detectAvailableProviders() {
1365
2178
  }
1366
2179
  const providers = [];
1367
2180
  for (const name of ["claude", "codex", "gemini"]) {
2181
+ const capabilities = resolveProviderCapabilities(name);
1368
2182
  try {
1369
2183
  const path = execFileSync2("which", [name], { encoding: "utf8", timeout: 5e3 }).trim();
1370
- providers.push({ name, available: true, path });
2184
+ providers.push({ name, available: true, path, capabilities });
1371
2185
  } catch {
1372
- providers.push({ name, available: false, path: "" });
2186
+ providers.push({ name, available: false, path: "", capabilities });
1373
2187
  }
1374
2188
  }
1375
2189
  cachedProviders = providers;
@@ -1402,23 +2216,133 @@ function readGeminiConfig() {
1402
2216
  return {};
1403
2217
  }
1404
2218
  }
2219
+ function readClaudeConfig() {
2220
+ try {
2221
+ const settingsPath = join5(homedir2(), ".claude", "settings.json");
2222
+ if (!existsSync6(settingsPath)) return {};
2223
+ const raw = readFileSync3(settingsPath, "utf8");
2224
+ const settings = JSON.parse(raw);
2225
+ return {
2226
+ model: typeof settings.model === "string" ? settings.model : void 0
2227
+ };
2228
+ } catch {
2229
+ return {};
2230
+ }
2231
+ }
1405
2232
  function resolveDefaultProvider(detected) {
1406
2233
  const available = detected.filter((p) => p.available);
1407
2234
  if (available.length === 0) return "";
1408
2235
  if (available.some((p) => p.name === "codex")) return "codex";
1409
2236
  return available[0].name;
1410
2237
  }
1411
- function getBaseAgentProviders(state) {
1412
- return [
1413
- {
1414
- provider: state.config.agentProvider,
1415
- role: "executor",
1416
- command: state.config.agentCommand,
1417
- profile: "",
1418
- profilePath: "",
1419
- profileInstructions: ""
1420
- }
1421
- ];
2238
+ var EFFORT_ORDER = ["low", "medium", "high", "extra-high"];
2239
+ var REVIEW_PROFILE_MIN_EFFORT = {
2240
+ "general-quality": "medium",
2241
+ "ui-polish": "high",
2242
+ "workflow-fsm": "high",
2243
+ "integration-safety": "high",
2244
+ "api-contract": "high",
2245
+ "security-hardening": "extra-high"
2246
+ };
2247
+ var REVIEW_PROFILE_OVERLAYS = {
2248
+ "general-quality": [],
2249
+ "ui-polish": ["impeccable", "frontend-design"],
2250
+ "workflow-fsm": ["workflow-audit"],
2251
+ "integration-safety": ["integration-safety"],
2252
+ "api-contract": ["api-contract"],
2253
+ "security-hardening": ["security-hardening"]
2254
+ };
2255
+ function maxEffort(left, right) {
2256
+ if (!left) return right;
2257
+ if (!right) return left;
2258
+ return EFFORT_ORDER[Math.max(EFFORT_ORDER.indexOf(left), EFFORT_ORDER.indexOf(right))] ?? left;
2259
+ }
2260
+ function stageToRole(stage) {
2261
+ if (stage === "plan") return "planner";
2262
+ if (stage === "review") return "reviewer";
2263
+ return "executor";
2264
+ }
2265
+ function buildStageProvider(state, issue, stage, workflowConfig) {
2266
+ const role = stageToRole(stage);
2267
+ const stageConfig = workflowConfig?.[roleToStageKey(stageToRole(stage))];
2268
+ const effort = stageConfig?.effort || resolveEffort(role, issue.effort, state.config.defaultEffort);
2269
+ const providerName = stageConfig?.provider || state.config.agentProvider;
2270
+ const model = stageConfig?.model || void 0;
2271
+ const command = stageConfig ? getProviderDefaultCommand(providerName, effort, model) : stage === "execute" ? resolveAgentCommand(providerName, state.config.agentCommand, "", "", effort) : getProviderDefaultCommand(providerName, effort, model);
2272
+ return {
2273
+ provider: providerName,
2274
+ role,
2275
+ command,
2276
+ model,
2277
+ profile: "",
2278
+ profilePath: "",
2279
+ profileInstructions: "",
2280
+ reasoningEffort: effort,
2281
+ selectionReason: stageConfig ? `Using workflow ${stage} stage configuration.` : `Using default ${stage} stage provider configuration.`,
2282
+ overlays: [],
2283
+ capabilities: resolveProviderCapabilities(providerName)
2284
+ };
2285
+ }
2286
+ function specializeReviewerProvider(baseProvider, issue) {
2287
+ const reviewProfile = issue.reviewProfile ?? deriveReviewProfile(issue);
2288
+ const minEffort = REVIEW_PROFILE_MIN_EFFORT[reviewProfile.primary];
2289
+ const reasoningEffort = maxEffort(baseProvider.reasoningEffort, minEffort);
2290
+ const overlays = [.../* @__PURE__ */ new Set([...baseProvider.overlays ?? [], ...REVIEW_PROFILE_OVERLAYS[reviewProfile.primary]])];
2291
+ const command = getProviderDefaultCommand(baseProvider.provider, reasoningEffort, baseProvider.model) || baseProvider.command;
2292
+ return {
2293
+ ...baseProvider,
2294
+ command,
2295
+ reasoningEffort,
2296
+ overlays,
2297
+ selectionReason: `Reviewer specialized for ${reviewProfile.primary}; raised scrutiny with ${reasoningEffort ?? "default"} effort.`
2298
+ };
2299
+ }
2300
+ function resolveSynchronousProviderModel(provider, workflowConfig) {
2301
+ const stages = workflowConfig ? [workflowConfig.review, workflowConfig.execute, workflowConfig.plan] : [];
2302
+ const fromWorkflow = stages.find((stage) => stage?.provider === provider)?.model;
2303
+ if (fromWorkflow) return fromWorkflow;
2304
+ if (provider === "codex") return readCodexConfig().model;
2305
+ if (provider === "gemini") return readGeminiConfig().model;
2306
+ if (provider === "claude") return readClaudeConfig().model;
2307
+ return void 0;
2308
+ }
2309
+ function buildAdaptiveReviewCandidates(baseProvider, workflowConfig) {
2310
+ const availableProviders = detectAvailableProviders().filter((provider) => provider.available).map((provider) => provider.name);
2311
+ const candidates = /* @__PURE__ */ new Map();
2312
+ const addCandidate = (providerName, reason) => {
2313
+ const model = resolveSynchronousProviderModel(providerName, workflowConfig ?? null);
2314
+ const command = getProviderDefaultCommand(providerName, baseProvider.reasoningEffort, model) || baseProvider.command;
2315
+ const candidate = {
2316
+ ...baseProvider,
2317
+ provider: providerName,
2318
+ model,
2319
+ command,
2320
+ selectionReason: reason,
2321
+ capabilities: resolveProviderCapabilities(providerName)
2322
+ };
2323
+ candidates.set(buildReviewRouteKey(candidate), candidate);
2324
+ };
2325
+ addCandidate(baseProvider.provider, baseProvider.selectionReason || "Configured review route.");
2326
+ for (const providerName of availableProviders) {
2327
+ if (providerName === baseProvider.provider) continue;
2328
+ addCandidate(providerName, `Adaptive routing candidate using available ${providerName} reviewer.`);
2329
+ }
2330
+ return [...candidates.values()];
2331
+ }
2332
+ function adaptReviewerProvider(state, issue, baseProvider, workflowConfig) {
2333
+ if (state.config.adaptiveReviewRouting === false) return baseProvider;
2334
+ const candidates = buildAdaptiveReviewCandidates(baseProvider, workflowConfig ?? null);
2335
+ const recommendation = recommendReviewRouteForIssue(
2336
+ state.issues,
2337
+ issue,
2338
+ candidates,
2339
+ state.config.adaptivePolicyMinSamples ?? 3
2340
+ );
2341
+ if (!recommendation) return baseProvider;
2342
+ return {
2343
+ ...recommendation.candidate,
2344
+ selectionReason: `${recommendation.rationale} ${baseProvider.selectionReason ?? ""}`.trim()
2345
+ };
1422
2346
  }
1423
2347
  function roleToStageKey(role) {
1424
2348
  switch (role) {
@@ -1430,1020 +2354,45 @@ function roleToStageKey(role) {
1430
2354
  return "review";
1431
2355
  }
1432
2356
  }
1433
- function applyWorkflowConfigToProviders(providers, workflowConfig) {
1434
- if (!workflowConfig) return providers;
1435
- return providers.map((provider) => {
1436
- const stageKey = roleToStageKey(provider.role);
1437
- const stageConfig = workflowConfig[stageKey];
1438
- if (!stageConfig) return provider;
1439
- const newProvider = stageConfig.provider || provider.provider;
1440
- const newModel = stageConfig.model || void 0;
1441
- const newEffort = stageConfig.effort || provider.reasoningEffort;
1442
- const command = getProviderDefaultCommand(newProvider, newEffort, newModel);
1443
- return {
1444
- ...provider,
1445
- provider: newProvider,
1446
- model: newModel,
1447
- command: command || provider.command,
1448
- reasoningEffort: newEffort
1449
- };
1450
- });
2357
+ function getExecutionProviders(state, issue, workflowConfig) {
2358
+ return [buildStageProvider(state, issue, "execute", workflowConfig ?? null)];
1451
2359
  }
1452
- function getEffectiveAgentProviders(state, issue, _workflowDefinition, workflowConfig) {
1453
- const baseProviders = getBaseAgentProviders(state);
1454
- const providers = baseProviders.map((provider) => {
1455
- const effort = resolveEffort(provider.role, issue.effort, state.config.defaultEffort);
1456
- return {
1457
- ...provider,
1458
- reasoningEffort: effort
1459
- };
1460
- });
1461
- return applyWorkflowConfigToProviders(providers, workflowConfig ?? null);
2360
+ function getReviewProvider(state, issue, workflowConfig) {
2361
+ const specialized = specializeReviewerProvider(buildStageProvider(state, issue, "review", workflowConfig ?? null), issue);
2362
+ return adaptReviewerProvider(state, issue, specialized, workflowConfig ?? null);
1462
2363
  }
1463
-
1464
- // src/agents/command-executor.ts
1465
- var HOOK_RUNTIME_CONFIG = {
1466
- pollIntervalMs: 0,
1467
- workerConcurrency: 1,
1468
- maxConcurrentByState: {},
1469
- commandTimeoutMs: 18e5,
1470
- maxAttemptsDefault: 1,
1471
- maxTurns: 1,
1472
- retryDelayMs: 0,
1473
- staleInProgressTimeoutMs: 0,
1474
- logLinesTail: 12e3,
1475
- maxPreviousOutputChars: 12e3,
1476
- agentProvider: "codex",
1477
- agentCommand: "",
1478
- defaultEffort: { default: "medium" },
1479
- runMode: "filesystem",
1480
- autoReviewApproval: true,
1481
- afterCreateHook: "",
1482
- beforeRunHook: "",
1483
- afterRunHook: "",
1484
- beforeRemoveHook: ""
1485
- };
1486
- async function runCommandWithTimeout(command, workspacePath, issue, config, promptText, promptFile, extraEnv = {}, outputFile) {
1487
- return new Promise((resolve2) => {
1488
- const started = Date.now();
1489
- const resultFile = extraEnv.FIFONY_RESULT_FILE;
1490
- if (resultFile && extraEnv.FIFONY_PRESERVE_RESULT_FILE !== "1") {
1491
- rmSync(resultFile, { force: true });
1492
- }
1493
- const allVars = {
1494
- FIFONY_ISSUE_ID: issue.id,
1495
- FIFONY_ISSUE_IDENTIFIER: issue.identifier,
1496
- FIFONY_ISSUE_TITLE: issue.title,
1497
- FIFONY_WORKSPACE_PATH: issue.worktreePath ?? workspacePath,
1498
- FIFONY_PROMPT_FILE: promptFile
1499
- };
1500
- for (const [key, value] of Object.entries(extraEnv)) {
1501
- if (value.length > 4e3) {
1502
- const valFile = join6(workspacePath, `${key.toLowerCase()}.txt`);
1503
- writeFileSync(valFile, value, "utf8");
1504
- allVars[`${key}_FILE`] = valFile;
1505
- } else {
1506
- allVars[key] = value;
1507
- }
1508
- }
1509
- const envFilePath = join6(workspacePath, ".env.sh");
1510
- const envFileLines = Object.entries(allVars).map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join("\n");
1511
- writeFileSync(envFilePath, envFileLines, "utf8");
1512
- const wrappedCommand = `. "${envFilePath}" && ${command}`;
1513
- const child = spawn(wrappedCommand, {
1514
- shell: true,
1515
- cwd: issue.worktreePath ?? workspacePath,
1516
- detached: true,
1517
- // Survive parent death
1518
- stdio: ["pipe", "pipe", "pipe"]
1519
- });
1520
- child.unref();
1521
- if (child.stdin) {
1522
- child.stdin.end();
1523
- }
1524
- const pidFile = join6(workspacePath, "agent.pid");
1525
- const pid = child.pid;
1526
- if (pid) {
1527
- logger.debug({ issueId: issue.id, pid, command: command.slice(0, 120), cwd: workspacePath }, "[Agent] Process spawned");
1528
- writeFileSync(pidFile, JSON.stringify({
1529
- pid,
1530
- issueId: issue.id,
1531
- startedAt: new Date(started).toISOString(),
1532
- command: command.slice(0, 200)
1533
- }), "utf8");
1534
- }
1535
- let output = "";
1536
- let timedOut = false;
1537
- let outputBytes = 0;
1538
- let outputHeader = "";
1539
- const liveLogFile = join6(workspacePath, "live-output.log");
1540
- writeFileSync(liveLogFile, "", "utf8");
1541
- if (outputFile) {
1542
- try {
1543
- const header = `# fifony stdout capture
1544
- # turn: ${extraEnv.FIFONY_TURN_INDEX ?? "?"}
1545
- # provider: ${extraEnv.FIFONY_AGENT_PROVIDER ?? "?"}
1546
- # role: ${extraEnv.FIFONY_AGENT_ROLE ?? "?"}
1547
- # timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
1548
- ---
1549
- `;
1550
- writeFileSync(outputFile, header, "utf8");
1551
- } catch {
1552
- }
1553
- }
1554
- const onChunk = (chunk) => {
1555
- const text = String(chunk);
1556
- if (outputHeader.length < 2e3) outputHeader = (outputHeader + text).slice(0, 2e3);
1557
- output = appendFileTail(output, text, config.logLinesTail);
1558
- outputBytes += text.length;
1559
- try {
1560
- appendFileSync(liveLogFile, text);
1561
- } catch {
1562
- }
1563
- if (outputFile) {
1564
- try {
1565
- appendFileSync(outputFile, text);
1566
- } catch {
1567
- }
1568
- }
1569
- issue.commandOutputTail = output;
1570
- };
1571
- child.stdout?.on("data", onChunk);
1572
- child.stderr?.on("data", onChunk);
1573
- const AGENT_STALE_OUTPUT_MS = 18e5;
1574
- const timer = setTimeout(() => {
1575
- timedOut = true;
1576
- if (pid) {
1577
- try {
1578
- process.kill(-pid, "SIGTERM");
1579
- } catch {
1580
- }
1581
- } else {
1582
- child.kill("SIGTERM");
1583
- }
1584
- }, config.commandTimeoutMs);
1585
- let lastWatchdogBytes = 0;
1586
- let lastOutputGrowthAt = Date.now();
1587
- let watchdogKilled = false;
1588
- const watchdog = setInterval(() => {
1589
- if (pid) {
1590
- try {
1591
- process.kill(pid, 0);
1592
- } catch {
1593
- clearInterval(watchdog);
1594
- clearTimeout(timer);
1595
- watchdogKilled = true;
1596
- try {
1597
- rmSync(pidFile, { force: true });
1598
- } catch {
1599
- }
1600
- resolve2({ success: false, code: null, output: appendFileTail(output, `
1601
- Agent process died unexpectedly (PID ${pid}).`, config.logLinesTail) });
1602
- return;
1603
- }
1604
- }
1605
- if (outputBytes > lastWatchdogBytes) {
1606
- lastWatchdogBytes = outputBytes;
1607
- lastOutputGrowthAt = Date.now();
1608
- } else if (Date.now() - lastOutputGrowthAt > AGENT_STALE_OUTPUT_MS) {
1609
- clearInterval(watchdog);
1610
- clearTimeout(timer);
1611
- timedOut = true;
1612
- watchdogKilled = true;
1613
- if (pid) {
1614
- try {
1615
- process.kill(-pid, "SIGTERM");
1616
- } catch {
1617
- }
1618
- } else {
1619
- child.kill("SIGTERM");
1620
- }
1621
- try {
1622
- rmSync(pidFile, { force: true });
1623
- } catch {
1624
- }
1625
- resolve2({ success: false, code: null, output: appendFileTail(output, `
1626
- Agent process stuck \u2014 no output for ${Math.round(AGENT_STALE_OUTPUT_MS / 6e4)} minutes.`, config.logLinesTail) });
1627
- }
1628
- }, 3e4);
1629
- const cleanup = () => {
1630
- clearInterval(watchdog);
1631
- try {
1632
- rmSync(pidFile, { force: true });
1633
- } catch {
1634
- }
1635
- };
1636
- child.on("error", () => {
1637
- clearTimeout(timer);
1638
- cleanup();
1639
- if (watchdogKilled) return;
1640
- resolve2({ success: false, code: null, output: `Command execution failed for issue ${issue.id}.` });
1641
- });
1642
- child.on("close", (code) => {
1643
- clearTimeout(timer);
1644
- cleanup();
1645
- if (watchdogKilled) return;
1646
- const buildOutput = (suffix) => {
1647
- const tail = appendFileTail(output, suffix, config.logLinesTail);
1648
- return outputHeader.length > 0 && !tail.startsWith(outputHeader.slice(0, 80)) ? `${outputHeader}
1649
- ${tail}` : tail;
1650
- };
1651
- if (timedOut) {
1652
- resolve2({ success: false, code: null, output: buildOutput(`
1653
- Execution timeout after ${config.commandTimeoutMs}ms.`) });
1654
- return;
1655
- }
1656
- const duration = Math.max(0, Date.now() - started);
1657
- if (code === 0) {
1658
- resolve2({ success: true, code, output: buildOutput(`
1659
- Execution succeeded in ${duration}ms.`) });
1660
- return;
1661
- }
1662
- resolve2({ success: false, code, output: buildOutput(`
1663
- Command exit code ${code ?? "unknown"} after ${duration}ms.`) });
1664
- });
1665
- });
1666
- }
1667
- async function runHook(command, workspacePath, issue, hookName, extraEnv = {}) {
1668
- if (!command.trim()) return;
1669
- const result = await runCommandWithTimeout(command, workspacePath, issue, {
1670
- ...HOOK_RUNTIME_CONFIG,
1671
- agentProvider: normalizeAgentProvider(env2.FIFONY_AGENT_PROVIDER ?? "codex"),
1672
- agentCommand: command
1673
- }, "", "", { FIFONY_HOOK_NAME: hookName, ...extraEnv });
1674
- if (!result.success) {
1675
- throw new Error(`${hookName} hook failed: ${result.output}`);
1676
- }
1677
- }
1678
-
1679
- // src/agents/prompt-builder.ts
1680
- function buildRetryContext(issue) {
1681
- const summaries = issue.previousAttemptSummaries;
1682
- if (!summaries || summaries.length === 0) return "";
1683
- const lines = ["## Previous Attempts\n"];
1684
- lines.push("The following previous attempts FAILED. Do NOT repeat the same approach. Try a fundamentally different strategy.\n");
1685
- for (let i = 0; i < summaries.length; i++) {
1686
- const s = summaries[i];
1687
- const phaseLabel = s.phase === "review" ? "review" : s.phase === "crash" ? "crash" : s.phase === "plan" ? "plan" : "execution";
1688
- lines.push(`### Attempt ${i + 1} \u2014 ${phaseLabel} failure (plan v${s.planVersion}, exec #${s.executeAttempt})`);
1689
- if (s.phase === "review") {
1690
- lines.push("*The reviewer identified issues with the previous implementation. Focus on addressing the reviewer's feedback \u2014 do not redo work that was already approved.*");
1691
- } else if (s.phase === "crash") {
1692
- lines.push("*The agent process crashed or timed out. Simplify the approach \u2014 break the work into smaller steps.*");
1693
- }
1694
- if (s.insight) {
1695
- lines.push(`**Failure type:** ${s.insight.errorType}`);
1696
- lines.push(`**Root cause:** ${s.insight.rootCause}`);
1697
- if (s.insight.failedCommand) lines.push(`**Failed command:** \`${s.insight.failedCommand}\``);
1698
- if (s.insight.filesInvolved.length > 0) {
1699
- lines.push(`**Files involved:** ${s.insight.filesInvolved.map((f) => `\`${f}\``).join(", ")}`);
1700
- }
1701
- lines.push(`**What to do differently:** ${s.insight.suggestion}`);
1702
- } else {
1703
- lines.push(`**Error:** ${s.error}`);
1704
- }
1705
- if (s.outputTail) {
1706
- lines.push(`
1707
- <details><summary>Output tail</summary>
1708
-
1709
- \`\`\`
1710
- ${s.outputTail}
1711
- \`\`\`
1712
- </details>`);
1713
- }
1714
- if (s.outputFile) {
1715
- lines.push(`*Full output saved in: outputs/${s.outputFile}*`);
1716
- }
1717
- lines.push("");
1718
- }
1719
- const full = lines.join("\n");
1720
- return full.length > 8e3 ? full.slice(0, 8e3) + "\n[...truncated]" : full;
1721
- }
1722
- async function buildPrompt(issue, _workflowDefinition) {
1723
- const rendered = await renderPrompt("workflow-default", { issue, attempt: issue.attempts || 0 });
1724
- if (!issue.plan?.steps?.length) {
1725
- return rendered;
1726
- }
1727
- const planSection = await renderPrompt("workflow-plan-section", {
1728
- estimatedComplexity: issue.plan.estimatedComplexity,
1729
- summary: issue.plan.summary,
1730
- steps: issue.plan.steps.map((step) => ({
1731
- step: step.step,
1732
- action: step.action,
1733
- files: step.files ?? [],
1734
- details: step.details ?? ""
1735
- }))
1736
- });
1737
- return `${rendered}
1738
-
1739
- ${planSection}`;
1740
- }
1741
- async function buildTurnPrompt(issue, basePrompt, previousOutput, turnIndex, maxTurns, nextPrompt) {
1742
- if (turnIndex === 1) return basePrompt;
1743
- return renderPrompt("agent-turn", {
1744
- issueIdentifier: issue.identifier,
1745
- turnIndex,
1746
- maxTurns,
1747
- basePrompt,
1748
- continuation: nextPrompt.trim() || "Continue the work, inspect the workspace, and move the issue toward completion.",
1749
- outputTail: previousOutput.trim() || "No previous output captured."
1750
- });
1751
- }
1752
- async function buildProviderBasePrompt(provider, issue, basePrompt, workspacePath, skillContext, capabilitiesManifest) {
1753
- return renderPrompt("agent-provider-base", {
1754
- isPlanner: provider.role === "planner",
1755
- isReviewer: provider.role === "reviewer",
1756
- hasImpeccableOverlay: provider.overlays?.includes("impeccable") ?? false,
1757
- hasFrontendDesignOverlay: provider.overlays?.includes("frontend-design") ?? false,
1758
- profileInstructions: provider.profileInstructions || "",
1759
- skillContext,
1760
- capabilitiesManifest: capabilitiesManifest || "",
1761
- capabilityCategory: "",
1762
- selectionReason: provider.selectionReason ?? "",
1763
- overlays: provider.overlays ?? [],
1764
- targetPaths: issue.paths ?? [],
1765
- workspacePath,
1766
- basePrompt
1767
- });
1768
- }
1769
-
1770
- // src/domains/workspace.ts
1771
- var SKIP_DIRS = /* @__PURE__ */ new Set([
1772
- ".git",
1773
- ".fifony",
1774
- "node_modules",
1775
- ".venv",
1776
- "data",
1777
- "dist",
1778
- "build",
1779
- ".turbo",
1780
- ".next",
1781
- ".nuxt",
1782
- ".tanstack",
1783
- "coverage",
1784
- "artifacts",
1785
- "captures",
1786
- "tmp",
1787
- "temp"
1788
- ]);
1789
- function shouldSkipPath(relativePath) {
1790
- const parts = relativePath.split("/");
1791
- if (parts.some((segment) => SKIP_DIRS.has(segment))) return true;
1792
- const base = parts.at(-1) ?? "";
1793
- if (base.startsWith("map_scan_") && extname2(base) === ".json") return true;
1794
- if (extname2(base) === ".xlsx") return true;
1795
- return false;
1796
- }
1797
- function bootstrapSource() {
1798
- if (existsSync7(SOURCE_MARKER)) return;
1799
- logger.info("Creating local source snapshot for Fifony (local-only runtime)...");
1800
- const copyRecursive = (source, target, rel = "") => {
1801
- mkdirSync(target, { recursive: true });
1802
- const items = readdirSync(source, { withFileTypes: true });
1803
- for (const item of items) {
1804
- const nextRel = rel ? `${rel}/${item.name}` : item.name;
1805
- if (shouldSkipPath(nextRel)) continue;
1806
- const sourcePath = `${source}/${item.name}`;
1807
- const targetPath = `${target}/${item.name}`;
1808
- const itemStat = statSync(sourcePath);
1809
- if (item.isDirectory()) {
1810
- copyRecursive(sourcePath, targetPath, nextRel);
1811
- continue;
1812
- }
1813
- if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
1814
- if (itemStat.isFile() || itemStat.isFIFO()) {
1815
- try {
1816
- const file = readFileSync4(sourcePath);
1817
- writeFileSync2(targetPath, file);
1818
- } catch (error) {
1819
- if (error.code === "ENOENT") {
1820
- logger.debug(`Skipped missing source file: ${sourcePath}`);
1821
- } else {
1822
- throw error;
1823
- }
1824
- }
1825
- }
1826
- }
1827
- };
1828
- mkdirSync(SOURCE_ROOT, { recursive: true });
1829
- copyRecursive(TARGET_ROOT, SOURCE_ROOT);
1830
- writeFileSync2(SOURCE_MARKER, `${now()}
1831
- `, "utf8");
1832
- }
1833
- var sourceReadyPromise = null;
1834
- var skipSourceFlag = false;
1835
- function setSkipSource(skip) {
1836
- skipSourceFlag = skip;
1837
- }
1838
- async function ensureSourceReady(onProgress) {
1839
- if (skipSourceFlag) {
1840
- onProgress?.("ready");
1841
- return;
1842
- }
1843
- if (existsSync7(SOURCE_MARKER)) {
1844
- onProgress?.("ready");
1845
- return;
1846
- }
1847
- if (sourceReadyPromise) return sourceReadyPromise;
1848
- sourceReadyPromise = (async () => {
1849
- onProgress?.("copying");
1850
- logger.info("Creating local source snapshot (async) for Fifony...");
1851
- const copyRecursiveAsync = async (source, target, rel = "") => {
1852
- await mkdir(target, { recursive: true });
1853
- const items = await readdir(source, { withFileTypes: true });
1854
- for (const item of items) {
1855
- const nextRel = rel ? `${rel}/${item.name}` : item.name;
1856
- if (shouldSkipPath(nextRel)) continue;
1857
- const sourcePath = `${source}/${item.name}`;
1858
- const targetPath = `${target}/${item.name}`;
1859
- const itemStat = await stat(sourcePath);
1860
- if (item.isDirectory()) {
1861
- await copyRecursiveAsync(sourcePath, targetPath, nextRel);
1862
- continue;
1863
- }
1864
- if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
1865
- if (itemStat.isFile() || itemStat.isFIFO()) {
1866
- try {
1867
- await copyFile(sourcePath, targetPath);
1868
- } catch (error) {
1869
- if (error.code === "ENOENT") {
1870
- logger.debug(`Skipped missing source file: ${sourcePath}`);
1871
- } else {
1872
- throw error;
1873
- }
1874
- }
1875
- }
1876
- }
1877
- };
1878
- await mkdir(SOURCE_ROOT, { recursive: true });
1879
- await copyRecursiveAsync(TARGET_ROOT, SOURCE_ROOT);
1880
- await writeFile(SOURCE_MARKER, `${now()}
1881
- `, "utf8");
1882
- onProgress?.("ready");
1883
- logger.info("Source snapshot ready (async).");
1884
- })();
1885
- return sourceReadyPromise;
1886
- }
1887
- function getGitRepoStatus(dir) {
1888
- const isGit = (() => {
1889
- try {
1890
- execSync("git rev-parse --git-dir", { cwd: dir, stdio: "pipe" });
1891
- return true;
1892
- } catch {
1893
- return false;
1894
- }
1895
- })();
1896
- if (!isGit) {
1897
- return { isGit: false, hasCommits: false, branch: null };
1898
- }
1899
- const branch = (() => {
1900
- try {
1901
- return execSync("git symbolic-ref --short HEAD", { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim() || null;
1902
- } catch {
1903
- try {
1904
- return execSync("git rev-parse --abbrev-ref HEAD", { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim() || null;
1905
- } catch {
1906
- return null;
1907
- }
1908
- }
1909
- })();
1910
- const hasCommits = (() => {
1911
- try {
1912
- execSync("git rev-parse --verify HEAD", { cwd: dir, stdio: "pipe" });
1913
- return true;
1914
- } catch {
1915
- return false;
1916
- }
1917
- })();
1918
- let isClean = true;
1919
- let untrackedCount = 0;
1920
- if (hasCommits) {
1921
- try {
1922
- const porcelain = execSync("git status --porcelain", { cwd: dir, encoding: "utf8", timeout: 5e3 }).trim();
1923
- isClean = porcelain.length === 0;
1924
- untrackedCount = porcelain.split("\n").filter((l) => l.startsWith("??")).length;
1925
- } catch {
1926
- }
1927
- }
1928
- return { isGit: true, hasCommits, branch, isClean, untrackedCount };
1929
- }
1930
- function gitRequirementMessage(action) {
1931
- return `fifony requires a git repository with at least one commit to ${action}. Initialize git in this project and create an initial commit, or use the onboarding Setup step.`;
1932
- }
1933
- function ensureGitRepoReadyForWorktrees(dir, action = "run issue worktrees") {
1934
- const status = getGitRepoStatus(dir);
1935
- if (!status.isGit) {
1936
- throw new Error(gitRequirementMessage(action));
1937
- }
1938
- if (!status.hasCommits) {
1939
- throw new Error(`fifony requires at least one commit to ${action} because git worktree needs a base commit. Create an initial commit, then retry.`);
1940
- }
1941
- return status;
1942
- }
1943
- function initializeGitRepoForWorktrees(dir) {
1944
- let status = getGitRepoStatus(dir);
1945
- if (!status.isGit) {
1946
- try {
1947
- execSync("git init -b main", { cwd: dir, stdio: "pipe" });
1948
- } catch {
1949
- execSync("git init", { cwd: dir, stdio: "pipe" });
1950
- }
1951
- status = getGitRepoStatus(dir);
1952
- }
1953
- if (!status.hasCommits) {
1954
- execSync(
1955
- 'git -c user.name="fifony" -c user.email="fifony@local.invalid" commit --allow-empty -m "Initial commit"',
1956
- { cwd: dir, stdio: "pipe" }
1957
- );
1958
- status = getGitRepoStatus(dir);
1959
- }
1960
- return status;
1961
- }
1962
- function assertIssueHasGitWorktree(issue, action) {
1963
- if (!issue.branchName || !issue.baseBranch || !issue.worktreePath) {
1964
- throw new Error(
1965
- `Issue ${issue.identifier} has no git worktree \u2014 cannot ${action}. This usually means the issue was executed before git was initialized for the project. Initialize git, then re-run the issue.`
1966
- );
1967
- }
1968
- }
1969
- function detectDefaultBranch(dir) {
1970
- try {
1971
- const current = execSync("git rev-parse --abbrev-ref HEAD", { cwd: dir, encoding: "utf8" }).trim();
1972
- if (current && current !== "HEAD") return current;
1973
- const remote = execSync("git symbolic-ref refs/remotes/origin/HEAD", { cwd: dir, encoding: "utf8" }).trim();
1974
- return remote.replace("refs/remotes/origin/", "");
1975
- } catch {
1976
- return "main";
1977
- }
1978
- }
1979
- var CLI_CONFIG_DIRS = [".claude", ".codex", ".gemini"];
1980
- var CLI_CONFIG_FILES = ["CLAUDE.md"];
1981
- function copyCliConfigDirs(sourceRoot, worktreePath) {
1982
- for (const dir of CLI_CONFIG_DIRS) {
1983
- const src = join7(sourceRoot, dir);
1984
- const dst = join7(worktreePath, dir);
1985
- if (existsSync7(src) && statSync(src).isDirectory() && !existsSync7(dst)) {
1986
- try {
1987
- execSync(`cp -R "${src}" "${dst}"`, { stdio: "pipe", timeout: 1e4 });
1988
- logger.debug({ dir, worktreePath }, "[Workspace] Copied CLI config dir to worktree");
1989
- } catch (err) {
1990
- logger.warn({ err: String(err), dir }, "[Workspace] Failed to copy CLI config dir");
1991
- }
1992
- }
1993
- }
1994
- for (const file of CLI_CONFIG_FILES) {
1995
- const src = join7(sourceRoot, file);
1996
- const dst = join7(worktreePath, file);
1997
- if (existsSync7(src) && !existsSync7(dst)) {
1998
- try {
1999
- execSync(`cp "${src}" "${dst}"`, { stdio: "pipe", timeout: 5e3 });
2000
- logger.debug({ file, worktreePath }, "[Workspace] Copied CLI config file to worktree");
2001
- } catch (err) {
2002
- logger.warn({ err: String(err), file }, "[Workspace] Failed to copy CLI config file");
2003
- }
2004
- }
2005
- }
2006
- }
2007
- function isGitWorkingTree(dir) {
2008
- try {
2009
- execSync("git rev-parse --git-dir", { cwd: dir, stdio: "pipe", timeout: 5e3 });
2010
- return true;
2011
- } catch {
2012
- return false;
2013
- }
2014
- }
2015
- function resolveTestWorkspacePath(issue) {
2016
- const workspaceRoot = issue.workspacePath ?? join7(WORKSPACE_ROOT, idToSafePath(issue.id));
2017
- return join7(workspaceRoot, "test-worktree");
2018
- }
2019
- function createTestWorkspace(issue) {
2020
- ensureGitRepoReadyForWorktrees(TARGET_ROOT, "create isolated test workspaces");
2021
- assertIssueHasGitWorktree(issue, "create a test workspace");
2022
- const workspaceRoot = issue.workspacePath ?? join7(WORKSPACE_ROOT, idToSafePath(issue.id));
2023
- const testWorkspacePath = issue.testWorkspacePath ?? resolveTestWorkspacePath(issue);
2024
- mkdirSync(workspaceRoot, { recursive: true });
2025
- if (existsSync7(testWorkspacePath)) {
2026
- if (isGitWorkingTree(testWorkspacePath)) {
2027
- issue.testWorkspacePath = testWorkspacePath;
2028
- issue.testApplied = true;
2029
- return testWorkspacePath;
2030
- }
2031
- rmSync2(testWorkspacePath, { recursive: true, force: true });
2032
- }
2033
- try {
2034
- execSync(`git worktree add --detach "${testWorkspacePath}" "${issue.branchName}"`, {
2035
- cwd: TARGET_ROOT,
2036
- stdio: "pipe",
2037
- timeout: 3e4
2038
- });
2039
- } catch (err) {
2040
- const msg = err.stderr || err.stdout || String(err);
2041
- throw new Error(`Failed to create isolated test workspace: ${msg}`);
2042
- }
2043
- copyCliConfigDirs(TARGET_ROOT, testWorkspacePath);
2044
- issue.testWorkspacePath = testWorkspacePath;
2045
- issue.testApplied = true;
2046
- return testWorkspacePath;
2047
- }
2048
- function removeTestWorkspace(issue) {
2049
- const testWorkspacePath = issue.testWorkspacePath;
2050
- issue.testApplied = false;
2051
- issue.testWorkspacePath = void 0;
2052
- if (!testWorkspacePath) return;
2053
- try {
2054
- execSync(`git worktree remove --force "${testWorkspacePath}"`, {
2055
- cwd: TARGET_ROOT,
2056
- stdio: "pipe",
2057
- timeout: 3e4
2058
- });
2059
- logger.info({ issueId: issue.id, testWorkspacePath }, "[Workspace] Removed isolated test workspace");
2060
- return;
2061
- } catch (error) {
2062
- logger.warn({ issueId: issue.id, testWorkspacePath, err: String(error) }, "[Workspace] Failed to remove isolated test workspace via git worktree");
2063
- }
2064
- try {
2065
- rmSync2(testWorkspacePath, { recursive: true, force: true });
2066
- } catch (error) {
2067
- logger.warn({ issueId: issue.id, testWorkspacePath, err: String(error) }, "[Workspace] Failed to remove isolated test workspace directory");
2068
- }
2069
- }
2070
- async function createGitWorktree(issue, worktreePath, baseBranch) {
2071
- let headCommitAtStart = "";
2072
- const resolvedBaseBranch = baseBranch ?? detectDefaultBranch(TARGET_ROOT);
2073
- try {
2074
- headCommitAtStart = execSync("git rev-parse HEAD", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
2075
- } catch {
2076
- }
2077
- const branchName = `fifony/${issue.id}`;
2078
- execSync(`git worktree add "${worktreePath}" -B "${branchName}"`, {
2079
- cwd: TARGET_ROOT,
2080
- stdio: "pipe"
2081
- });
2082
- try {
2083
- const gitFileContent = readFileSync4(join7(worktreePath, ".git"), "utf8").trim();
2084
- const gitDirRel = gitFileContent.replace("gitdir: ", "").trim();
2085
- const gitDirPath = resolve(worktreePath, gitDirRel);
2086
- mkdirSync(join7(gitDirPath, "info"), { recursive: true });
2087
- writeFileSync2(join7(gitDirPath, "info", "exclude"), "fifony-*\n.fifony-*\nfifony_*\n", "utf8");
2088
- } catch (err) {
2089
- logger.warn({ err: String(err) }, "[Agent] Failed to write worktree excludes");
2090
- }
2091
- issue.branchName = branchName;
2092
- issue.baseBranch = resolvedBaseBranch;
2093
- issue.headCommitAtStart = headCommitAtStart;
2094
- issue.worktreePath = worktreePath;
2095
- copyCliConfigDirs(TARGET_ROOT, worktreePath);
2096
- logger.debug({ issueId: issue.id, branchName, baseBranch: resolvedBaseBranch, worktreePath }, "[Agent] Git worktree created");
2097
- }
2098
- async function prepareWorkspace(issue, state, defaultBranch) {
2099
- const safeId = idToSafePath(issue.id);
2100
- const workspaceRoot = join7(WORKSPACE_ROOT, safeId);
2101
- const worktreePath = join7(workspaceRoot, "worktree");
2102
- const createdNow = !existsSync7(worktreePath);
2103
- if (createdNow) {
2104
- mkdirSync(workspaceRoot, { recursive: true });
2105
- logger.debug({ issueId: issue.id, identifier: issue.identifier, workspacePath: workspaceRoot }, "[Agent] Creating workspace");
2106
- ensureGitRepoReadyForWorktrees(TARGET_ROOT, "execute issues");
2107
- if (state.config.afterCreateHook) {
2108
- mkdirSync(worktreePath, { recursive: true });
2109
- await runHook(state.config.afterCreateHook, worktreePath, issue, "after_create");
2110
- } else {
2111
- await createGitWorktree(issue, worktreePath, defaultBranch);
2112
- }
2113
- logger.debug({ issueId: issue.id, workspacePath: workspaceRoot, worktreePath }, "[Agent] Workspace created");
2114
- } else {
2115
- logger.debug({ issueId: issue.id, workspacePath: workspaceRoot }, "[Agent] Reusing existing workspace");
2116
- }
2117
- const metaPath = join7(workspaceRoot, "issue.json");
2118
- const promptText = await buildPrompt(issue, null);
2119
- const promptFile = join7(workspaceRoot, "prompt.md");
2120
- writeFileSync2(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
2121
- writeFileSync2(promptFile, `${promptText}
2122
- `, "utf8");
2123
- issue.workspacePath = workspaceRoot;
2124
- issue.worktreePath = worktreePath;
2125
- issue.workspacePreparedAt = now();
2126
- return { workspacePath: workspaceRoot, promptText, promptFile };
2127
- }
2128
- async function cleanWorkspace(issueId, issue, state) {
2129
- const safeId = idToSafePath(issueId);
2130
- const workspacePath = issue?.workspacePath ?? join7(WORKSPACE_ROOT, safeId);
2131
- if (!existsSync7(workspacePath)) return;
2132
- if (state.config.beforeRemoveHook) {
2133
- try {
2134
- const dummyIssue = issue ?? { id: issueId, identifier: issueId };
2135
- await runHook(state.config.beforeRemoveHook, workspacePath, dummyIssue, "before_remove");
2136
- } catch (error) {
2137
- logger.warn(`before_remove hook failed for ${issueId}: ${String(error)}`);
2138
- }
2139
- }
2140
- if (issue?.testWorkspacePath) {
2141
- removeTestWorkspace(issue);
2142
- }
2143
- if (issue?.branchName && issue.worktreePath) {
2144
- try {
2145
- execSync(`git worktree remove --force "${issue.worktreePath}"`, { cwd: TARGET_ROOT, stdio: "pipe" });
2146
- logger.info(`Removed worktree for ${issueId}: ${issue.worktreePath}`);
2147
- } catch (error) {
2148
- logger.warn(`Failed to remove worktree for ${issueId}: ${String(error)}`);
2149
- try {
2150
- rmSync2(issue.worktreePath, { recursive: true, force: true });
2151
- } catch {
2152
- }
2153
- }
2154
- try {
2155
- execSync(`git branch -D "${issue.branchName}"`, { cwd: TARGET_ROOT, stdio: "pipe" });
2156
- } catch {
2157
- }
2158
- try {
2159
- rmSync2(workspacePath, { recursive: true, force: true });
2160
- } catch {
2161
- }
2162
- return;
2163
- }
2164
- try {
2165
- rmSync2(workspacePath, { recursive: true, force: true });
2166
- logger.info(`Cleaned workspace for ${issueId}: ${workspacePath}`);
2167
- } catch (error) {
2168
- logger.warn(`Failed to clean workspace for ${issueId}: ${String(error)}`);
2169
- }
2170
- }
2171
- function inferChangedWorkspacePaths(workspacePath, limit = 32, issue) {
2172
- if (!issue?.baseBranch || !issue.branchName) return [];
2173
- try {
2174
- const output = execSync(
2175
- `git diff --name-only "${issue.baseBranch}"..."${issue.branchName}"`,
2176
- { cwd: TARGET_ROOT, encoding: "utf8", timeout: 1e4, stdio: "pipe" }
2177
- );
2178
- return output.trim().split("\n").filter(Boolean).slice(0, limit);
2179
- } catch {
2180
- return [];
2181
- }
2182
- }
2183
- function computeDiffStats(issue) {
2184
- if (!issue.baseBranch || !issue.branchName) return;
2185
- try {
2186
- let raw = "";
2187
- try {
2188
- raw = execSync(
2189
- `git diff --stat "${issue.baseBranch}"..."${issue.branchName}"`,
2190
- { cwd: TARGET_ROOT, encoding: "utf8", maxBuffer: 512e3, timeout: 1e4, stdio: "pipe" }
2191
- );
2192
- } catch (err) {
2193
- raw = err.stdout || "";
2194
- }
2195
- if (raw) parseDiffStats(issue, raw);
2196
- } catch {
2197
- }
2198
- }
2199
- function parseDiffStats(issue, raw) {
2200
- const lines = raw.trim().split("\n");
2201
- const summary = lines[lines.length - 1] || "";
2202
- const filesMatch = summary.match(/(\d+)\s+files?\s+changed/);
2203
- const addMatch = summary.match(/(\d+)\s+insertions?\(\+\)/);
2204
- const delMatch = summary.match(/(\d+)\s+deletions?\(-\)/);
2205
- const internalRe = /fifony[-_]|\.fifony-|WORKFLOW\.local/;
2206
- const fileLines = lines.slice(0, -1).filter((l) => {
2207
- const name = l.trim().split("|")[0]?.trim().split("/").pop() || "";
2208
- return !internalRe.test(name);
2209
- });
2210
- const regexFiles = filesMatch ? parseInt(filesMatch[1], 10) : 0;
2211
- issue.filesChanged = fileLines.length > 0 ? fileLines.length : regexFiles;
2212
- issue.linesAdded = addMatch ? parseInt(addMatch[1], 10) : 0;
2213
- issue.linesRemoved = delMatch ? parseInt(delMatch[1], 10) : 0;
2214
- }
2215
- async function syncIssueDiffStatsToStore(issue) {
2216
- if (!issue?.id) return;
2217
- const { getIssueStateResource } = await import("./store-4HCGBN4L.js");
2218
- const issueResource = getIssueStateResource();
2219
- if (!issueResource) return;
2220
- const toNumber = (value) => {
2221
- const parsed = typeof value === "number" ? value : Number(value ?? 0);
2222
- return Number.isFinite(parsed) ? parsed : 0;
2223
- };
2224
- const nextLinesAdded = toNumber(issue.linesAdded);
2225
- const nextLinesRemoved = toNumber(issue.linesRemoved);
2226
- const nextFilesChanged = toNumber(issue.filesChanged);
2227
- if (nextLinesAdded === 0 && nextLinesRemoved === 0 && nextFilesChanged === 0 && !issue.branchName) {
2228
- return;
2229
- }
2230
- const current = await issueResource.get?.(issue.id).catch(() => null);
2231
- const previousLinesAdded = toNumber(current?.linesAdded);
2232
- const previousLinesRemoved = toNumber(current?.linesRemoved);
2233
- const previousFilesChanged = toNumber(current?.filesChanged);
2234
- await issueResource.patch(issue.id, {
2235
- linesAdded: nextLinesAdded,
2236
- linesRemoved: nextLinesRemoved,
2237
- filesChanged: nextFilesChanged,
2238
- branchName: issue.branchName
2239
- });
2240
- const add = issueResource.add;
2241
- const sub = issueResource.sub;
2242
- if (typeof add !== "function" || typeof sub !== "function") {
2243
- logger.debug({ issueId: issue.id }, "[DiffStats] resource.add/sub not available \u2014 EC plugin may not be installed");
2244
- return;
2245
- }
2246
- const deltaAdded = nextLinesAdded - previousLinesAdded;
2247
- const deltaRemoved = nextLinesRemoved - previousLinesRemoved;
2248
- const deltaFiles = nextFilesChanged - previousFilesChanged;
2249
- if (deltaAdded === 0 && deltaRemoved === 0 && deltaFiles === 0) {
2250
- logger.debug({ issueId: issue.id, nextLinesAdded, previousLinesAdded }, "[DiffStats] No delta to send to EC (values already synced)");
2251
- return;
2252
- }
2253
- logger.debug({ issueId: issue.id, deltaAdded, deltaRemoved, deltaFiles }, "[DiffStats] Sending deltas to EC");
2254
- const applyDelta = async (field, delta) => {
2255
- if (delta > 0) {
2256
- await add.call(issueResource, issue.id, field, delta);
2257
- } else if (delta < 0) {
2258
- await sub.call(issueResource, issue.id, field, Math.abs(delta));
2259
- }
2260
- };
2261
- await Promise.all([
2262
- applyDelta("linesAdded", deltaAdded),
2263
- applyDelta("linesRemoved", deltaRemoved),
2264
- applyDelta("filesChanged", deltaFiles)
2265
- ]);
2266
- }
2267
- function ensureWorktreeCommitted(issue) {
2268
- const worktreePath = issue.worktreePath;
2269
- if (!worktreePath || !issue.branchName) return;
2270
- execSync("git add -A", { cwd: worktreePath, stdio: "pipe" });
2271
- const statusBeforeCommit = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf8" }).trim();
2272
- if (!statusBeforeCommit) return;
2273
- try {
2274
- execSync(`git commit -m "fifony: agent changes for ${issue.identifier}"`, { cwd: worktreePath, stdio: "pipe" });
2275
- } catch (error) {
2276
- const remaining = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf8" }).trim();
2277
- if (remaining) {
2278
- throw new Error(`Failed to commit agent changes for ${issue.identifier}: ${String(error)}`);
2279
- }
2280
- }
2281
- const statusAfterCommit = execSync("git status --porcelain", { cwd: worktreePath, encoding: "utf8" }).trim();
2282
- if (statusAfterCommit) {
2283
- throw new Error(`Worktree for ${issue.identifier} still has uncommitted changes after commit.`);
2284
- }
2285
- }
2286
- function mergeWorktree(issue, worktreePath, abortOnConflict = true) {
2287
- const result = { copied: [], deleted: [], skipped: [], conflicts: [] };
2288
- ensureWorktreeCommitted(issue);
2289
- const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
2290
- if (currentBranch !== issue.baseBranch) {
2291
- throw new Error(`Cannot merge ${issue.identifier}: current branch is ${currentBranch}, expected ${issue.baseBranch}.`);
2292
- }
2293
- const targetStatus = execSync("git status --porcelain", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
2294
- if (targetStatus) {
2295
- throw new Error(`Cannot merge ${issue.identifier}: target repository has uncommitted changes.`);
2296
- }
2297
- try {
2298
- const diffOut = execSync(
2299
- `git diff --name-status "${issue.baseBranch}"..."${issue.branchName}"`,
2300
- { cwd: TARGET_ROOT, encoding: "utf8" }
2301
- );
2302
- for (const line of diffOut.trim().split("\n").filter(Boolean)) {
2303
- const [statusChar, ...parts] = line.split(" ");
2304
- const filePath = parts.join(" ");
2305
- if (statusChar === "D") result.deleted.push(filePath);
2306
- else result.copied.push(filePath);
2307
- }
2308
- } catch {
2309
- }
2310
- try {
2311
- execSync(
2312
- `git merge --no-ff "${issue.branchName}" -m "fifony: merge ${issue.identifier}"`,
2313
- { cwd: TARGET_ROOT, stdio: "pipe" }
2314
- );
2315
- } catch (err) {
2316
- try {
2317
- const conflictOut = execSync(
2318
- "git diff --name-only --diff-filter=U",
2319
- { cwd: TARGET_ROOT, encoding: "utf8" }
2320
- );
2321
- result.conflicts.push(...conflictOut.trim().split("\n").filter(Boolean));
2322
- } catch {
2323
- }
2324
- if (abortOnConflict) {
2325
- try {
2326
- execSync("git merge --abort", { cwd: TARGET_ROOT, stdio: "pipe" });
2327
- } catch {
2328
- }
2329
- logger.warn({ issueId: issue.id, err: String(err) }, "[Agent] Git merge failed, aborted");
2330
- } else {
2331
- logger.info({ issueId: issue.id, conflicts: result.conflicts }, "[Agent] Git merge has conflicts \u2014 leaving markers for agent resolution");
2332
- }
2333
- }
2334
- return result;
2335
- }
2336
- function shouldSkipMergePath(relativePath) {
2337
- const parts = relativePath.split("/");
2338
- if (parts.some((s) => s === ".git" || s === "node_modules" || s === ".fifony" || s === "dist" || s === ".tanstack")) {
2339
- return true;
2340
- }
2341
- const base = parts.at(-1) ?? "";
2342
- return base === "WORKFLOW.local.md" || base === ".fifony-env.sh" || base === ".fifony-compiled-env.sh" || base === ".fifony-local-source-ready" || base.startsWith("fifony-") || base.startsWith("fifony_");
2343
- }
2344
- function mergeWorkspace(issue, abortOnConflict = true) {
2345
- ensureGitRepoReadyForWorktrees(TARGET_ROOT, "merge issues");
2346
- assertIssueHasGitWorktree(issue, "merge");
2347
- return mergeWorktree(issue, issue.worktreePath, abortOnConflict);
2348
- }
2349
- function dryMerge(issue) {
2350
- ensureGitRepoReadyForWorktrees(TARGET_ROOT, "preview merges");
2351
- assertIssueHasGitWorktree(issue, "preview merge");
2352
- ensureWorktreeCommitted(issue);
2353
- const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
2354
- if (currentBranch !== issue.baseBranch) {
2355
- throw new Error(`Cannot preview merge: current branch is ${currentBranch}, expected ${issue.baseBranch}.`);
2356
- }
2357
- const targetStatus = execSync("git status --porcelain", { cwd: TARGET_ROOT, encoding: "utf8" }).trim();
2358
- if (targetStatus) {
2359
- throw new Error(`Cannot preview merge: target repository has uncommitted changes.`);
2360
- }
2361
- let conflictFiles = [];
2362
- let willConflict = false;
2363
- try {
2364
- execSync(
2365
- `git merge --no-commit --no-ff "${issue.branchName}"`,
2366
- { cwd: TARGET_ROOT, stdio: "pipe" }
2367
- );
2368
- } catch {
2369
- willConflict = true;
2370
- try {
2371
- const conflictOut = execSync(
2372
- "git diff --name-only --diff-filter=U",
2373
- { cwd: TARGET_ROOT, encoding: "utf8" }
2374
- );
2375
- conflictFiles = conflictOut.trim().split("\n").filter(Boolean);
2376
- } catch {
2377
- }
2378
- }
2379
- try {
2380
- execSync("git merge --abort", { cwd: TARGET_ROOT, stdio: "pipe" });
2381
- } catch {
2382
- try {
2383
- execSync("git reset --merge ORIG_HEAD", { cwd: TARGET_ROOT, stdio: "pipe" });
2384
- } catch {
2385
- try {
2386
- execSync("git reset --merge", { cwd: TARGET_ROOT, stdio: "pipe" });
2387
- } catch (error) {
2388
- logger.warn({ issueId: issue.id, err: String(error) }, "[Workspace] Failed to safely clean dry-merge state");
2389
- }
2390
- }
2391
- }
2392
- let changedFiles = 0;
2393
- try {
2394
- const diffOut = execSync(
2395
- `git diff --name-only "${issue.baseBranch}"..."${issue.branchName}"`,
2396
- { cwd: TARGET_ROOT, encoding: "utf8" }
2397
- );
2398
- changedFiles = diffOut.trim().split("\n").filter(Boolean).length;
2399
- } catch {
2400
- }
2401
- return { willConflict, conflictFiles, canMerge: !willConflict, changedFiles };
2402
- }
2403
- function rebaseWorktree(issue) {
2404
- ensureGitRepoReadyForWorktrees(TARGET_ROOT, "rebase worktrees");
2405
- assertIssueHasGitWorktree(issue, "rebase");
2406
- ensureWorktreeCommitted(issue);
2407
- try {
2408
- execSync(
2409
- `git rebase "${issue.baseBranch}"`,
2410
- { cwd: issue.worktreePath, stdio: "pipe" }
2411
- );
2412
- return { success: true, conflictFiles: [] };
2413
- } catch {
2414
- let conflictFiles = [];
2415
- try {
2416
- const conflictOut = execSync(
2417
- "git diff --name-only --diff-filter=U",
2418
- { cwd: issue.worktreePath, encoding: "utf8" }
2419
- );
2420
- conflictFiles = conflictOut.trim().split("\n").filter(Boolean);
2421
- } catch {
2422
- }
2423
- try {
2424
- execSync("git rebase --abort", { cwd: issue.worktreePath, stdio: "pipe" });
2425
- } catch {
2426
- }
2427
- return { success: false, conflictFiles };
2428
- }
2429
- }
2430
- function hydrateIssuePathsFromWorkspace(issue) {
2431
- const inferredPaths = inferChangedWorkspacePaths(issue.workspacePath ?? "", 32, issue);
2432
- if (inferredPaths.length === 0) return [];
2433
- issue.paths = [.../* @__PURE__ */ new Set([...issue.paths ?? [], ...inferredPaths])];
2434
- return inferredPaths;
2435
- }
2436
- function writeVersionedArtifacts(workspacePath, prefix, planVersion, attempt, sources) {
2437
- const { writeFileSync: _wfs, readFileSync: _rfs, existsSync: _es } = { writeFileSync: writeFileSync2, readFileSync: readFileSync4, existsSync: existsSync7 };
2438
- for (const { srcFile, destSuffix } of sources) {
2439
- const src = join7(workspacePath, srcFile);
2440
- if (_es(src)) {
2441
- _wfs(join7(workspacePath, `${prefix}.v${planVersion}a${attempt}.${destSuffix}`), _rfs(src, "utf8"), "utf8");
2442
- }
2443
- }
2364
+ function getSessionProvidersForIssue(state, issue, workflowConfig) {
2365
+ return [
2366
+ ...getExecutionProviders(state, issue, workflowConfig ?? null),
2367
+ getReviewProvider(state, issue, workflowConfig ?? null)
2368
+ ];
2444
2369
  }
2445
2370
 
2446
2371
  export {
2372
+ markIssueDirty,
2373
+ markMilestoneDirty,
2374
+ markIssuePlanDirty,
2375
+ markEventDirty,
2376
+ hasDirtyState,
2377
+ getDirtyIssueIds,
2378
+ getDirtyMilestoneIds,
2379
+ getDirtyEventIds,
2380
+ snapshotAndClearDirtyIssueIds,
2381
+ snapshotAndClearDirtyMilestoneIds,
2382
+ snapshotAndClearDirtyIssuePlanIds,
2383
+ snapshotAndClearDirtyEventIds,
2384
+ markAllIssuesDirty,
2385
+ markAllMilestonesDirty,
2386
+ markAllIssuePlansDirty,
2387
+ markAllEventsDirty,
2388
+ deriveReviewProfile,
2389
+ serializeReviewRouteSnapshot,
2390
+ applyHarnessModeToPlan,
2391
+ applyCheckpointPolicyToPlan,
2392
+ recommendCheckpointPolicyForIssue,
2393
+ recommendHarnessModeForIssue,
2394
+ normalizeAcceptanceCriteria,
2395
+ deriveExecutionContract,
2447
2396
  buildFullPlanPrompt,
2448
2397
  buildExecutionPayload,
2449
2398
  collectClaudeUsageFromCli,
@@ -2452,40 +2401,14 @@ export {
2452
2401
  ADAPTERS,
2453
2402
  discoverModels,
2454
2403
  normalizeAgentProvider,
2404
+ resolveProviderCapabilities,
2405
+ getProviderCapabilityWarnings,
2455
2406
  getProviderDefaultCommand,
2456
2407
  detectAvailableProviders,
2457
2408
  readCodexConfig,
2458
2409
  resolveDefaultProvider,
2459
- getEffectiveAgentProviders,
2460
- runCommandWithTimeout,
2461
- runHook,
2462
- buildRetryContext,
2463
- buildPrompt,
2464
- buildTurnPrompt,
2465
- buildProviderBasePrompt,
2466
- bootstrapSource,
2467
- setSkipSource,
2468
- ensureSourceReady,
2469
- getGitRepoStatus,
2470
- ensureGitRepoReadyForWorktrees,
2471
- initializeGitRepoForWorktrees,
2472
- assertIssueHasGitWorktree,
2473
- detectDefaultBranch,
2474
- createTestWorkspace,
2475
- removeTestWorkspace,
2476
- createGitWorktree,
2477
- prepareWorkspace,
2478
- cleanWorkspace,
2479
- inferChangedWorkspacePaths,
2480
- computeDiffStats,
2481
- parseDiffStats,
2482
- syncIssueDiffStatsToStore,
2483
- ensureWorktreeCommitted,
2484
- shouldSkipMergePath,
2485
- mergeWorkspace,
2486
- dryMerge,
2487
- rebaseWorktree,
2488
- hydrateIssuePathsFromWorkspace,
2489
- writeVersionedArtifacts
2410
+ getExecutionProviders,
2411
+ getReviewProvider,
2412
+ getSessionProvidersForIssue
2490
2413
  };
2491
- //# sourceMappingURL=chunk-NB44PCD2.js.map
2414
+ //# sourceMappingURL=chunk-FJNH3G2Z.js.map