fifony 0.1.13 → 0.1.14-next.6f02449

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.
@@ -86,6 +86,10 @@ var ALLOWED_STATES = [
86
86
  var TERMINAL_STATES = /* @__PURE__ */ new Set(["Done", "Cancelled"]);
87
87
  var EXECUTING_STATES = /* @__PURE__ */ new Set(["Running", "In Review"]);
88
88
  var PERSIST_EVENTS_MAX = 500;
89
+ var FAST_BOOT = CLI_ARGS.includes("--fast-boot");
90
+ var SKIP_SOURCE = FAST_BOOT || CLI_ARGS.includes("--skip-source");
91
+ var SKIP_SCAN = FAST_BOOT || CLI_ARGS.includes("--skip-scan");
92
+ var SKIP_RECOVERY = FAST_BOOT || CLI_ARGS.includes("--skip-recovery");
89
93
 
90
94
  // src/runtime/helpers.ts
91
95
  import { env as env2 } from "process";
@@ -296,7 +300,7 @@ var logger = {
296
300
  };
297
301
 
298
302
  // src/runtime/store.ts
299
- import { mkdirSync as mkdirSync3 } from "fs";
303
+ import { mkdirSync as mkdirSync4 } from "fs";
300
304
 
301
305
  // src/runtime/issues.ts
302
306
  import { env as env4 } from "process";
@@ -463,6 +467,50 @@ function getAnalytics(topN = 20) {
463
467
  };
464
468
  }
465
469
 
470
+ // src/runtime/dirty-tracker.ts
471
+ var dirtyIssueIds = /* @__PURE__ */ new Set();
472
+ var dirtyEventIds = /* @__PURE__ */ new Set();
473
+ function markIssueDirty(id) {
474
+ dirtyIssueIds.add(id);
475
+ }
476
+ function markEventDirty(id) {
477
+ dirtyEventIds.add(id);
478
+ }
479
+ function hasDirtyState() {
480
+ return dirtyIssueIds.size > 0 || dirtyEventIds.size > 0;
481
+ }
482
+ function getDirtyIssueIds() {
483
+ return dirtyIssueIds;
484
+ }
485
+ function getDirtyEventIds() {
486
+ return dirtyEventIds;
487
+ }
488
+ function clearDirtyIssueIds() {
489
+ dirtyIssueIds.clear();
490
+ }
491
+ function clearDirtyEventIds() {
492
+ dirtyEventIds.clear();
493
+ }
494
+ function markAllIssuesDirty(ids) {
495
+ for (const id of ids) dirtyIssueIds.add(id);
496
+ }
497
+ function markAllEventsDirty(ids) {
498
+ for (const id of ids) dirtyEventIds.add(id);
499
+ }
500
+
501
+ // src/runtime/metrics-cache.ts
502
+ var cachedMetrics = null;
503
+ var metricsStale = true;
504
+ function invalidateMetrics() {
505
+ metricsStale = true;
506
+ }
507
+ function getMetrics(issues) {
508
+ if (!metricsStale && cachedMetrics) return cachedMetrics;
509
+ cachedMetrics = computeMetrics2(issues);
510
+ metricsStale = false;
511
+ return cachedMetrics;
512
+ }
513
+
466
514
  // src/runtime/issue-state-machine.ts
467
515
  var ISSUE_STATE_MACHINE_ID = "issue-lifecycle";
468
516
  var ISSUE_STATE_MACHINE_DEFINITION = {
@@ -720,7 +768,13 @@ function getProviderDefaultCommand(provider, _reasoningEffort, model) {
720
768
  if (provider === "claude") return buildClaudeCommand({ model, jsonSchema: CLAUDE_RESULT_SCHEMA });
721
769
  return "";
722
770
  }
771
+ var cachedProviders = null;
772
+ var providersCachedAt = 0;
773
+ var PROVIDER_CACHE_TTL = 6e4;
723
774
  function detectAvailableProviders() {
775
+ if (cachedProviders && Date.now() - providersCachedAt < PROVIDER_CACHE_TTL) {
776
+ return cachedProviders;
777
+ }
724
778
  const providers = [];
725
779
  for (const name of ["claude", "codex"]) {
726
780
  try {
@@ -730,6 +784,8 @@ function detectAvailableProviders() {
730
784
  providers.push({ name, available: false, path: "" });
731
785
  }
732
786
  }
787
+ cachedProviders = providers;
788
+ providersCachedAt = Date.now();
733
789
  return providers;
734
790
  }
735
791
  var modelCache = /* @__PURE__ */ new Map();
@@ -1257,7 +1313,7 @@ function buildRuntimeState(previous, config, definition) {
1257
1313
  }
1258
1314
  }
1259
1315
  dedupHistoryEntries(mergedIssues);
1260
- const metrics = computeMetrics(mergedIssues);
1316
+ const metrics = computeMetrics2(mergedIssues);
1261
1317
  return {
1262
1318
  startedAt: previous?.startedAt ?? now(),
1263
1319
  updatedAt: now(),
@@ -1280,7 +1336,7 @@ function buildRuntimeState(previous, config, definition) {
1280
1336
  ]
1281
1337
  };
1282
1338
  }
1283
- function computeMetrics(issues) {
1339
+ function computeMetrics2(issues) {
1284
1340
  let queued = 0;
1285
1341
  let inProgress = 0;
1286
1342
  let blocked = 0;
@@ -1361,6 +1417,7 @@ function addEvent(state, issueId, kind, message) {
1361
1417
  at: now()
1362
1418
  };
1363
1419
  state.events = [event, ...state.events].slice(0, PERSIST_EVENTS_MAX);
1420
+ markEventDirty(event.id);
1364
1421
  try {
1365
1422
  recordEvent();
1366
1423
  } catch {
@@ -1371,6 +1428,8 @@ function transition(issue, target, note) {
1371
1428
  const previous = issue.state;
1372
1429
  issue.state = target;
1373
1430
  issue.updatedAt = now();
1431
+ markIssueDirty(issue.id);
1432
+ invalidateMetrics();
1374
1433
  issue.history.push(`[${issue.updatedAt}] ${note}`);
1375
1434
  if (previous === "Blocked" && target === "Todo") {
1376
1435
  issue.lastError = void 0;
@@ -1519,12 +1578,14 @@ function getApiRuntimeContextOrThrow() {
1519
1578
  // src/runtime/api-server.ts
1520
1579
  import { execSync as execSync3 } from "child_process";
1521
1580
  import {
1581
+ appendFileSync as appendFileSync2,
1522
1582
  closeSync,
1523
- existsSync as existsSync9,
1583
+ existsSync as existsSync10,
1524
1584
  openSync,
1525
- readFileSync as readFileSync8,
1585
+ readFileSync as readFileSync9,
1526
1586
  readSync,
1527
- statSync as statSync2
1587
+ statSync as statSync3,
1588
+ writeFileSync as writeFileSync8
1528
1589
  } from "fs";
1529
1590
 
1530
1591
  // src/runtime/resources/runtime-state.resource.ts
@@ -1552,16 +1613,16 @@ var runtime_state_resource_default = {
1552
1613
  import {
1553
1614
  appendFileSync,
1554
1615
  cpSync,
1555
- existsSync as existsSync4,
1556
- mkdirSync,
1557
- readdirSync as readdirSync2,
1558
- readFileSync as readFileSync3,
1616
+ existsSync as existsSync5,
1617
+ mkdirSync as mkdirSync2,
1618
+ readdirSync as readdirSync3,
1619
+ readFileSync as readFileSync4,
1559
1620
  rmSync,
1560
- statSync,
1561
- writeFileSync as writeFileSync2
1621
+ statSync as statSync2,
1622
+ writeFileSync as writeFileSync3
1562
1623
  } from "fs";
1563
- import { join as join7, relative } from "path";
1564
- import { env as env5 } from "process";
1624
+ import { join as join8, relative } from "path";
1625
+ import { env as env6 } from "process";
1565
1626
  import { execSync, spawn as spawn2 } from "child_process";
1566
1627
 
1567
1628
  // src/runtime/skills.ts
@@ -1608,9 +1669,141 @@ ${skill.content}`
1608
1669
  ${sections.join("\n\n")}`;
1609
1670
  }
1610
1671
 
1672
+ // src/runtime/workflow.ts
1673
+ import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync, writeFileSync } from "fs";
1674
+ import { copyFile, mkdir, readdir, stat, writeFile } from "fs/promises";
1675
+ import { extname } from "path";
1676
+ import { argv as argv2, exit } from "process";
1677
+ var sourceReadyPromise = null;
1678
+ var skipSourceFlag = false;
1679
+ function setSkipSource(skip) {
1680
+ skipSourceFlag = skip;
1681
+ }
1682
+ async function ensureSourceReady(onProgress) {
1683
+ if (skipSourceFlag) {
1684
+ onProgress?.("ready");
1685
+ return;
1686
+ }
1687
+ if (existsSync4(SOURCE_MARKER)) {
1688
+ onProgress?.("ready");
1689
+ return;
1690
+ }
1691
+ if (sourceReadyPromise) return sourceReadyPromise;
1692
+ sourceReadyPromise = (async () => {
1693
+ onProgress?.("copying");
1694
+ logger.info("Creating local source snapshot (async) for Fifony...");
1695
+ const skipDirs = /* @__PURE__ */ new Set([
1696
+ ".git",
1697
+ ".fifony",
1698
+ "node_modules",
1699
+ ".venv",
1700
+ "data",
1701
+ "dist",
1702
+ "build",
1703
+ ".turbo",
1704
+ ".next",
1705
+ ".nuxt",
1706
+ ".tanstack",
1707
+ "coverage",
1708
+ "artifacts",
1709
+ "captures",
1710
+ "tmp",
1711
+ "temp"
1712
+ ]);
1713
+ const shouldSkip = (relativePath) => {
1714
+ const parts = relativePath.split("/");
1715
+ if (parts.some((segment) => skipDirs.has(segment))) return true;
1716
+ const base = relativePath.split("/").at(-1) ?? "";
1717
+ if (base.startsWith("map_scan_") && extname(base) === ".json") return true;
1718
+ if (extname(base) === ".xlsx") return true;
1719
+ return false;
1720
+ };
1721
+ const copyRecursiveAsync = async (source, target, rel = "") => {
1722
+ await mkdir(target, { recursive: true });
1723
+ const items = await readdir(source, { withFileTypes: true });
1724
+ for (const item of items) {
1725
+ const nextRel = rel ? `${rel}/${item.name}` : item.name;
1726
+ if (shouldSkip(nextRel)) continue;
1727
+ const sourcePath = `${source}/${item.name}`;
1728
+ const targetPath = `${target}/${item.name}`;
1729
+ const itemStat = await stat(sourcePath);
1730
+ if (item.isDirectory()) {
1731
+ await copyRecursiveAsync(sourcePath, targetPath, nextRel);
1732
+ continue;
1733
+ }
1734
+ if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
1735
+ if (itemStat.isFile() || itemStat.isFIFO()) {
1736
+ try {
1737
+ await copyFile(sourcePath, targetPath);
1738
+ } catch (error) {
1739
+ if (error.code === "ENOENT") {
1740
+ logger.debug(`Skipped missing source file: ${sourcePath}`);
1741
+ } else {
1742
+ throw error;
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1747
+ };
1748
+ await mkdir(SOURCE_ROOT, { recursive: true });
1749
+ await copyRecursiveAsync(TARGET_ROOT, SOURCE_ROOT);
1750
+ await writeFile(SOURCE_MARKER, `${now()}
1751
+ `, "utf8");
1752
+ onProgress?.("ready");
1753
+ logger.info("Source snapshot ready (async).");
1754
+ })();
1755
+ return sourceReadyPromise;
1756
+ }
1757
+ function loadWorkflowDefinition() {
1758
+ const defaultPrompt = PROMPT_TEMPLATES["workflow-default"];
1759
+ return {
1760
+ workflowPath: "",
1761
+ rendered: "",
1762
+ config: {},
1763
+ promptTemplate: defaultPrompt,
1764
+ agentProvider: "codex",
1765
+ agentProfile: "",
1766
+ agentProfilePath: "",
1767
+ agentProfileInstructions: "",
1768
+ agentProviders: [],
1769
+ afterCreateHook: "",
1770
+ beforeRunHook: "",
1771
+ afterRunHook: "",
1772
+ beforeRemoveHook: ""
1773
+ };
1774
+ }
1775
+ function parsePort(args) {
1776
+ for (let i = 0; i < args.length; i += 1) {
1777
+ const arg = args[i];
1778
+ if (arg === "--help" || arg === "-h") {
1779
+ console.log(
1780
+ `Usage: ${argv2[1]} [options]
1781
+ Options:
1782
+ --port <n> Start local dashboard (default: no UI and single batch run)
1783
+ --workspace <path> Target workspace root (default: current directory)
1784
+ --persistence <path> Persistence root (default: current directory)
1785
+ --concurrency <n> Maximum number of parallel issue runners
1786
+ --attempts <n> Maximum attempts per issue
1787
+ --poll <ms> Polling interval for the scheduler
1788
+ --once Run one local batch and exit
1789
+ --help Show this message`
1790
+ );
1791
+ exit(0);
1792
+ }
1793
+ if (arg === "--port") {
1794
+ const value = args[i + 1];
1795
+ if (!value || !/^\d+$/.test(value)) {
1796
+ fail(`Invalid value for --port: ${value ?? "<empty>"}`);
1797
+ }
1798
+ return parseIntArg(value, 4040);
1799
+ }
1800
+ }
1801
+ return void 0;
1802
+ }
1803
+
1611
1804
  // src/runtime/adapters/index.ts
1612
- import { writeFileSync } from "fs";
1613
- import { join as join6 } from "path";
1805
+ import { writeFileSync as writeFileSync2 } from "fs";
1806
+ import { join as join7 } from "path";
1614
1807
 
1615
1808
  // src/runtime/adapters/shared.ts
1616
1809
  function buildPlanContextSection(plan) {
@@ -1862,7 +2055,7 @@ async function compileForClaude(issue, provider, plan, config, workspacePath, sk
1862
2055
  }
1863
2056
 
1864
2057
  // src/runtime/adapters/plan-to-codex.ts
1865
- import { join as join5 } from "path";
2058
+ import { join as join6 } from "path";
1866
2059
  var CODEX_RESULT_CONTRACT = `
1867
2060
  Return a JSON object with this exact schema when finished:
1868
2061
  {
@@ -1899,7 +2092,7 @@ async function compileForCodex(issue, provider, plan, config, workspacePath, ski
1899
2092
  outputContract: CODEX_RESULT_CONTRACT
1900
2093
  });
1901
2094
  const relativeDirs = extractPlanDirs(plan);
1902
- const absoluteDirs = relativeDirs.map((d) => join5(workspacePath, d));
2095
+ const absoluteDirs = relativeDirs.map((d) => join6(workspacePath, d));
1903
2096
  const command = buildCodexCommand({
1904
2097
  model: provider.model,
1905
2098
  addDirs: absoluteDirs
@@ -1987,8 +2180,8 @@ function buildExecutionAudit(provider, compiled, issue, durationMs, result) {
1987
2180
  }
1988
2181
  function persistCompilationArtifacts(workspacePath, compiled) {
1989
2182
  try {
1990
- writeFileSync(
1991
- join6(workspacePath, "fifony-compiled-execution.json"),
2183
+ writeFileSync2(
2184
+ join7(workspacePath, "fifony-compiled-execution.json"),
1992
2185
  JSON.stringify({
1993
2186
  adapter: compiled.meta.adapter,
1994
2187
  model: compiled.meta.model,
@@ -2010,8 +2203,8 @@ function persistCompilationArtifacts(workspacePath, compiled) {
2010
2203
  }
2011
2204
  if (compiled.payload) {
2012
2205
  try {
2013
- writeFileSync(
2014
- join6(workspacePath, "fifony-execution-payload.json"),
2206
+ writeFileSync2(
2207
+ join7(workspacePath, "fifony-execution-payload.json"),
2015
2208
  JSON.stringify(compiled.payload, null, 2),
2016
2209
  "utf8"
2017
2210
  );
@@ -2021,8 +2214,8 @@ function persistCompilationArtifacts(workspacePath, compiled) {
2021
2214
  }
2022
2215
  function persistExecutionAudit(workspacePath, audit) {
2023
2216
  try {
2024
- writeFileSync(
2025
- join6(workspacePath, "fifony-execution-audit.json"),
2217
+ writeFileSync2(
2218
+ join7(workspacePath, "fifony-execution-audit.json"),
2026
2219
  JSON.stringify(audit, null, 2),
2027
2220
  "utf8"
2028
2221
  );
@@ -2412,7 +2605,7 @@ function tryParseJsonOutput(output) {
2412
2605
  }
2413
2606
  function readAgentDirective(workspacePath, output, success) {
2414
2607
  const fallbackStatus = success ? "done" : "failed";
2415
- const resultFile = join7(workspacePath, "fifony-result.json");
2608
+ const resultFile = join8(workspacePath, "fifony-result.json");
2416
2609
  let resultPayload = {};
2417
2610
  const fullJson = (() => {
2418
2611
  try {
@@ -2431,9 +2624,9 @@ function readAgentDirective(workspacePath, output, success) {
2431
2624
  tokenUsage
2432
2625
  };
2433
2626
  }
2434
- if (existsSync4(resultFile)) {
2627
+ if (existsSync5(resultFile)) {
2435
2628
  try {
2436
- const parsed = JSON.parse(readFileSync3(resultFile, "utf8"));
2629
+ const parsed = JSON.parse(readFileSync4(resultFile, "utf8"));
2437
2630
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2438
2631
  resultPayload = parsed;
2439
2632
  }
@@ -2450,10 +2643,10 @@ function readAgentDirective(workspacePath, output, success) {
2450
2643
  return { status, summary, nextPrompt, tokenUsage };
2451
2644
  }
2452
2645
  function readAgentPid(workspacePath) {
2453
- const pidFile = join7(workspacePath, "fifony-agent.pid");
2454
- if (!existsSync4(pidFile)) return null;
2646
+ const pidFile = join8(workspacePath, "fifony-agent.pid");
2647
+ if (!existsSync5(pidFile)) return null;
2455
2648
  try {
2456
- const data = JSON.parse(readFileSync3(pidFile, "utf8"));
2649
+ const data = JSON.parse(readFileSync4(pidFile, "utf8"));
2457
2650
  if (!data?.pid || typeof data.pid !== "number") return null;
2458
2651
  return data;
2459
2652
  } catch {
@@ -2470,7 +2663,7 @@ function isProcessAlive(pid) {
2470
2663
  }
2471
2664
  function isAgentStillRunning(issue) {
2472
2665
  const wp = issue.workspacePath;
2473
- if (!wp || !existsSync4(wp)) return { alive: false, pid: null };
2666
+ if (!wp || !existsSync5(wp)) return { alive: false, pid: null };
2474
2667
  const pidInfo = readAgentPid(wp);
2475
2668
  if (!pidInfo) return { alive: false, pid: null };
2476
2669
  return { alive: isProcessAlive(pidInfo.pid), pid: pidInfo };
@@ -2480,7 +2673,7 @@ function cleanStalePidFile(workspacePath) {
2480
2673
  if (!pidInfo) return;
2481
2674
  if (!isProcessAlive(pidInfo.pid)) {
2482
2675
  try {
2483
- rmSync(join7(workspacePath, "fifony-agent.pid"), { force: true });
2676
+ rmSync(join8(workspacePath, "fifony-agent.pid"), { force: true });
2484
2677
  } catch {
2485
2678
  }
2486
2679
  }
@@ -2522,33 +2715,33 @@ function shouldSkipRoutingPath(relativePath) {
2522
2715
  return base === "WORKFLOW.local.md" || base === ".fifony-env.sh" || base.startsWith("fifony-") || base.startsWith("fifony_");
2523
2716
  }
2524
2717
  function inferChangedWorkspacePaths(workspacePath, limit = 32) {
2525
- if (!workspacePath || !existsSync4(workspacePath) || !existsSync4(SOURCE_ROOT)) return [];
2718
+ if (!workspacePath || !existsSync5(workspacePath) || !existsSync5(SOURCE_ROOT)) return [];
2526
2719
  const changed = /* @__PURE__ */ new Set();
2527
2720
  const walk = (currentRoot, relativeRoot = "") => {
2528
2721
  if (changed.size >= limit) return;
2529
- for (const item of readdirSync2(currentRoot, { withFileTypes: true })) {
2722
+ for (const item of readdirSync3(currentRoot, { withFileTypes: true })) {
2530
2723
  if (changed.size >= limit) return;
2531
2724
  const nextRelative = relativeRoot ? `${relativeRoot}/${item.name}` : item.name;
2532
2725
  if (shouldSkipRoutingPath(nextRelative)) continue;
2533
- const currentPath = join7(currentRoot, item.name);
2726
+ const currentPath = join8(currentRoot, item.name);
2534
2727
  if (item.isDirectory()) {
2535
2728
  walk(currentPath, nextRelative);
2536
2729
  continue;
2537
2730
  }
2538
2731
  if (!item.isFile()) continue;
2539
- const sourcePath = join7(SOURCE_ROOT, nextRelative);
2540
- if (!existsSync4(sourcePath)) {
2732
+ const sourcePath = join8(SOURCE_ROOT, nextRelative);
2733
+ if (!existsSync5(sourcePath)) {
2541
2734
  changed.add(nextRelative);
2542
2735
  continue;
2543
2736
  }
2544
- const currentStat = statSync(currentPath);
2545
- const sourceStat = statSync(sourcePath);
2737
+ const currentStat = statSync2(currentPath);
2738
+ const sourceStat = statSync2(sourcePath);
2546
2739
  if (currentStat.size !== sourceStat.size) {
2547
2740
  changed.add(nextRelative);
2548
2741
  continue;
2549
2742
  }
2550
- const currentFile = readFileSync3(currentPath);
2551
- const sourceFile = readFileSync3(sourcePath);
2743
+ const currentFile = readFileSync4(currentPath);
2744
+ const sourceFile = readFileSync4(sourcePath);
2552
2745
  if (!currentFile.equals(sourceFile)) changed.add(nextRelative);
2553
2746
  }
2554
2747
  };
@@ -2557,7 +2750,7 @@ function inferChangedWorkspacePaths(workspacePath, limit = 32) {
2557
2750
  }
2558
2751
  function computeDiffStats(issue) {
2559
2752
  const wp = issue.workspacePath;
2560
- if (!wp || !existsSync4(wp) || !existsSync4(SOURCE_ROOT)) return;
2753
+ if (!wp || !existsSync5(wp) || !existsSync5(SOURCE_ROOT)) return;
2561
2754
  try {
2562
2755
  let raw = "";
2563
2756
  try {
@@ -2586,23 +2779,23 @@ function computeDiffStats(issue) {
2586
2779
  }
2587
2780
  }
2588
2781
  function isConflict(relativePath) {
2589
- const targetPath = join7(TARGET_ROOT, relativePath);
2590
- const sourcePath = join7(SOURCE_ROOT, relativePath);
2591
- if (!existsSync4(sourcePath)) return existsSync4(targetPath);
2592
- if (!existsSync4(targetPath)) return false;
2593
- const targetStat = statSync(targetPath);
2594
- const sourceStat = statSync(sourcePath);
2782
+ const targetPath = join8(TARGET_ROOT, relativePath);
2783
+ const sourcePath = join8(SOURCE_ROOT, relativePath);
2784
+ if (!existsSync5(sourcePath)) return existsSync5(targetPath);
2785
+ if (!existsSync5(targetPath)) return false;
2786
+ const targetStat = statSync2(targetPath);
2787
+ const sourceStat = statSync2(sourcePath);
2595
2788
  if (targetStat.size !== sourceStat.size) return true;
2596
- return !readFileSync3(targetPath).equals(readFileSync3(sourcePath));
2789
+ return !readFileSync4(targetPath).equals(readFileSync4(sourcePath));
2597
2790
  }
2598
2791
  function mergeWorkspace(workspacePath) {
2599
2792
  const result = { copied: [], deleted: [], skipped: [], conflicts: [] };
2600
- if (!workspacePath || !existsSync4(workspacePath)) {
2793
+ if (!workspacePath || !existsSync5(workspacePath)) {
2601
2794
  throw new Error(`Workspace not found: ${workspacePath}`);
2602
2795
  }
2603
2796
  const walkWorkspace = (dir) => {
2604
- for (const item of readdirSync2(dir, { withFileTypes: true })) {
2605
- const fullPath = join7(dir, item.name);
2797
+ for (const item of readdirSync3(dir, { withFileTypes: true })) {
2798
+ const fullPath = join8(dir, item.name);
2606
2799
  const relativePath = relative(workspacePath, fullPath);
2607
2800
  if (shouldSkipMergePath(relativePath)) {
2608
2801
  result.skipped.push(relativePath);
@@ -2613,17 +2806,17 @@ function mergeWorkspace(workspacePath) {
2613
2806
  continue;
2614
2807
  }
2615
2808
  if (!item.isFile()) continue;
2616
- const sourcePath = join7(SOURCE_ROOT, relativePath);
2617
- const isNew = !existsSync4(sourcePath);
2809
+ const sourcePath = join8(SOURCE_ROOT, relativePath);
2810
+ const isNew = !existsSync5(sourcePath);
2618
2811
  let isModified = false;
2619
2812
  if (!isNew) {
2620
- const wsStat = statSync(fullPath);
2621
- const srcStat = statSync(sourcePath);
2813
+ const wsStat = statSync2(fullPath);
2814
+ const srcStat = statSync2(sourcePath);
2622
2815
  if (wsStat.size !== srcStat.size) {
2623
2816
  isModified = true;
2624
2817
  } else {
2625
- const wsContent = readFileSync3(fullPath);
2626
- const srcContent = readFileSync3(sourcePath);
2818
+ const wsContent = readFileSync4(fullPath);
2819
+ const srcContent = readFileSync4(sourcePath);
2627
2820
  isModified = !wsContent.equals(srcContent);
2628
2821
  }
2629
2822
  }
@@ -2632,18 +2825,18 @@ function mergeWorkspace(workspacePath) {
2632
2825
  result.conflicts.push(relativePath);
2633
2826
  continue;
2634
2827
  }
2635
- const targetDir = join7(TARGET_ROOT, relative(workspacePath, dir));
2636
- const targetPath = join7(TARGET_ROOT, relativePath);
2637
- mkdirSync(targetDir, { recursive: true });
2828
+ const targetDir = join8(TARGET_ROOT, relative(workspacePath, dir));
2829
+ const targetPath = join8(TARGET_ROOT, relativePath);
2830
+ mkdirSync2(targetDir, { recursive: true });
2638
2831
  cpSync(fullPath, targetPath, { force: true });
2639
2832
  result.copied.push(relativePath);
2640
2833
  }
2641
2834
  }
2642
2835
  };
2643
2836
  const walkSource = (dir) => {
2644
- if (!existsSync4(dir)) return;
2645
- for (const item of readdirSync2(dir, { withFileTypes: true })) {
2646
- const fullPath = join7(dir, item.name);
2837
+ if (!existsSync5(dir)) return;
2838
+ for (const item of readdirSync3(dir, { withFileTypes: true })) {
2839
+ const fullPath = join8(dir, item.name);
2647
2840
  const relativePath = relative(SOURCE_ROOT, fullPath);
2648
2841
  if (shouldSkipMergePath(relativePath)) continue;
2649
2842
  if (item.isDirectory()) {
@@ -2651,10 +2844,10 @@ function mergeWorkspace(workspacePath) {
2651
2844
  continue;
2652
2845
  }
2653
2846
  if (!item.isFile()) continue;
2654
- const wsPath = join7(workspacePath, relativePath);
2655
- if (!existsSync4(wsPath)) {
2656
- const targetPath = join7(TARGET_ROOT, relativePath);
2657
- if (existsSync4(targetPath)) {
2847
+ const wsPath = join8(workspacePath, relativePath);
2848
+ if (!existsSync5(wsPath)) {
2849
+ const targetPath = join8(TARGET_ROOT, relativePath);
2850
+ if (existsSync5(targetPath)) {
2658
2851
  if (isConflict(relativePath)) {
2659
2852
  result.conflicts.push(relativePath);
2660
2853
  } else {
@@ -2976,16 +3169,16 @@ async function runCommandWithTimeout(command, workspacePath, issue, config, prom
2976
3169
  };
2977
3170
  for (const [key, value] of Object.entries(extraEnv)) {
2978
3171
  if (value.length > 4e3) {
2979
- const valFile = join7(workspacePath, `${key.toLowerCase()}.txt`);
2980
- writeFileSync2(valFile, value, "utf8");
3172
+ const valFile = join8(workspacePath, `${key.toLowerCase()}.txt`);
3173
+ writeFileSync3(valFile, value, "utf8");
2981
3174
  allVars[`${key}_FILE`] = valFile;
2982
3175
  } else {
2983
3176
  allVars[key] = value;
2984
3177
  }
2985
3178
  }
2986
- const envFilePath = join7(workspacePath, ".fifony-env.sh");
3179
+ const envFilePath = join8(workspacePath, ".fifony-env.sh");
2987
3180
  const envFileLines = Object.entries(allVars).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join("\n");
2988
- writeFileSync2(envFilePath, envFileLines, "utf8");
3181
+ writeFileSync3(envFilePath, envFileLines, "utf8");
2989
3182
  const wrappedCommand = `. "${envFilePath}" && ${command}`;
2990
3183
  const child = spawn2(wrappedCommand, {
2991
3184
  shell: true,
@@ -2998,10 +3191,10 @@ async function runCommandWithTimeout(command, workspacePath, issue, config, prom
2998
3191
  if (child.stdin) {
2999
3192
  child.stdin.end();
3000
3193
  }
3001
- const pidFile = join7(workspacePath, "fifony-agent.pid");
3194
+ const pidFile = join8(workspacePath, "fifony-agent.pid");
3002
3195
  const pid = child.pid;
3003
3196
  if (pid) {
3004
- writeFileSync2(pidFile, JSON.stringify({
3197
+ writeFileSync3(pidFile, JSON.stringify({
3005
3198
  pid,
3006
3199
  issueId: issue.id,
3007
3200
  startedAt: new Date(started).toISOString(),
@@ -3011,8 +3204,8 @@ async function runCommandWithTimeout(command, workspacePath, issue, config, prom
3011
3204
  let output = "";
3012
3205
  let timedOut = false;
3013
3206
  let outputBytes = 0;
3014
- const liveLogFile = join7(workspacePath, "fifony-live-output.log");
3015
- writeFileSync2(liveLogFile, "", "utf8");
3207
+ const liveLogFile = join8(workspacePath, "fifony-live-output.log");
3208
+ writeFileSync3(liveLogFile, "", "utf8");
3016
3209
  const onChunk = (chunk) => {
3017
3210
  const text = String(chunk);
3018
3211
  output = appendFileTail(output, text, config.logLinesTail);
@@ -3077,7 +3270,7 @@ async function runHook(command, workspacePath, issue, hookName, extraEnv = {}) {
3077
3270
  retryDelayMs: 0,
3078
3271
  staleInProgressTimeoutMs: 0,
3079
3272
  logLinesTail: 12e3,
3080
- agentProvider: normalizeAgentProvider(env5.FIFONY_AGENT_PROVIDER ?? "codex"),
3273
+ agentProvider: normalizeAgentProvider(env6.FIFONY_AGENT_PROVIDER ?? "codex"),
3081
3274
  agentCommand: command,
3082
3275
  maxTurns: 1,
3083
3276
  runMode: "filesystem"
@@ -3088,8 +3281,8 @@ async function runHook(command, workspacePath, issue, hookName, extraEnv = {}) {
3088
3281
  }
3089
3282
  async function cleanWorkspace(issueId, workflowDefinition) {
3090
3283
  const safeId = idToSafePath(issueId);
3091
- const workspacePath = join7(WORKSPACE_ROOT, safeId);
3092
- if (!existsSync4(workspacePath)) return;
3284
+ const workspacePath = join8(WORKSPACE_ROOT, safeId);
3285
+ if (!existsSync5(workspacePath)) return;
3093
3286
  if (workflowDefinition?.beforeRemoveHook) {
3094
3287
  try {
3095
3288
  const dummyIssue = { id: issueId, identifier: issueId };
@@ -3107,13 +3300,14 @@ async function cleanWorkspace(issueId, workflowDefinition) {
3107
3300
  }
3108
3301
  async function prepareWorkspace(issue, workflowDefinition) {
3109
3302
  const safeId = idToSafePath(issue.id);
3110
- const workspaceRoot = join7(WORKSPACE_ROOT, safeId);
3111
- const createdNow = !existsSync4(workspaceRoot);
3303
+ const workspaceRoot = join8(WORKSPACE_ROOT, safeId);
3304
+ const createdNow = !existsSync5(workspaceRoot);
3112
3305
  if (createdNow) {
3113
- mkdirSync(workspaceRoot, { recursive: true });
3306
+ mkdirSync2(workspaceRoot, { recursive: true });
3114
3307
  if (workflowDefinition?.afterCreateHook) {
3115
3308
  await runHook(workflowDefinition.afterCreateHook, workspaceRoot, issue, "after_create");
3116
3309
  } else {
3310
+ await ensureSourceReady();
3117
3311
  cpSync(SOURCE_ROOT, workspaceRoot, {
3118
3312
  recursive: true,
3119
3313
  force: true,
@@ -3121,11 +3315,11 @@ async function prepareWorkspace(issue, workflowDefinition) {
3121
3315
  });
3122
3316
  }
3123
3317
  }
3124
- const metaPath = join7(workspaceRoot, "fifony-issue.json");
3318
+ const metaPath = join8(workspaceRoot, "fifony-issue.json");
3125
3319
  const promptText = await buildPrompt(issue, workflowDefinition);
3126
- const promptFile = join7(workspaceRoot, "fifony-prompt.md");
3127
- writeFileSync2(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
3128
- writeFileSync2(promptFile, `${promptText}
3320
+ const promptFile = join8(workspaceRoot, "fifony-prompt.md");
3321
+ writeFileSync3(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
3322
+ writeFileSync3(promptFile, `${promptText}
3129
3323
  `, "utf8");
3130
3324
  issue.workspacePath = workspaceRoot;
3131
3325
  issue.workspacePreparedAt = now();
@@ -3142,7 +3336,7 @@ async function runAgentSession(state, issue, provider, cycle, workspacePath, bas
3142
3336
  let nextPrompt = session.nextPrompt;
3143
3337
  let lastCode = session.lastCode;
3144
3338
  let lastOutput = session.lastOutput;
3145
- const resultFile = join7(workspacePath, `fifony-result-${provider.role}-${provider.provider}.json`);
3339
+ const resultFile = join8(workspacePath, `fifony-result-${provider.role}-${provider.provider}.json`);
3146
3340
  if (session.status === "done" && session.turns.length > 0) {
3147
3341
  return { success: true, blocked: false, continueRequested: false, code: session.lastCode, output: session.lastOutput, turns: session.turns.length };
3148
3342
  }
@@ -3155,8 +3349,8 @@ Agent requested additional turns beyond configured limit (${maxTurns}).`, state.
3155
3349
  return { success: false, blocked: true, continueRequested: false, code: lastCode, output: session.lastOutput, turns: session.turns.length };
3156
3350
  }
3157
3351
  const turnPrompt = await buildTurnPrompt(issue, basePromptText, previousOutput, turnIndex, maxTurns, nextPrompt);
3158
- const turnPromptFile = turnIndex === 1 ? basePromptFile : join7(workspacePath, `fifony-turn-${String(turnIndex).padStart(2, "0")}.md`);
3159
- if (turnIndex > 1) writeFileSync2(turnPromptFile, `${turnPrompt}
3352
+ const turnPromptFile = turnIndex === 1 ? basePromptFile : join8(workspacePath, `fifony-turn-${String(turnIndex).padStart(2, "0")}.md`);
3353
+ if (turnIndex > 1) writeFileSync3(turnPromptFile, `${turnPrompt}
3160
3354
  `, "utf8");
3161
3355
  session.status = "running";
3162
3356
  session.lastPrompt = turnPrompt;
@@ -3267,7 +3461,7 @@ async function runAgentPipeline(state, issue, workspacePath, basePromptText, bas
3267
3461
  const skills = discoverSkills(workspacePath);
3268
3462
  const skillContext = buildSkillContext(skills);
3269
3463
  if (skillContext) {
3270
- writeFileSync2(join7(workspacePath, "fifony-skills.md"), skillContext, "utf8");
3464
+ writeFileSync3(join8(workspacePath, "fifony-skills.md"), skillContext, "utf8");
3271
3465
  }
3272
3466
  const compiled = await compileExecution(issue, activeProvider, state.config, workspacePath, skillContext);
3273
3467
  let providerPrompt;
@@ -3283,9 +3477,9 @@ async function runAgentPipeline(state, issue, workspacePath, basePromptText, bas
3283
3477
  `Plan compiled for ${compiled.meta.adapter}: effort=${compiled.meta.reasoningEffort}, skills=[${compiled.meta.skillsActivated.join(",")}], subagents=[${compiled.meta.subagentsRequested.join(",")}].`
3284
3478
  );
3285
3479
  if (Object.keys(compiled.env).length > 0) {
3286
- const envFile = join7(workspacePath, ".fifony-compiled-env.sh");
3480
+ const envFile = join8(workspacePath, ".fifony-compiled-env.sh");
3287
3481
  const envLines = Object.entries(compiled.env).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join("\n");
3288
- writeFileSync2(envFile, envLines, "utf8");
3482
+ writeFileSync3(envFile, envLines, "utf8");
3289
3483
  }
3290
3484
  } else {
3291
3485
  providerPrompt = await buildProviderBasePrompt(activeProvider, issue, basePromptText, workspacePath, skillContext);
@@ -3393,8 +3587,8 @@ async function runIssueOnce(state, issue, running, workflowDefinition) {
3393
3587
  }
3394
3588
  const compiled = await compileReview(issue, reviewer, workspacePath, diffSummary);
3395
3589
  const effectiveReviewer = { ...reviewer, command: compiled.command || reviewer.command };
3396
- const reviewPromptFile = join7(workspacePath, "fifony-review-prompt.md");
3397
- writeFileSync2(reviewPromptFile, `${compiled.prompt}
3590
+ const reviewPromptFile = join8(workspacePath, "fifony-review-prompt.md");
3591
+ writeFileSync3(reviewPromptFile, `${compiled.prompt}
3398
3592
  `, "utf8");
3399
3593
  state._workflowDefinition = workflowDefinition;
3400
3594
  const reviewResult = await runAgentSession(state, issue, effectiveReviewer, 1, workspacePath, compiled.prompt, reviewPromptFile);
@@ -3511,9 +3705,10 @@ async function runIssueOnce(state, issue, running, workflowDefinition) {
3511
3705
  }
3512
3706
  } finally {
3513
3707
  issue.updatedAt = now();
3708
+ markIssueDirty(issue.id);
3514
3709
  state.metrics.activeWorkers = Math.max(state.metrics.activeWorkers - 1, 0);
3515
3710
  running.delete(issue.id);
3516
- state.metrics = computeMetrics(state.issues);
3711
+ state.metrics = computeMetrics2(state.issues);
3517
3712
  state.metrics.activeWorkers = Math.max(state.metrics.activeWorkers, 0);
3518
3713
  state.updatedAt = now();
3519
3714
  await persistState(state);
@@ -3863,43 +4058,43 @@ var NATIVE_RESOURCE_NAMES = NATIVE_RESOURCE_CONFIGS.map((resource) => resource.n
3863
4058
 
3864
4059
  // src/runtime/providers-usage.ts
3865
4060
  import { execSync as execSync2 } from "child_process";
3866
- import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
3867
- import { join as join8 } from "path";
4061
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
4062
+ import { join as join9 } from "path";
3868
4063
  import { homedir as homedir4 } from "os";
3869
- import { env as env6 } from "process";
4064
+ import { env as env7 } from "process";
3870
4065
  function resolveCodexHomeCandidates() {
3871
4066
  const homePaths = /* @__PURE__ */ new Set([
3872
4067
  homedir4(),
3873
- env6.XDG_STATE_HOME?.trim() || "",
3874
- env6.XDG_DATA_HOME?.trim() || ""
4068
+ env7.XDG_STATE_HOME?.trim() || "",
4069
+ env7.XDG_DATA_HOME?.trim() || ""
3875
4070
  ]);
3876
- const sudoUser = env6.SUDO_USER?.trim();
4071
+ const sudoUser = env7.SUDO_USER?.trim();
3877
4072
  if (sudoUser && sudoUser !== "root") {
3878
4073
  homePaths.add(`/home/${sudoUser}`);
3879
4074
  }
3880
4075
  const direct = /* @__PURE__ */ new Set([
3881
- env6.CODEX_HOME?.trim() || ""
4076
+ env7.CODEX_HOME?.trim() || ""
3882
4077
  ]);
3883
4078
  const candidates = [...homePaths, ...direct].filter(Boolean).flatMap((candidate) => {
3884
4079
  if (candidate.endsWith("/.codex") || candidate.endsWith("/codex")) return [candidate];
3885
- return [join8(candidate, ".codex"), join8(candidate, "codex")];
4080
+ return [join9(candidate, ".codex"), join9(candidate, "codex")];
3886
4081
  });
3887
4082
  return [...new Set(candidates)];
3888
4083
  }
3889
4084
  function resolveCodexDir() {
3890
4085
  for (const candidate of resolveCodexHomeCandidates()) {
3891
- if (existsSync5(candidate)) {
4086
+ if (existsSync6(candidate)) {
3892
4087
  return candidate;
3893
4088
  }
3894
4089
  }
3895
4090
  return null;
3896
4091
  }
3897
4092
  function findLatestCodexDb(codexDir) {
3898
- const explicit = join8(codexDir, "state_5.sqlite");
3899
- if (existsSync5(explicit)) return explicit;
3900
- const candidates = readdirSync3(codexDir).filter((name) => name.startsWith("state_") && name.endsWith(".sqlite")).sort().reverse();
4093
+ const explicit = join9(codexDir, "state_5.sqlite");
4094
+ if (existsSync6(explicit)) return explicit;
4095
+ const candidates = readdirSync4(codexDir).filter((name) => name.startsWith("state_") && name.endsWith(".sqlite")).sort().reverse();
3901
4096
  if (candidates.length === 0) return null;
3902
- return join8(codexDir, candidates[0]);
4097
+ return join9(codexDir, candidates[0]);
3903
4098
  }
3904
4099
  function computeNextMonday() {
3905
4100
  const now2 = /* @__PURE__ */ new Date();
@@ -3936,15 +4131,15 @@ var CLAUDE_PLAN_LIMITS = {
3936
4131
  };
3937
4132
  function collectClaudeUsage() {
3938
4133
  const home = homedir4();
3939
- const claudeDir = join8(home, ".claude");
3940
- if (!existsSync5(claudeDir)) return null;
4134
+ const claudeDir = join9(home, ".claude");
4135
+ if (!existsSync6(claudeDir)) return null;
3941
4136
  let available = false;
3942
4137
  try {
3943
4138
  execSync2("which claude", { encoding: "utf8", timeout: 3e3 });
3944
4139
  available = true;
3945
4140
  } catch {
3946
4141
  }
3947
- const projectsDir = join8(claudeDir, "projects");
4142
+ const projectsDir = join9(claudeDir, "projects");
3948
4143
  let totalInputTokens = 0;
3949
4144
  let totalOutputTokens = 0;
3950
4145
  let totalSessions = 0;
@@ -3958,23 +4153,23 @@ function collectClaudeUsage() {
3958
4153
  const todayMs = todayStart.getTime();
3959
4154
  const weekStart = computeWeekStart();
3960
4155
  const weekMs = weekStart.getTime();
3961
- if (existsSync5(projectsDir)) {
4156
+ if (existsSync6(projectsDir)) {
3962
4157
  try {
3963
- const projectDirs = readdirSync3(projectsDir, { withFileTypes: true });
4158
+ const projectDirs = readdirSync4(projectsDir, { withFileTypes: true });
3964
4159
  for (const dir of projectDirs) {
3965
4160
  if (!dir.isDirectory()) continue;
3966
- const projectPath = join8(projectsDir, dir.name);
4161
+ const projectPath = join9(projectsDir, dir.name);
3967
4162
  let sessionFiles;
3968
4163
  try {
3969
- sessionFiles = readdirSync3(projectPath).filter((f) => f.endsWith(".jsonl"));
4164
+ sessionFiles = readdirSync4(projectPath).filter((f) => f.endsWith(".jsonl"));
3970
4165
  } catch {
3971
4166
  continue;
3972
4167
  }
3973
4168
  for (const file of sessionFiles) {
3974
- const filePath = join8(projectPath, file);
4169
+ const filePath = join9(projectPath, file);
3975
4170
  let content;
3976
4171
  try {
3977
- content = readFileSync4(filePath, "utf8");
4172
+ content = readFileSync5(filePath, "utf8");
3978
4173
  } catch {
3979
4174
  continue;
3980
4175
  }
@@ -4028,10 +4223,10 @@ function collectClaudeUsage() {
4028
4223
  ];
4029
4224
  let plan = "pro";
4030
4225
  let resetInfo = "Weekly reset (every Monday 00:00 UTC)";
4031
- const settingsPath = join8(claudeDir, "settings.json");
4032
- if (existsSync5(settingsPath)) {
4226
+ const settingsPath = join9(claudeDir, "settings.json");
4227
+ if (existsSync6(settingsPath)) {
4033
4228
  try {
4034
- const settings = JSON.parse(readFileSync4(settingsPath, "utf8"));
4229
+ const settings = JSON.parse(readFileSync5(settingsPath, "utf8"));
4035
4230
  if (settings.plan === "max" || settings.plan === "max5x") {
4036
4231
  plan = settings.plan;
4037
4232
  resetInfo = `Plan: ${settings.plan.toUpperCase()} \u2014 Weekly token limit resets every Monday 00:00 UTC`;
@@ -4069,11 +4264,11 @@ function collectCodexUsage() {
4069
4264
  } catch {
4070
4265
  }
4071
4266
  const models = [];
4072
- const modelsCachePath = join8(codexDir, "models_cache.json");
4267
+ const modelsCachePath = join9(codexDir, "models_cache.json");
4073
4268
  let currentModel = "";
4074
- if (existsSync5(modelsCachePath)) {
4269
+ if (existsSync6(modelsCachePath)) {
4075
4270
  try {
4076
- const cache = JSON.parse(readFileSync4(modelsCachePath, "utf8"));
4271
+ const cache = JSON.parse(readFileSync5(modelsCachePath, "utf8"));
4077
4272
  for (const m of cache.models || []) {
4078
4273
  models.push({
4079
4274
  slug: m.slug,
@@ -4084,10 +4279,10 @@ function collectCodexUsage() {
4084
4279
  } catch {
4085
4280
  }
4086
4281
  }
4087
- const configPath = join8(codexDir, "config.toml");
4088
- if (existsSync5(configPath)) {
4282
+ const configPath = join9(codexDir, "config.toml");
4283
+ if (existsSync6(configPath)) {
4089
4284
  try {
4090
- const configContent = readFileSync4(configPath, "utf8");
4285
+ const configContent = readFileSync5(configPath, "utf8");
4091
4286
  const modelMatch = configContent.match(/^model\s*=\s*"([^"]+)"/m);
4092
4287
  if (modelMatch) currentModel = modelMatch[1];
4093
4288
  } catch {
@@ -4179,6 +4374,14 @@ function collectProvidersUsage() {
4179
4374
 
4180
4375
  // src/runtime/scheduler.ts
4181
4376
  var shuttingDown = false;
4377
+ var lastPersistAt = 0;
4378
+ var PERSIST_DEBOUNCE_MS = 5e3;
4379
+ var schedulerWakeResolve = null;
4380
+ function wakeScheduler() {
4381
+ schedulerWakeResolve?.();
4382
+ }
4383
+ var IDLE_POLL_MS = 5e3;
4384
+ var ACTIVE_POLL_MS = 500;
4182
4385
  function installGracefulShutdown(state, running) {
4183
4386
  const handler = async (signal) => {
4184
4387
  if (shuttingDown) {
@@ -4197,7 +4400,7 @@ function installGracefulShutdown(state, running) {
4197
4400
  }
4198
4401
  }
4199
4402
  state.updatedAt = now();
4200
- state.metrics = computeMetrics(state.issues);
4403
+ state.metrics = computeMetrics2(state.issues);
4201
4404
  try {
4202
4405
  await persistState(state);
4203
4406
  logger.info("State persisted.");
@@ -4275,6 +4478,7 @@ async function ensureNotStale(state, staleTimeoutMs) {
4275
4478
  issue.attempts += 1;
4276
4479
  issue.nextRetryAt = getNextRetryAt(issue, state.config.retryDelayMs);
4277
4480
  issue.startedAt = void 0;
4481
+ markIssueDirty(issue.id);
4278
4482
  await transitionIssueState(issue, "Blocked", `Issue state auto-recovered from stale execution.`);
4279
4483
  }
4280
4484
  }
@@ -4347,9 +4551,20 @@ async function scheduler(state, running, runForever, workflowDefinition) {
4347
4551
  }
4348
4552
  }
4349
4553
  state.updatedAt = now();
4350
- await persistState(state);
4554
+ const shouldPersist = hasDirtyState() || Date.now() - lastPersistAt > PERSIST_DEBOUNCE_MS;
4555
+ if (shouldPersist) {
4556
+ await persistState(state);
4557
+ lastPersistAt = Date.now();
4558
+ }
4351
4559
  logger.debug("Scheduler tick completed.");
4352
- await sleep(state.config.pollIntervalMs);
4560
+ const effectivePoll = running.size > 0 ? ACTIVE_POLL_MS : IDLE_POLL_MS;
4561
+ await Promise.race([
4562
+ sleep(effectivePoll),
4563
+ new Promise((resolve4) => {
4564
+ schedulerWakeResolve = resolve4;
4565
+ })
4566
+ ]);
4567
+ schedulerWakeResolve = null;
4353
4568
  }
4354
4569
  return;
4355
4570
  }
@@ -4381,11 +4596,11 @@ async function scheduler(state, running, runForever, workflowDefinition) {
4381
4596
  }
4382
4597
 
4383
4598
  // src/runtime/issue-enhancer.ts
4384
- import { env as env7 } from "process";
4385
- import { existsSync as existsSync6, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
4599
+ import { env as env8 } from "process";
4600
+ import { existsSync as existsSync7, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
4386
4601
  import { spawn as spawn3 } from "child_process";
4387
4602
  import { tmpdir } from "os";
4388
- import { join as join9 } from "path";
4603
+ import { join as join10 } from "path";
4389
4604
  function getProviderCommand(provider, config, workflowDefinition) {
4390
4605
  const workflowConfig = workflowDefinition ? workflowDefinition.config : {};
4391
4606
  const codexCommand = getNestedString(getNestedRecord(workflowConfig, "codex"), "command");
@@ -4455,23 +4670,23 @@ function parseCandidate(raw, expectedField) {
4455
4670
  return "";
4456
4671
  }
4457
4672
  function readProviderOutput(resultFile, fallback) {
4458
- if (existsSync6(resultFile)) {
4673
+ if (existsSync7(resultFile)) {
4459
4674
  try {
4460
- return readFileSync5(resultFile, "utf8").trim();
4675
+ return readFileSync6(resultFile, "utf8").trim();
4461
4676
  } catch {
4462
4677
  }
4463
4678
  }
4464
4679
  return fallback;
4465
4680
  }
4466
4681
  async function runProviderCommand(command, provider, prompt, title, description, field, timeoutMs) {
4467
- const tempDir = mkdtempSync(join9(tmpdir(), "fifony-enhance-"));
4468
- const promptFile = join9(tempDir, "fifony-enhance-prompt.md");
4469
- const issuePayloadFile = join9(tempDir, "fifony-issue.json");
4470
- const resultFile = join9(tempDir, "fifony-result.txt");
4471
- const envFile = join9(tempDir, "fifony-enhance-env.sh");
4472
- writeFileSync3(promptFile, `${prompt}
4682
+ const tempDir = mkdtempSync(join10(tmpdir(), "fifony-enhance-"));
4683
+ const promptFile = join10(tempDir, "fifony-enhance-prompt.md");
4684
+ const issuePayloadFile = join10(tempDir, "fifony-issue.json");
4685
+ const resultFile = join10(tempDir, "fifony-result.txt");
4686
+ const envFile = join10(tempDir, "fifony-enhance-env.sh");
4687
+ writeFileSync4(promptFile, `${prompt}
4473
4688
  `, "utf8");
4474
- writeFileSync3(issuePayloadFile, JSON.stringify({ title, description, field }, null, 2), "utf8");
4689
+ writeFileSync4(issuePayloadFile, JSON.stringify({ title, description, field }, null, 2), "utf8");
4475
4690
  const envLines = [
4476
4691
  `export FIFONY_ISSUE_TITLE=${JSON.stringify(title)}`,
4477
4692
  `export FIFONY_ISSUE_DESCRIPTION=${JSON.stringify(description)}`,
@@ -4482,11 +4697,11 @@ async function runProviderCommand(command, provider, prompt, title, description,
4482
4697
  "export FIFONY_AGENT_PROVIDER=" + JSON.stringify(provider),
4483
4698
  "export FIFONY_RESULT_FILE=" + JSON.stringify(resultFile)
4484
4699
  ];
4485
- const processEnv = Object.entries(env7).map(([key, value]) => {
4700
+ const processEnv = Object.entries(env8).map(([key, value]) => {
4486
4701
  if (typeof value !== "string") return `export ${key}=${JSON.stringify("")}`;
4487
4702
  return `export ${key}=${JSON.stringify(value)}`;
4488
4703
  }).join("\n");
4489
- writeFileSync3(envFile, `${processEnv}
4704
+ writeFileSync4(envFile, `${processEnv}
4490
4705
  ${envLines.join("\n")}
4491
4706
  `, "utf8");
4492
4707
  const wrappedCommand = `. "${envFile}" && ${command}`;
@@ -4587,9 +4802,9 @@ async function enhanceIssueField(payload, config, workflowDefinition) {
4587
4802
  }
4588
4803
 
4589
4804
  // src/runtime/issue-planner.ts
4590
- import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync4, rmSync as rmSync3 } from "fs";
4805
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync5, rmSync as rmSync3 } from "fs";
4591
4806
  import { spawn as spawn4 } from "child_process";
4592
- import { join as join10 } from "path";
4807
+ import { join as join11 } from "path";
4593
4808
  import { tmpdir as tmpdir2 } from "os";
4594
4809
  var PLANNING_SETTING_ID = "planning:active";
4595
4810
  function emptySession() {
@@ -4876,12 +5091,12 @@ async function generatePlan(title, description, config, workflowDefinition, opti
4876
5091
  };
4877
5092
  await persistSession(session);
4878
5093
  const prompt = await buildPlanPrompt(title, description, fast);
4879
- const tempDir = mkdtempSync2(join10(tmpdir2(), "fifony-plan-"));
4880
- const promptFile = join10(tempDir, "fifony-plan-prompt.md");
4881
- const envFile = join10(tempDir, "fifony-plan-env.sh");
4882
- writeFileSync4(promptFile, `${prompt}
5094
+ const tempDir = mkdtempSync2(join11(tmpdir2(), "fifony-plan-"));
5095
+ const promptFile = join11(tempDir, "fifony-plan-prompt.md");
5096
+ const envFile = join11(tempDir, "fifony-plan-env.sh");
5097
+ writeFileSync5(promptFile, `${prompt}
4883
5098
  `, "utf8");
4884
- writeFileSync4(envFile, [
5099
+ writeFileSync5(envFile, [
4885
5100
  `export FIFONY_PROMPT_FILE=${JSON.stringify(promptFile)}`,
4886
5101
  `export FIFONY_AGENT_PROVIDER=${JSON.stringify(preferred)}`
4887
5102
  ].join("\n"), "utf8");
@@ -5013,12 +5228,12 @@ async function refinePlan(issue, feedback, config, workflowDefinition) {
5013
5228
  if (!command) throw new Error(`No command configured for provider ${preferred}.`);
5014
5229
  const refineStartMs = Date.now();
5015
5230
  const prompt = await buildRefinePrompt(issue.title, issue.description, issue.plan, feedback);
5016
- const tempDir = mkdtempSync2(join10(tmpdir2(), "fifony-refine-"));
5017
- const promptFile = join10(tempDir, "fifony-refine-prompt.md");
5018
- const envFile = join10(tempDir, "fifony-refine-env.sh");
5019
- writeFileSync4(promptFile, `${prompt}
5231
+ const tempDir = mkdtempSync2(join11(tmpdir2(), "fifony-refine-"));
5232
+ const promptFile = join11(tempDir, "fifony-refine-prompt.md");
5233
+ const envFile = join11(tempDir, "fifony-refine-env.sh");
5234
+ writeFileSync5(promptFile, `${prompt}
5020
5235
  `, "utf8");
5021
- writeFileSync4(envFile, [
5236
+ writeFileSync5(envFile, [
5022
5237
  `export FIFONY_PROMPT_FILE=${JSON.stringify(promptFile)}`,
5023
5238
  `export FIFONY_AGENT_PROVIDER=${JSON.stringify(preferred)}`
5024
5239
  ].join("\n"), "utf8");
@@ -5158,19 +5373,20 @@ function refinePlanInBackground(issue, feedback, config, workflowDefinition, cal
5158
5373
 
5159
5374
  // src/runtime/project-scanner.ts
5160
5375
  import {
5161
- existsSync as existsSync7,
5376
+ existsSync as existsSync8,
5162
5377
  mkdtempSync as mkdtempSync3,
5163
- readdirSync as readdirSync4,
5164
- readFileSync as readFileSync6,
5378
+ readdirSync as readdirSync5,
5379
+ readFileSync as readFileSync7,
5165
5380
  rmSync as rmSync4,
5166
- writeFileSync as writeFileSync5
5381
+ writeFileSync as writeFileSync6
5167
5382
  } from "fs";
5168
- import { join as join11, basename as basename2 } from "path";
5383
+ import { join as join12, basename as basename2 } from "path";
5169
5384
  import { spawn as spawn5 } from "child_process";
5170
5385
  import { tmpdir as tmpdir3 } from "os";
5171
- import { env as env8 } from "process";
5386
+ import { env as env9 } from "process";
5387
+ import { createHash } from "crypto";
5172
5388
  function scanProjectFiles(targetRoot) {
5173
- const check = (rel) => existsSync7(join11(targetRoot, rel));
5389
+ const check = (rel) => existsSync8(join12(targetRoot, rel));
5174
5390
  const files = {
5175
5391
  claudeMd: check("CLAUDE.md"),
5176
5392
  claudeDir: check(".claude"),
@@ -5192,10 +5408,10 @@ function scanProjectFiles(targetRoot) {
5192
5408
  };
5193
5409
  const existingAgents = [];
5194
5410
  for (const agentDir of [".claude/agents", ".codex/agents"]) {
5195
- const fullPath = join11(targetRoot, agentDir);
5196
- if (!existsSync7(fullPath)) continue;
5411
+ const fullPath = join12(targetRoot, agentDir);
5412
+ if (!existsSync8(fullPath)) continue;
5197
5413
  try {
5198
- const entries = readdirSync4(fullPath);
5414
+ const entries = readdirSync5(fullPath);
5199
5415
  for (const entry of entries) {
5200
5416
  if (entry.endsWith(".md")) {
5201
5417
  existingAgents.push(basename2(entry, ".md"));
@@ -5206,13 +5422,13 @@ function scanProjectFiles(targetRoot) {
5206
5422
  }
5207
5423
  const existingSkills = [];
5208
5424
  for (const skillDir of [".claude/skills", ".codex/skills"]) {
5209
- const fullPath = join11(targetRoot, skillDir);
5210
- if (!existsSync7(fullPath)) continue;
5425
+ const fullPath = join12(targetRoot, skillDir);
5426
+ if (!existsSync8(fullPath)) continue;
5211
5427
  try {
5212
- const entries = readdirSync4(fullPath);
5428
+ const entries = readdirSync5(fullPath);
5213
5429
  for (const entry of entries) {
5214
- const skillFile = join11(fullPath, entry, "SKILL.md");
5215
- if (existsSync7(skillFile)) {
5430
+ const skillFile = join12(fullPath, entry, "SKILL.md");
5431
+ if (existsSync8(skillFile)) {
5216
5432
  existingSkills.push(entry);
5217
5433
  }
5218
5434
  }
@@ -5220,20 +5436,20 @@ function scanProjectFiles(targetRoot) {
5220
5436
  }
5221
5437
  }
5222
5438
  let readmeExcerpt = "";
5223
- const readmePath = join11(targetRoot, "README.md");
5224
- if (existsSync7(readmePath)) {
5439
+ const readmePath = join12(targetRoot, "README.md");
5440
+ if (existsSync8(readmePath)) {
5225
5441
  try {
5226
- const content = readFileSync6(readmePath, "utf8");
5442
+ const content = readFileSync7(readmePath, "utf8");
5227
5443
  readmeExcerpt = content.slice(0, 200).trim();
5228
5444
  } catch {
5229
5445
  }
5230
5446
  }
5231
5447
  let packageName = "";
5232
5448
  let packageDescription = "";
5233
- const pkgPath = join11(targetRoot, "package.json");
5234
- if (existsSync7(pkgPath)) {
5449
+ const pkgPath = join12(targetRoot, "package.json");
5450
+ if (existsSync8(pkgPath)) {
5235
5451
  try {
5236
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
5452
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
5237
5453
  packageName = typeof pkg.name === "string" ? pkg.name : "";
5238
5454
  packageDescription = typeof pkg.description === "string" ? pkg.description : "";
5239
5455
  } catch {
@@ -5275,39 +5491,39 @@ function buildFallbackAnalysis(targetRoot) {
5275
5491
  let description = "";
5276
5492
  let readmeExcerpt = "";
5277
5493
  for (const readmeFile of ["README.md", "README.rst", "README.txt", "README"]) {
5278
- const p = join11(targetRoot, readmeFile);
5279
- if (existsSync7(p)) {
5494
+ const p = join12(targetRoot, readmeFile);
5495
+ if (existsSync8(p)) {
5280
5496
  try {
5281
- readmeExcerpt = readFileSync6(p, "utf8").slice(0, 300).trim();
5497
+ readmeExcerpt = readFileSync7(p, "utf8").slice(0, 300).trim();
5282
5498
  break;
5283
5499
  } catch {
5284
5500
  }
5285
5501
  }
5286
5502
  }
5287
- const pkgPath = join11(targetRoot, "package.json");
5288
- if (existsSync7(pkgPath)) {
5503
+ const pkgPath = join12(targetRoot, "package.json");
5504
+ if (existsSync8(pkgPath)) {
5289
5505
  try {
5290
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
5506
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
5291
5507
  const name = typeof pkg.name === "string" ? pkg.name : "";
5292
5508
  const desc = typeof pkg.description === "string" ? pkg.description : "";
5293
5509
  if (desc) description = name ? `${name}: ${desc}` : desc;
5294
5510
  } catch {
5295
5511
  }
5296
5512
  }
5297
- const cargoPath = join11(targetRoot, "Cargo.toml");
5298
- if (!description && existsSync7(cargoPath)) {
5513
+ const cargoPath = join12(targetRoot, "Cargo.toml");
5514
+ if (!description && existsSync8(cargoPath)) {
5299
5515
  try {
5300
- const content = readFileSync6(cargoPath, "utf8");
5516
+ const content = readFileSync7(cargoPath, "utf8");
5301
5517
  const descMatch = content.match(/^description\s*=\s*"([^"]+)"/m);
5302
5518
  const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
5303
5519
  if (descMatch) description = nameMatch ? `${nameMatch[1]}: ${descMatch[1]}` : descMatch[1];
5304
5520
  } catch {
5305
5521
  }
5306
5522
  }
5307
- const pyprojectPath = join11(targetRoot, "pyproject.toml");
5308
- if (!description && existsSync7(pyprojectPath)) {
5523
+ const pyprojectPath = join12(targetRoot, "pyproject.toml");
5524
+ if (!description && existsSync8(pyprojectPath)) {
5309
5525
  try {
5310
- const content = readFileSync6(pyprojectPath, "utf8");
5526
+ const content = readFileSync7(pyprojectPath, "utf8");
5311
5527
  const descMatch = content.match(/^description\s*=\s*"([^"]+)"/m);
5312
5528
  const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
5313
5529
  if (descMatch) description = nameMatch ? `${nameMatch[1]}: ${descMatch[1]}` : descMatch[1];
@@ -5320,7 +5536,7 @@ function buildFallbackAnalysis(targetRoot) {
5320
5536
  let language = "unknown";
5321
5537
  const stack = [];
5322
5538
  for (const [file, signal] of Object.entries(BUILD_FILE_SIGNALS)) {
5323
- if (existsSync7(join11(targetRoot, file))) {
5539
+ if (existsSync8(join12(targetRoot, file))) {
5324
5540
  if (language === "unknown" && signal.language !== "unknown") {
5325
5541
  language = signal.language;
5326
5542
  }
@@ -5388,7 +5604,51 @@ function validateAnalysis(parsed) {
5388
5604
  source: "cli"
5389
5605
  };
5390
5606
  }
5391
- async function analyzeProjectWithCli(provider, targetRoot) {
5607
+ var ANALYSIS_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
5608
+ function computeProjectHash(targetRoot) {
5609
+ const buildFiles = Object.keys(BUILD_FILE_SIGNALS);
5610
+ const found = buildFiles.filter((f) => existsSync8(join12(targetRoot, f))).sort();
5611
+ return createHash("sha256").update(found.join(",")).digest("hex").slice(0, 16);
5612
+ }
5613
+ async function loadCachedAnalysis(targetRoot) {
5614
+ const resource = getSettingStateResource();
5615
+ if (!resource) return null;
5616
+ const hash = computeProjectHash(targetRoot);
5617
+ const key = `project-analysis:${hash}`;
5618
+ try {
5619
+ const record2 = await resource.get(key);
5620
+ if (!record2?.value) return null;
5621
+ const cached = record2.value;
5622
+ if (!cached.analysis || !cached.updatedAt) return null;
5623
+ if (Date.now() - Date.parse(cached.updatedAt) > ANALYSIS_CACHE_TTL_MS) return null;
5624
+ return cached.analysis;
5625
+ } catch {
5626
+ return null;
5627
+ }
5628
+ }
5629
+ async function saveCachedAnalysis(targetRoot, analysis) {
5630
+ const resource = getSettingStateResource();
5631
+ if (!resource) return;
5632
+ const hash = computeProjectHash(targetRoot);
5633
+ const key = `project-analysis:${hash}`;
5634
+ try {
5635
+ await resource.replace(key, {
5636
+ id: key,
5637
+ scope: "system",
5638
+ source: "detected",
5639
+ value: { analysis, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
5640
+ });
5641
+ } catch {
5642
+ }
5643
+ }
5644
+ async function analyzeProjectWithCli(provider, targetRoot, options) {
5645
+ if (!options?.forceRefresh) {
5646
+ const cached = await loadCachedAnalysis(targetRoot);
5647
+ if (cached) {
5648
+ logger.info("Using cached project analysis.");
5649
+ return cached;
5650
+ }
5651
+ }
5392
5652
  const normalizedProvider = provider.trim().toLowerCase();
5393
5653
  const providers = detectAvailableProviders();
5394
5654
  const providerInfo = providers.find((p) => p.name === normalizedProvider && p.available);
@@ -5399,12 +5659,12 @@ async function analyzeProjectWithCli(provider, targetRoot) {
5399
5659
  );
5400
5660
  return buildFallbackAnalysis(targetRoot);
5401
5661
  }
5402
- const tempDir = mkdtempSync3(join11(tmpdir3(), "fifony-scan-"));
5403
- const promptFile = join11(tempDir, "fifony-scan-prompt.txt");
5662
+ const tempDir = mkdtempSync3(join12(tmpdir3(), "fifony-scan-"));
5663
+ const promptFile = join12(tempDir, "fifony-scan-prompt.txt");
5404
5664
  const analysisPrompt = await renderPrompt("project-analysis");
5405
- writeFileSync5(promptFile, analysisPrompt, "utf8");
5665
+ writeFileSync6(promptFile, analysisPrompt, "utf8");
5406
5666
  const processEnv = {};
5407
- for (const [key, value] of Object.entries(env8)) {
5667
+ for (const [key, value] of Object.entries(env9)) {
5408
5668
  if (typeof value === "string") processEnv[key] = value;
5409
5669
  }
5410
5670
  processEnv.FIFONY_PROMPT_FILE = promptFile;
@@ -5473,6 +5733,7 @@ async function analyzeProjectWithCli(provider, targetRoot) {
5473
5733
  { provider: normalizedProvider, domains: analysis.domains, stack: analysis.stack },
5474
5734
  "CLI project analysis completed"
5475
5735
  );
5736
+ await saveCachedAnalysis(targetRoot, analysis);
5476
5737
  return analysis;
5477
5738
  }
5478
5739
  logger.warn(
@@ -5494,27 +5755,180 @@ async function analyzeProjectWithCli(provider, targetRoot) {
5494
5755
  }
5495
5756
  }
5496
5757
 
5758
+ // src/runtime/issue-scanner.ts
5759
+ import { execFileSync as execFileSync2 } from "child_process";
5760
+ var SCAN_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i;
5761
+ var EXCLUDE_DIRS = [
5762
+ "node_modules",
5763
+ ".git",
5764
+ ".fifony",
5765
+ "dist",
5766
+ "build",
5767
+ ".turbo",
5768
+ ".next",
5769
+ ".nuxt",
5770
+ "coverage",
5771
+ ".venv",
5772
+ "vendor",
5773
+ "tmp",
5774
+ "temp",
5775
+ "artifacts"
5776
+ ];
5777
+ function scanForTodos(targetRoot) {
5778
+ const excludeArgs = EXCLUDE_DIRS.flatMap((dir) => ["--exclude-dir", dir]);
5779
+ let output;
5780
+ try {
5781
+ output = execFileSync2("grep", [
5782
+ "-rn",
5783
+ "-E",
5784
+ "\\b(TODO|FIXME|HACK|XXX)\\b",
5785
+ ...excludeArgs,
5786
+ "--include=*.ts",
5787
+ "--include=*.tsx",
5788
+ "--include=*.js",
5789
+ "--include=*.jsx",
5790
+ "--include=*.py",
5791
+ "--include=*.rs",
5792
+ "--include=*.go",
5793
+ "--include=*.java",
5794
+ "--include=*.rb",
5795
+ "--include=*.php",
5796
+ "--include=*.cs",
5797
+ "--include=*.swift",
5798
+ "--include=*.kt",
5799
+ "--include=*.vue",
5800
+ "--include=*.svelte",
5801
+ targetRoot
5802
+ ], {
5803
+ encoding: "utf8",
5804
+ timeout: 15e3,
5805
+ maxBuffer: 5e6
5806
+ });
5807
+ } catch (error) {
5808
+ if (error.status === 1) return [];
5809
+ if (error.stdout) output = error.stdout;
5810
+ else {
5811
+ logger.warn(`TODO scan failed: ${String(error)}`);
5812
+ return [];
5813
+ }
5814
+ }
5815
+ const results = [];
5816
+ const lines = output.split("\n").filter(Boolean);
5817
+ for (const line of lines) {
5818
+ const match = line.match(/^(.+?):(\d+):(.+)$/);
5819
+ if (!match) continue;
5820
+ const [, file, lineNo, content] = match;
5821
+ const todoMatch = content.match(SCAN_PATTERN);
5822
+ if (!todoMatch) continue;
5823
+ const [, tag, text] = todoMatch;
5824
+ const source = tag.toLowerCase();
5825
+ const trimmedText = text.trim();
5826
+ if (!trimmedText || trimmedText.length < 5) continue;
5827
+ const relativePath = file.startsWith(targetRoot) ? file.slice(targetRoot.length + 1) : file;
5828
+ results.push({
5829
+ source: source === "xxx" ? "hack" : source,
5830
+ title: trimmedText.length > 120 ? `${trimmedText.slice(0, 117)}...` : trimmedText,
5831
+ file: relativePath,
5832
+ line: parseInt(lineNo, 10),
5833
+ context: content.trim()
5834
+ });
5835
+ }
5836
+ return results;
5837
+ }
5838
+ function categorizeScannedIssues(issues, workflowDefinition) {
5839
+ const options = getCapabilityRoutingOptions(workflowDefinition);
5840
+ return issues.map((issue) => {
5841
+ const resolution = resolveTaskCapabilities({
5842
+ id: `scan-${issue.file}:${issue.line}`,
5843
+ identifier: `${issue.source}:${issue.file}:${issue.line}`,
5844
+ title: issue.title,
5845
+ description: issue.context,
5846
+ labels: [issue.source],
5847
+ paths: [issue.file]
5848
+ }, options);
5849
+ return {
5850
+ ...issue,
5851
+ category: resolution.category,
5852
+ overlays: resolution.overlays,
5853
+ rationale: resolution.rationale,
5854
+ suggestedLabels: [
5855
+ issue.source,
5856
+ resolution.category ? `capability:${resolution.category}` : ""
5857
+ ].filter(Boolean),
5858
+ suggestedPaths: [issue.file]
5859
+ };
5860
+ });
5861
+ }
5862
+
5863
+ // src/runtime/github-sync.ts
5864
+ import { execFile } from "child_process";
5865
+ async function fetchGitHubIssues(targetRoot) {
5866
+ return new Promise((resolve4) => {
5867
+ execFile(
5868
+ "gh",
5869
+ [
5870
+ "issue",
5871
+ "list",
5872
+ "--json",
5873
+ "number,title,body,labels,state,url",
5874
+ "--state",
5875
+ "open",
5876
+ "--limit",
5877
+ "50"
5878
+ ],
5879
+ {
5880
+ cwd: targetRoot,
5881
+ timeout: 15e3,
5882
+ maxBuffer: 2e6
5883
+ },
5884
+ (error, stdout2) => {
5885
+ if (error) {
5886
+ logger.warn(`Failed to fetch GitHub issues: ${String(error)}`);
5887
+ resolve4([]);
5888
+ return;
5889
+ }
5890
+ try {
5891
+ const issues = JSON.parse(stdout2.trim());
5892
+ const results = issues.map((issue) => ({
5893
+ source: "github",
5894
+ title: issue.title,
5895
+ file: "",
5896
+ line: 0,
5897
+ context: (issue.body || "").slice(0, 500),
5898
+ suggestedLabels: issue.labels.map((l) => l.name),
5899
+ suggestedPaths: []
5900
+ }));
5901
+ resolve4(results);
5902
+ } catch (parseError) {
5903
+ logger.warn(`Failed to parse GitHub issues: ${String(parseError)}`);
5904
+ resolve4([]);
5905
+ }
5906
+ }
5907
+ );
5908
+ });
5909
+ }
5910
+
5497
5911
  // src/runtime/catalog.ts
5498
- import { existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
5499
- import { join as join12, dirname as dirname2 } from "path";
5912
+ import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
5913
+ import { join as join13, dirname as dirname2 } from "path";
5500
5914
  import { fileURLToPath as fileURLToPath2 } from "url";
5501
5915
  var __filename2 = fileURLToPath2(import.meta.url);
5502
5916
  var __dirname2 = dirname2(__filename2);
5503
5917
  function resolveFixturePath(filename) {
5504
5918
  const candidates = [
5505
- join12(__dirname2, "..", "fixtures", filename),
5506
- join12(__dirname2, "../..", "src", "fixtures", filename),
5507
- join12(__dirname2, "../../..", "src", "fixtures", filename)
5919
+ join13(__dirname2, "..", "fixtures", filename),
5920
+ join13(__dirname2, "../..", "src", "fixtures", filename),
5921
+ join13(__dirname2, "../../..", "src", "fixtures", filename)
5508
5922
  ];
5509
5923
  for (const candidate of candidates) {
5510
- if (existsSync8(candidate)) return candidate;
5924
+ if (existsSync9(candidate)) return candidate;
5511
5925
  }
5512
5926
  return candidates[0];
5513
5927
  }
5514
5928
  function loadAgentCatalog() {
5515
5929
  try {
5516
5930
  const filePath = resolveFixturePath("agent-catalog.json");
5517
- const raw = readFileSync7(filePath, "utf8");
5931
+ const raw = readFileSync8(filePath, "utf8");
5518
5932
  return JSON.parse(raw);
5519
5933
  } catch (error) {
5520
5934
  logger.error({ err: error }, "Failed to load agent catalog");
@@ -5524,7 +5938,7 @@ function loadAgentCatalog() {
5524
5938
  function loadSkillCatalog() {
5525
5939
  try {
5526
5940
  const filePath = resolveFixturePath("skill-catalog.json");
5527
- const raw = readFileSync7(filePath, "utf8");
5941
+ const raw = readFileSync8(filePath, "utf8");
5528
5942
  return JSON.parse(raw);
5529
5943
  } catch (error) {
5530
5944
  logger.error({ err: error }, "Failed to load skill catalog");
@@ -5543,9 +5957,9 @@ function filterByDomains(catalog, domains) {
5543
5957
  function installAgents(targetRoot, agentNames, catalog) {
5544
5958
  const result = { installed: [], skipped: [], errors: [] };
5545
5959
  const catalogMap = new Map(catalog.map((entry) => [entry.name, entry]));
5546
- const agentsDir = join12(targetRoot, ".claude", "agents");
5960
+ const agentsDir = join13(targetRoot, ".claude", "agents");
5547
5961
  try {
5548
- mkdirSync2(agentsDir, { recursive: true });
5962
+ mkdirSync3(agentsDir, { recursive: true });
5549
5963
  } catch (error) {
5550
5964
  logger.error({ err: error, path: agentsDir }, "Failed to create agents directory");
5551
5965
  result.errors.push({ name: "_directory", error: `Failed to create ${agentsDir}` });
@@ -5557,13 +5971,13 @@ function installAgents(targetRoot, agentNames, catalog) {
5557
5971
  result.errors.push({ name, error: "Agent not found in catalog" });
5558
5972
  continue;
5559
5973
  }
5560
- const filePath = join12(agentsDir, `${name}.md`);
5561
- if (existsSync8(filePath)) {
5974
+ const filePath = join13(agentsDir, `${name}.md`);
5975
+ if (existsSync9(filePath)) {
5562
5976
  result.skipped.push(name);
5563
5977
  continue;
5564
5978
  }
5565
5979
  try {
5566
- writeFileSync6(filePath, entry.content, "utf8");
5980
+ writeFileSync7(filePath, entry.content, "utf8");
5567
5981
  result.installed.push(name);
5568
5982
  logger.info({ agent: name, path: filePath }, "Agent installed");
5569
5983
  } catch (error) {
@@ -5578,9 +5992,9 @@ function installAgents(targetRoot, agentNames, catalog) {
5578
5992
  function installSkills(targetRoot, skillNames, catalog) {
5579
5993
  const result = { installed: [], skipped: [], errors: [] };
5580
5994
  const catalogMap = new Map(catalog.map((entry) => [entry.name, entry]));
5581
- const skillsDir = join12(targetRoot, ".claude", "skills");
5995
+ const skillsDir = join13(targetRoot, ".claude", "skills");
5582
5996
  try {
5583
- mkdirSync2(skillsDir, { recursive: true });
5997
+ mkdirSync3(skillsDir, { recursive: true });
5584
5998
  } catch (error) {
5585
5999
  logger.error({ err: error, path: skillsDir }, "Failed to create skills directory");
5586
6000
  result.errors.push({ name: "_directory", error: `Failed to create ${skillsDir}` });
@@ -5592,16 +6006,16 @@ function installSkills(targetRoot, skillNames, catalog) {
5592
6006
  result.errors.push({ name, error: "Skill not found in catalog" });
5593
6007
  continue;
5594
6008
  }
5595
- const skillDir = join12(skillsDir, name);
5596
- const filePath = join12(skillDir, "SKILL.md");
5597
- if (existsSync8(filePath)) {
6009
+ const skillDir = join13(skillsDir, name);
6010
+ const filePath = join13(skillDir, "SKILL.md");
6011
+ if (existsSync9(filePath)) {
5598
6012
  result.skipped.push(name);
5599
6013
  continue;
5600
6014
  }
5601
6015
  try {
5602
- mkdirSync2(skillDir, { recursive: true });
6016
+ mkdirSync3(skillDir, { recursive: true });
5603
6017
  if (entry.installType === "bundled" && entry.content) {
5604
- writeFileSync6(filePath, entry.content, "utf8");
6018
+ writeFileSync7(filePath, entry.content, "utf8");
5605
6019
  } else {
5606
6020
  const referenceContent = [
5607
6021
  `# ${entry.displayName}`,
@@ -5613,7 +6027,7 @@ function installSkills(targetRoot, skillNames, catalog) {
5613
6027
  "",
5614
6028
  `> This skill references an external resource. Install it from the source above.`
5615
6029
  ].filter(Boolean).join("\n");
5616
- writeFileSync6(filePath, referenceContent, "utf8");
6030
+ writeFileSync7(filePath, referenceContent, "utf8");
5617
6031
  }
5618
6032
  result.installed.push(name);
5619
6033
  logger.info({ skill: name, path: filePath, type: entry.installType }, "Skill installed");
@@ -5628,6 +6042,7 @@ function installSkills(targetRoot, skillNames, catalog) {
5628
6042
  }
5629
6043
 
5630
6044
  // src/runtime/api-server.ts
6045
+ import { join as join14 } from "path";
5631
6046
  var wsClients = /* @__PURE__ */ new Map();
5632
6047
  var broadcastSeq = 0;
5633
6048
  var lastBroadcastIssueSnapshot = /* @__PURE__ */ new Map();
@@ -5767,6 +6182,7 @@ async function startApiServer(state, port, workflowDefinition) {
5767
6182
  }
5768
6183
  await updater(issue);
5769
6184
  await persistState(state);
6185
+ wakeScheduler();
5770
6186
  return c.json({ ok: true, issue });
5771
6187
  };
5772
6188
  const resourceConfigs = Object.fromEntries(
@@ -5787,10 +6203,10 @@ async function startApiServer(state, port, workflowDefinition) {
5787
6203
  }
5788
6204
  setApiRuntimeContext(state, workflowDefinition);
5789
6205
  const serveTextFile = (filePath, contentType, cacheControl = "no-cache") => {
5790
- if (!existsSync9(filePath)) {
6206
+ if (!existsSync10(filePath)) {
5791
6207
  return new Response("Not found", { status: 404 });
5792
6208
  }
5793
- return new Response(readFileSync8(filePath), {
6209
+ return new Response(readFileSync9(filePath), {
5794
6210
  headers: {
5795
6211
  "content-type": contentType,
5796
6212
  "cache-control": cacheControl
@@ -5798,10 +6214,10 @@ async function startApiServer(state, port, workflowDefinition) {
5798
6214
  });
5799
6215
  };
5800
6216
  const serveAppShell = () => {
5801
- if (!existsSync9(FRONTEND_INDEX)) {
6217
+ if (!existsSync10(FRONTEND_INDEX)) {
5802
6218
  return new Response("Not found", { status: 404 });
5803
6219
  }
5804
- const html = readFileSync8(FRONTEND_INDEX, "utf8").replace('href="/assets/manifest.webmanifest"', 'href="/manifest.webmanifest"').replaceAll('href="/assets/icon.svg"', 'href="/icon.svg"');
6220
+ const html = readFileSync9(FRONTEND_INDEX, "utf8").replace('href="/assets/manifest.webmanifest"', 'href="/manifest.webmanifest"').replaceAll('href="/assets/icon.svg"', 'href="/icon.svg"');
5805
6221
  return new Response(html, {
5806
6222
  headers: {
5807
6223
  "content-type": "text/html; charset=utf-8",
@@ -5830,7 +6246,7 @@ async function startApiServer(state, port, workflowDefinition) {
5830
6246
  type: "connected",
5831
6247
  seq: broadcastSeq,
5832
6248
  timestamp: now(),
5833
- metrics: computeMetrics(state.issues),
6249
+ metrics: computeMetrics2(state.issues),
5834
6250
  capabilities: computeCapabilityCounts(state.issues),
5835
6251
  issues: state.issues,
5836
6252
  events: state.events.slice(0, 50)
@@ -5883,12 +6299,14 @@ async function startApiServer(state, port, workflowDefinition) {
5883
6299
  "GET /icon-maskable.svg": () => serveTextFile(FRONTEND_MASKABLE_ICON_SVG, "image/svg+xml", "public, max-age=604800, immutable"),
5884
6300
  "GET /kanban": () => serveAppShell(),
5885
6301
  "GET /issues": () => serveAppShell(),
6302
+ "GET /discover": () => serveAppShell(),
5886
6303
  "GET /agents": () => serveAppShell(),
5887
6304
  "GET /settings": () => serveAppShell(),
5888
6305
  "GET /settings/general": () => serveAppShell(),
5889
6306
  "GET /settings/notifications": () => serveAppShell(),
5890
6307
  "GET /settings/workflow": () => serveAppShell(),
5891
6308
  "GET /settings/providers": () => serveAppShell(),
6309
+ "GET /api/health": (c) => c.json({ status: state.booting ? "booting" : "ready" }),
5892
6310
  "GET /api/state": async (c) => {
5893
6311
  const showAll = c.req.query("all") === "1";
5894
6312
  let issues = state.issues;
@@ -5907,7 +6325,7 @@ async function startApiServer(state, port, workflowDefinition) {
5907
6325
  ...state,
5908
6326
  issues,
5909
6327
  capabilities: computeCapabilityCounts(issues),
5910
- metrics: computeMetrics(issues),
6328
+ metrics: computeMetrics2(issues),
5911
6329
  _filter: showAll ? "all" : "recent",
5912
6330
  _totalIssues: state.issues.length
5913
6331
  });
@@ -6066,11 +6484,13 @@ async function startApiServer(state, port, workflowDefinition) {
6066
6484
  const payload = await c.req.json();
6067
6485
  const issue = createIssueFromPayload(payload, state.issues, workflowDefinition);
6068
6486
  state.issues.push(issue);
6487
+ markIssueDirty(issue.id);
6069
6488
  addEvent(state, issue.id, "info", `Issue ${issue.identifier} created via API.`);
6070
6489
  if (issue.plan) {
6071
6490
  addEvent(state, issue.id, "info", `Plan: ${issue.plan.steps.length} steps, complexity: ${issue.plan.estimatedComplexity}.`);
6072
6491
  }
6073
6492
  await persistState(state);
6493
+ wakeScheduler();
6074
6494
  return c.json({ ok: true, issue }, 201);
6075
6495
  } catch (error) {
6076
6496
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
@@ -6113,6 +6533,7 @@ async function startApiServer(state, port, workflowDefinition) {
6113
6533
  const payload = await c.req.json();
6114
6534
  await handleStatePatch(state, issue, payload);
6115
6535
  await persistState(state);
6536
+ wakeScheduler();
6116
6537
  return c.json({ ok: true, issue });
6117
6538
  } catch (error) {
6118
6539
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
@@ -6171,7 +6592,7 @@ async function startApiServer(state, port, workflowDefinition) {
6171
6592
  const issue = findIssue2(issueId);
6172
6593
  if (!issue) return c.json({ ok: false, error: "Issue not found." }, 404);
6173
6594
  const wp = issue.workspacePath;
6174
- if (!wp || !existsSync9(wp)) {
6595
+ if (!wp || !existsSync10(wp)) {
6175
6596
  return c.json({ ok: false, error: "No workspace found for this issue." }, 400);
6176
6597
  }
6177
6598
  const result = mergeWorkspace(wp);
@@ -6239,10 +6660,10 @@ async function startApiServer(state, port, workflowDefinition) {
6239
6660
  const liveLog = wp ? `${wp}/fifony-live-output.log` : null;
6240
6661
  let logTail = "";
6241
6662
  let logSize = 0;
6242
- if (liveLog && existsSync9(liveLog)) {
6663
+ if (liveLog && existsSync10(liveLog)) {
6243
6664
  try {
6244
- const stat = statSync2(liveLog);
6245
- logSize = stat.size;
6665
+ const stat2 = statSync3(liveLog);
6666
+ logSize = stat2.size;
6246
6667
  const fd = openSync(liveLog, "r");
6247
6668
  const readSize = Math.min(logSize, 8192);
6248
6669
  const buf = Buffer.alloc(readSize);
@@ -6279,10 +6700,10 @@ async function startApiServer(state, port, workflowDefinition) {
6279
6700
  const issue = findIssue2(issueId);
6280
6701
  if (!issue) return c.json({ ok: false, error: "Issue not found." }, 404);
6281
6702
  const wp = issue.workspacePath;
6282
- if (!wp || !existsSync9(wp)) {
6703
+ if (!wp || !existsSync10(wp)) {
6283
6704
  return c.json({ ok: true, files: [], diff: "", message: "No workspace found." });
6284
6705
  }
6285
- if (!existsSync9(SOURCE_ROOT)) {
6706
+ if (!existsSync10(SOURCE_ROOT)) {
6286
6707
  return c.json({ ok: true, files: [], diff: "", message: "Source root not found." });
6287
6708
  }
6288
6709
  let raw = "";
@@ -6348,6 +6769,46 @@ async function startApiServer(state, port, workflowDefinition) {
6348
6769
  });
6349
6770
  return c.json({ events: events.slice(0, 200) });
6350
6771
  },
6772
+ // ── Onboarding: gitignore check ────────────────────────────────────
6773
+ "GET /api/gitignore/status": async (c) => {
6774
+ try {
6775
+ const gitignorePath = join14(TARGET_ROOT, ".gitignore");
6776
+ if (!existsSync10(gitignorePath)) {
6777
+ return c.json({ exists: false, hasFifony: false });
6778
+ }
6779
+ const content = readFileSync9(gitignorePath, "utf-8");
6780
+ const lines = content.split("\n").map((l) => l.trim());
6781
+ const hasFifony = lines.some((l) => l === ".fifony" || l === ".fifony/" || l === "/.fifony" || l === "/.fifony/");
6782
+ return c.json({ exists: true, hasFifony });
6783
+ } catch (error) {
6784
+ logger.error({ err: error }, "Failed to check .gitignore");
6785
+ return c.json({ exists: false, hasFifony: false, error: "Failed to check .gitignore" }, 500);
6786
+ }
6787
+ },
6788
+ "POST /api/gitignore/add": async (c) => {
6789
+ try {
6790
+ const gitignorePath = join14(TARGET_ROOT, ".gitignore");
6791
+ if (!existsSync10(gitignorePath)) {
6792
+ writeFileSync8(gitignorePath, "# Fifony state directory\n.fifony/\n", "utf-8");
6793
+ return c.json({ ok: true, created: true });
6794
+ }
6795
+ const content = readFileSync9(gitignorePath, "utf-8");
6796
+ const lines = content.split("\n").map((l) => l.trim());
6797
+ const hasFifony = lines.some((l) => l === ".fifony" || l === ".fifony/" || l === "/.fifony" || l === "/.fifony/");
6798
+ if (hasFifony) {
6799
+ return c.json({ ok: true, alreadyPresent: true });
6800
+ }
6801
+ const suffix = content.endsWith("\n") ? "" : "\n";
6802
+ appendFileSync2(gitignorePath, `${suffix}
6803
+ # Fifony state directory
6804
+ .fifony/
6805
+ `, "utf-8");
6806
+ return c.json({ ok: true, added: true });
6807
+ } catch (error) {
6808
+ logger.error({ err: error }, "Failed to update .gitignore");
6809
+ return c.json({ ok: false, error: "Failed to update .gitignore" }, 500);
6810
+ }
6811
+ },
6351
6812
  // ── Onboarding: project scanning & catalog ─────────────────────────
6352
6813
  "GET /api/scan/project": async (c) => {
6353
6814
  try {
@@ -6369,6 +6830,30 @@ async function startApiServer(state, port, workflowDefinition) {
6369
6830
  return c.json({ ok: false, error: "Failed to analyze project." }, 500);
6370
6831
  }
6371
6832
  },
6833
+ "GET /api/scan/issues": async (c) => {
6834
+ try {
6835
+ const todos = scanForTodos(TARGET_ROOT);
6836
+ const categorized = categorizeScannedIssues(todos, workflowDefinition);
6837
+ return c.json({ ok: true, issues: categorized, total: categorized.length });
6838
+ } catch (error) {
6839
+ logger.error({ err: error }, "Failed to scan for TODOs");
6840
+ return c.json({ ok: false, error: "Failed to scan for issues." }, 500);
6841
+ }
6842
+ },
6843
+ "POST /api/boot/skip-scan": async (c) => {
6844
+ broadcastToWebSocketClients({ type: "boot:scan:skipped" });
6845
+ return c.json({ ok: true, message: "Scan skipped." });
6846
+ },
6847
+ "GET /api/scan/github-issues": async (c) => {
6848
+ try {
6849
+ const issues = await fetchGitHubIssues(TARGET_ROOT);
6850
+ const categorized = categorizeScannedIssues(issues, workflowDefinition);
6851
+ return c.json({ ok: true, issues: categorized, total: categorized.length });
6852
+ } catch (error) {
6853
+ logger.error({ err: error }, "Failed to fetch GitHub issues");
6854
+ return c.json({ ok: false, error: "Failed to fetch GitHub issues." }, 500);
6855
+ }
6856
+ },
6372
6857
  "GET /api/catalog/agents": async (c) => {
6373
6858
  const domainsParam = c.req.query("domains");
6374
6859
  const domains = typeof domainsParam === "string" ? domainsParam.split(",").map((d) => d.trim()).filter(Boolean) : [];
@@ -6495,7 +6980,7 @@ async function initStateStore() {
6495
6980
  debugBoot("initStateStore:start");
6496
6981
  const { S3db, FileSystemClient, StateMachinePlugin } = await loadS3dbModule();
6497
6982
  debugBoot("initStateStore:module-loaded");
6498
- mkdirSync3(S3DB_DATABASE_PATH, { recursive: true });
6983
+ mkdirSync4(S3DB_DATABASE_PATH, { recursive: true });
6499
6984
  stateDb = new S3db({
6500
6985
  client: new FileSystemClient({
6501
6986
  basePath: S3DB_DATABASE_PATH,
@@ -6634,20 +7119,25 @@ async function recoverStateFromIssueResource() {
6634
7119
  }
6635
7120
  async function persistState(state) {
6636
7121
  state.metrics = {
6637
- ...computeMetrics(state.issues),
7122
+ ...getMetrics(state.issues),
6638
7123
  activeWorkers: state.metrics.activeWorkers
6639
7124
  };
6640
7125
  if (!runtimeStateResource) return;
6641
- await runtimeStateResource.replace(S3DB_RUNTIME_RECORD_ID, {
6642
- id: S3DB_RUNTIME_RECORD_ID,
6643
- schemaVersion: S3DB_RUNTIME_SCHEMA_VERSION,
6644
- trackerKind: "filesystem",
6645
- runtimeTag: "local-only",
6646
- updatedAt: now(),
6647
- state
6648
- });
6649
- if (issueStateResource) {
7126
+ const dirty = hasDirtyState();
7127
+ if (dirty) {
7128
+ await runtimeStateResource.replace(S3DB_RUNTIME_RECORD_ID, {
7129
+ id: S3DB_RUNTIME_RECORD_ID,
7130
+ schemaVersion: S3DB_RUNTIME_SCHEMA_VERSION,
7131
+ trackerKind: "filesystem",
7132
+ runtimeTag: "local-only",
7133
+ updatedAt: now(),
7134
+ state
7135
+ });
7136
+ }
7137
+ const dirtyIssues = getDirtyIssueIds();
7138
+ if (issueStateResource && dirtyIssues.size > 0) {
6650
7139
  for (const issue of state.issues) {
7140
+ if (!dirtyIssues.has(issue.id)) continue;
6651
7141
  const clean = {
6652
7142
  ...issue,
6653
7143
  nextRetryAt: issue.nextRetryAt || void 0,
@@ -6662,11 +7152,15 @@ async function persistState(state) {
6662
7152
  logger.warn(`Failed to persist issue ${issue.id}: ${String(error)}`);
6663
7153
  }
6664
7154
  }
7155
+ clearDirtyIssueIds();
6665
7156
  }
6666
- if (eventStateResource) {
7157
+ const dirtyEvents = getDirtyEventIds();
7158
+ if (eventStateResource && dirtyEvents.size > 0) {
6667
7159
  for (const event of state.events) {
7160
+ if (!dirtyEvents.has(event.id)) continue;
6668
7161
  await eventStateResource.replace(event.id, event);
6669
7162
  }
7163
+ clearDirtyEventIds();
6670
7164
  }
6671
7165
  broadcastToWebSocketClients({
6672
7166
  type: "state:update",
@@ -6677,6 +7171,11 @@ async function persistState(state) {
6677
7171
  updatedAt: state.updatedAt
6678
7172
  });
6679
7173
  }
7174
+ async function persistStateFull(state) {
7175
+ markAllIssuesDirty(state.issues.map((i) => i.id));
7176
+ markAllEventsDirty(state.events.map((e) => e.id));
7177
+ await persistState(state);
7178
+ }
6680
7179
  async function loadPersistedSettings() {
6681
7180
  if (!settingStateResource?.list) return [];
6682
7181
  try {
@@ -6750,119 +7249,6 @@ async function closeStateStore() {
6750
7249
  }
6751
7250
  }
6752
7251
 
6753
- // src/runtime/workflow.ts
6754
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readdirSync as readdirSync5, readFileSync as readFileSync9, statSync as statSync3, writeFileSync as writeFileSync7 } from "fs";
6755
- import { extname } from "path";
6756
- import { argv as argv2, exit } from "process";
6757
- function bootstrapSource() {
6758
- if (existsSync10(SOURCE_MARKER)) return;
6759
- logger.info("Creating local source snapshot for Fifony (local-only runtime)...");
6760
- const skipDirs = /* @__PURE__ */ new Set([
6761
- ".git",
6762
- ".fifony",
6763
- "node_modules",
6764
- ".venv",
6765
- "data",
6766
- "dist",
6767
- "build",
6768
- ".turbo",
6769
- ".next",
6770
- ".nuxt",
6771
- ".tanstack",
6772
- "coverage",
6773
- "artifacts",
6774
- "captures",
6775
- "tmp",
6776
- "temp"
6777
- ]);
6778
- const shouldSkip = (relativePath) => {
6779
- const parts = relativePath.split("/");
6780
- if (parts.some((segment) => skipDirs.has(segment))) return true;
6781
- const base = relativePath.split("/").at(-1) ?? "";
6782
- if (base.startsWith("map_scan_") && extname(base) === ".json") return true;
6783
- if (extname(base) === ".xlsx") return true;
6784
- return false;
6785
- };
6786
- const copyRecursive = (source, target, rel = "") => {
6787
- mkdirSync4(target, { recursive: true });
6788
- const items = readdirSync5(source, { withFileTypes: true });
6789
- for (const item of items) {
6790
- const nextRel = rel ? `${rel}/${item.name}` : item.name;
6791
- if (shouldSkip(nextRel)) continue;
6792
- const sourcePath = `${source}/${item.name}`;
6793
- const targetPath = `${target}/${item.name}`;
6794
- const itemStat = statSync3(sourcePath);
6795
- if (item.isDirectory()) {
6796
- copyRecursive(sourcePath, targetPath, nextRel);
6797
- continue;
6798
- }
6799
- if (item.isSymbolicLink() || itemStat.isSymbolicLink()) continue;
6800
- if (itemStat.isFile() || itemStat.isFIFO()) {
6801
- try {
6802
- const file = readFileSync9(sourcePath);
6803
- writeFileSync7(targetPath, file);
6804
- } catch (error) {
6805
- if (error.code === "ENOENT") {
6806
- logger.debug(`Skipped missing source file: ${sourcePath}`);
6807
- } else {
6808
- throw error;
6809
- }
6810
- }
6811
- }
6812
- }
6813
- };
6814
- mkdirSync4(SOURCE_ROOT, { recursive: true });
6815
- copyRecursive(TARGET_ROOT, SOURCE_ROOT);
6816
- writeFileSync7(SOURCE_MARKER, `${now()}
6817
- `, "utf8");
6818
- }
6819
- function loadWorkflowDefinition() {
6820
- const defaultPrompt = PROMPT_TEMPLATES["workflow-default"];
6821
- return {
6822
- workflowPath: "",
6823
- rendered: "",
6824
- config: {},
6825
- promptTemplate: defaultPrompt,
6826
- agentProvider: "codex",
6827
- agentProfile: "",
6828
- agentProfilePath: "",
6829
- agentProfileInstructions: "",
6830
- agentProviders: [],
6831
- afterCreateHook: "",
6832
- beforeRunHook: "",
6833
- afterRunHook: "",
6834
- beforeRemoveHook: ""
6835
- };
6836
- }
6837
- function parsePort(args) {
6838
- for (let i = 0; i < args.length; i += 1) {
6839
- const arg = args[i];
6840
- if (arg === "--help" || arg === "-h") {
6841
- console.log(
6842
- `Usage: ${argv2[1]} [options]
6843
- Options:
6844
- --port <n> Start local dashboard (default: no UI and single batch run)
6845
- --workspace <path> Target workspace root (default: current directory)
6846
- --persistence <path> Persistence root (default: current directory)
6847
- --concurrency <n> Maximum number of parallel issue runners
6848
- --attempts <n> Maximum attempts per issue
6849
- --poll <ms> Polling interval for the scheduler
6850
- --once Run one local batch and exit
6851
- --help Show this message`
6852
- );
6853
- exit(0);
6854
- }
6855
- if (arg === "--port") {
6856
- const value = args[i + 1];
6857
- if (!value || !/^\d+$/.test(value)) {
6858
- fail(`Invalid value for --port: ${value ?? "<empty>"}`);
6859
- }
6860
- return parseIntArg(value, 4040);
6861
- }
6862
- }
6863
- return void 0;
6864
- }
6865
-
6866
7252
  // src/runtime/dev-server.ts
6867
7253
  import { resolve as resolve3 } from "path";
6868
7254
  var VITE_CONFIG_PATH = resolve3(PACKAGE_ROOT, "app/vite.config.js");
@@ -6915,6 +7301,10 @@ Options:
6915
7301
  --timeout <ms> Agent command timeout in ms (default: 1800000)
6916
7302
  --dev Start Vite dev server alongside API (HMR on port+1)
6917
7303
  --once Process once and exit
7304
+ --skip-source Skip source snapshot copy
7305
+ --skip-scan Skip project analysis
7306
+ --skip-recovery Skip orphaned agent recovery
7307
+ --fast-boot Equivalent to --skip-source --skip-scan --skip-recovery
6918
7308
  `
6919
7309
  );
6920
7310
  }
@@ -6937,6 +7327,9 @@ async function main() {
6937
7327
  const interfaceMode = (env10.FIFONY_INTERFACE ?? "cli").trim().toLowerCase();
6938
7328
  const runOnce = args.includes("--once");
6939
7329
  const devMode = args.includes("--dev") || env10.NODE_ENV === "development";
7330
+ const fastBoot = args.includes("--fast-boot");
7331
+ const skipSource = fastBoot || args.includes("--skip-source");
7332
+ if (skipSource) setSkipSource(true);
6940
7333
  debugBoot("main:state-root-ready");
6941
7334
  const workflowDefinition = loadWorkflowDefinition();
6942
7335
  debugBoot("main:workflow-loaded");
@@ -6953,14 +7346,40 @@ async function main() {
6953
7346
  }
6954
7347
  }
6955
7348
  const dashboardPort = port ?? (config.dashboardPort ? Number.parseInt(config.dashboardPort, 10) : void 0);
6956
- bootstrapSource();
6957
- debugBoot("main:source-bootstrapped");
7349
+ const skipRecovery = args.includes("--skip-recovery") || args.includes("--fast-boot");
7350
+ debugBoot("main:phase-b-start");
6958
7351
  await initStateStore();
6959
7352
  debugBoot("main:store-initialized");
6960
- await persistDetectedProvidersSetting(detectedProviders);
6961
- await recoverPlanningSession();
6962
- const previous = await loadPersistedState();
6963
- const persistedSettings = await loadRuntimeSettings();
7353
+ const earlyState = {
7354
+ startedAt: now(),
7355
+ updatedAt: now(),
7356
+ trackerKind: "filesystem",
7357
+ sourceRepoUrl: "",
7358
+ sourceRef: "workspace",
7359
+ workflowPath: "",
7360
+ config,
7361
+ issues: [],
7362
+ events: [],
7363
+ metrics: { total: 0, queued: 0, inProgress: 0, blocked: 0, done: 0, cancelled: 0, activeWorkers: 0 },
7364
+ notes: [],
7365
+ booting: true
7366
+ };
7367
+ let apiState = earlyState;
7368
+ if (dashboardPort) {
7369
+ await startApiServer(apiState, dashboardPort, workflowDefinition);
7370
+ debugBoot("main:api-server-early-start");
7371
+ if (devMode) {
7372
+ const devPort = dashboardPort + 1;
7373
+ await startDevFrontend(dashboardPort, devPort);
7374
+ }
7375
+ }
7376
+ debugBoot("main:phase-c-start");
7377
+ const [previous, persistedSettings] = await Promise.all([
7378
+ loadPersistedState(),
7379
+ loadRuntimeSettings(),
7380
+ persistDetectedProvidersSetting(detectedProviders),
7381
+ recoverPlanningSession()
7382
+ ]);
6964
7383
  debugBoot("main:state-loaded");
6965
7384
  config = applyPersistedSettings(config, persistedSettings);
6966
7385
  await syncRuntimeConfigSettings(config, persistedSettings);
@@ -6969,6 +7388,7 @@ async function main() {
6969
7388
  state.config.dashboardPort = dashboardPort ? String(dashboardPort) : void 0;
6970
7389
  state.workflowPath = WORKFLOW_RENDERED;
6971
7390
  state.updatedAt = now();
7391
+ state.booting = false;
6972
7392
  if (state.config.agentCommand) {
6973
7393
  state.notes.push(`Using agent command: ${state.config.agentCommand}`);
6974
7394
  }
@@ -6998,25 +7418,31 @@ async function main() {
6998
7418
  logger.info("Background workspace cleanup complete.");
6999
7419
  });
7000
7420
  }
7001
- for (const issue of state.issues) {
7002
- if (issue.state === "Running" || issue.state === "Interrupted" || issue.state === "Queued") {
7003
- const { alive, pid } = isAgentStillRunning(issue);
7004
- if (alive && pid) {
7005
- logger.info(`Agent for ${issue.identifier} still alive (PID ${pid.pid}), keeping state as Running.`);
7006
- issue.state = "Running";
7007
- addEvent(state, issue.id, "info", `Orphaned agent detected (PID ${pid.pid}), still alive \u2014 tracking resumed.`);
7008
- } else {
7009
- if (issue.workspacePath) cleanStalePidFile(issue.workspacePath);
7010
- if (issue.state === "Running") {
7011
- issue.state = "Interrupted";
7012
- issue.history.push(`[${now()}] Agent process not found on boot \u2014 marked Interrupted.`);
7013
- addEvent(state, issue.id, "info", `Agent for ${issue.identifier} not found, marked Interrupted.`);
7421
+ if (!skipRecovery) {
7422
+ for (const issue of state.issues) {
7423
+ if (issue.state === "Running" || issue.state === "Interrupted" || issue.state === "Queued") {
7424
+ const { alive, pid } = isAgentStillRunning(issue);
7425
+ if (alive && pid) {
7426
+ logger.info(`Agent for ${issue.identifier} still alive (PID ${pid.pid}), keeping state as Running.`);
7427
+ issue.state = "Running";
7428
+ addEvent(state, issue.id, "info", `Orphaned agent detected (PID ${pid.pid}), still alive \u2014 tracking resumed.`);
7429
+ } else {
7430
+ if (issue.workspacePath) cleanStalePidFile(issue.workspacePath);
7431
+ if (issue.state === "Running") {
7432
+ issue.state = "Interrupted";
7433
+ issue.history.push(`[${now()}] Agent process not found on boot \u2014 marked Interrupted.`);
7434
+ addEvent(state, issue.id, "info", `Agent for ${issue.identifier} not found, marked Interrupted.`);
7435
+ }
7014
7436
  }
7015
7437
  }
7016
7438
  }
7017
7439
  }
7018
- state.metrics = computeMetrics(state.issues);
7019
- await persistState(state);
7440
+ state.metrics = computeMetrics2(state.issues);
7441
+ await persistStateFull(state);
7442
+ if (dashboardPort) {
7443
+ Object.assign(apiState, state);
7444
+ debugBoot("main:api-state-swapped");
7445
+ }
7020
7446
  const running = /* @__PURE__ */ new Set();
7021
7447
  installGracefulShutdown(state, running);
7022
7448
  logger.info(`Rendered local workflow: ${WORKFLOW_RENDERED}`);
@@ -7027,13 +7453,6 @@ async function main() {
7027
7453
  logger.info(`Max turns: ${state.config.maxTurns}`);
7028
7454
  logger.info(`Agent provider: ${state.config.agentProvider}`);
7029
7455
  logger.info(`Interface mode: ${interfaceMode}`);
7030
- if (dashboardPort) {
7031
- await startApiServer(state, dashboardPort, workflowDefinition);
7032
- if (devMode) {
7033
- const devPort = dashboardPort + 1;
7034
- await startDevFrontend(dashboardPort, devPort);
7035
- }
7036
- }
7037
7456
  try {
7038
7457
  addEvent(state, void 0, "info", `Runtime started in local-only mode (filesystem tracker).`);
7039
7458
  const runForever = !runOnce && (Boolean(dashboardPort) || interfaceMode === "mcp");
@@ -7045,8 +7464,8 @@ async function main() {
7045
7464
  throw error;
7046
7465
  } finally {
7047
7466
  state.updatedAt = now();
7048
- state.metrics = computeMetrics(state.issues);
7049
- await persistState(state);
7467
+ state.metrics = computeMetrics2(state.issues);
7468
+ await persistStateFull(state);
7050
7469
  await closeStateStore();
7051
7470
  }
7052
7471
  }