@neotx/core 0.1.0-alpha.14 → 0.1.0-alpha.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -406,11 +406,10 @@ var Semaphore = class {
406
406
  };
407
407
 
408
408
  // src/config.ts
409
- import { existsSync } from "fs";
409
+ import { existsSync as existsSync2 } from "fs";
410
410
  import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
411
411
  import path4 from "path";
412
- import { parse as parseYaml2, stringify as stringifyYaml } from "yaml";
413
- import { z as z2 } from "zod";
412
+ import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
414
413
 
415
414
  // src/paths.ts
416
415
  import { homedir } from "os";
@@ -458,8 +457,12 @@ function getSupervisorEventsPath(name) {
458
457
  function getSupervisorLockPath(name) {
459
458
  return path3.join(getSupervisorDir(name), "daemon.lock");
460
459
  }
460
+ function getSupervisorDecisionsPath(name) {
461
+ return path3.join(getSupervisorDir(name), "decisions.jsonl");
462
+ }
461
463
 
462
- // src/config.ts
464
+ // src/config/schema.ts
465
+ import { z as z2 } from "zod";
463
466
  var httpMcpServerSchema = z2.object({
464
467
  type: z2.literal("http"),
465
468
  url: z2.string(),
@@ -484,26 +487,54 @@ var repoConfigSchema = z2.object({
484
487
  pushRemote: z2.string().default("origin"),
485
488
  gitStrategy: gitStrategySchema
486
489
  });
490
+ var concurrencyConfigSchema = z2.object({
491
+ maxSessions: z2.number().default(5),
492
+ maxPerRepo: z2.number().default(4),
493
+ queueMax: z2.number().default(50)
494
+ }).default({ maxSessions: 5, maxPerRepo: 4, queueMax: 50 });
495
+ var budgetConfigSchema = z2.object({
496
+ dailyCapUsd: z2.number().default(500),
497
+ alertThresholdPct: z2.number().default(80)
498
+ }).default({ dailyCapUsd: 500, alertThresholdPct: 80 });
499
+ var recoveryConfigSchema = z2.object({
500
+ maxRetries: z2.number().default(3),
501
+ backoffBaseMs: z2.number().default(3e4)
502
+ }).default({ maxRetries: 3, backoffBaseMs: 3e4 });
503
+ var sessionsConfigSchema = z2.object({
504
+ initTimeoutMs: z2.number().default(12e4),
505
+ maxDurationMs: z2.number().default(36e5),
506
+ dir: z2.string().default("/tmp/neo-sessions")
507
+ }).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5, dir: "/tmp/neo-sessions" });
508
+ var supervisorConfigSchema = z2.object({
509
+ port: z2.number().default(7777),
510
+ secret: z2.string().optional(),
511
+ heartbeatTimeoutMs: z2.number().default(3e5),
512
+ maxConsecutiveFailures: z2.number().default(3),
513
+ maxEventsPerSec: z2.number().default(10),
514
+ dailyCapUsd: z2.number().default(50),
515
+ /** How often consolidation runs (ms) */
516
+ consolidationIntervalMs: z2.number().default(3e5),
517
+ /** How often compaction runs (ms) */
518
+ compactionIntervalMs: z2.number().default(36e5),
519
+ /** Safety timeout for waitForWork (ms) */
520
+ eventTimeoutMs: z2.number().default(3e5),
521
+ instructions: z2.string().optional()
522
+ }).default({
523
+ port: 7777,
524
+ heartbeatTimeoutMs: 3e5,
525
+ maxConsecutiveFailures: 3,
526
+ maxEventsPerSec: 10,
527
+ dailyCapUsd: 50,
528
+ consolidationIntervalMs: 3e5,
529
+ compactionIntervalMs: 36e5,
530
+ eventTimeoutMs: 3e5
531
+ });
487
532
  var globalConfigSchema = z2.object({
488
533
  repos: z2.array(repoConfigSchema).default([]),
489
- concurrency: z2.object({
490
- maxSessions: z2.number().default(5),
491
- maxPerRepo: z2.number().default(4),
492
- queueMax: z2.number().default(50)
493
- }).default({ maxSessions: 5, maxPerRepo: 4, queueMax: 50 }),
494
- budget: z2.object({
495
- dailyCapUsd: z2.number().default(500),
496
- alertThresholdPct: z2.number().default(80)
497
- }).default({ dailyCapUsd: 500, alertThresholdPct: 80 }),
498
- recovery: z2.object({
499
- maxRetries: z2.number().default(3),
500
- backoffBaseMs: z2.number().default(3e4)
501
- }).default({ maxRetries: 3, backoffBaseMs: 3e4 }),
502
- sessions: z2.object({
503
- initTimeoutMs: z2.number().default(12e4),
504
- maxDurationMs: z2.number().default(36e5),
505
- dir: z2.string().default("/tmp/neo-sessions")
506
- }).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5, dir: "/tmp/neo-sessions" }),
534
+ concurrency: concurrencyConfigSchema,
535
+ budget: budgetConfigSchema,
536
+ recovery: recoveryConfigSchema,
537
+ sessions: sessionsConfigSchema,
507
538
  webhooks: z2.array(
508
539
  z2.object({
509
540
  url: z2.string().url(),
@@ -512,30 +543,7 @@ var globalConfigSchema = z2.object({
512
543
  timeoutMs: z2.number().default(5e3)
513
544
  })
514
545
  ).default([]),
515
- supervisor: z2.object({
516
- port: z2.number().default(7777),
517
- secret: z2.string().optional(),
518
- heartbeatTimeoutMs: z2.number().default(3e5),
519
- maxConsecutiveFailures: z2.number().default(3),
520
- maxEventsPerSec: z2.number().default(10),
521
- dailyCapUsd: z2.number().default(50),
522
- /** How often consolidation runs (ms) */
523
- consolidationIntervalMs: z2.number().default(3e5),
524
- /** How often compaction runs (ms) */
525
- compactionIntervalMs: z2.number().default(36e5),
526
- /** Safety timeout for waitForWork (ms) */
527
- eventTimeoutMs: z2.number().default(3e5),
528
- instructions: z2.string().optional()
529
- }).default({
530
- port: 7777,
531
- heartbeatTimeoutMs: 3e5,
532
- maxConsecutiveFailures: 3,
533
- maxEventsPerSec: 10,
534
- dailyCapUsd: 50,
535
- consolidationIntervalMs: 3e5,
536
- compactionIntervalMs: 36e5,
537
- eventTimeoutMs: 3e5
538
- }),
546
+ supervisor: supervisorConfigSchema,
539
547
  memory: z2.object({
540
548
  embeddings: z2.boolean().default(true)
541
549
  }).default({ embeddings: true }),
@@ -548,6 +556,296 @@ var globalConfigSchema = z2.object({
548
556
  }).optional()
549
557
  });
550
558
  var neoConfigSchema = globalConfigSchema;
559
+ var repoOverrideConfigSchema = z2.object({
560
+ concurrency: concurrencyConfigSchema.unwrap().partial().optional(),
561
+ budget: budgetConfigSchema.unwrap().partial().optional(),
562
+ recovery: recoveryConfigSchema.unwrap().partial().optional(),
563
+ sessions: sessionsConfigSchema.unwrap().partial().optional()
564
+ }).partial();
565
+
566
+ // src/config/dotNotation.ts
567
+ function getConfigValue(config, path19) {
568
+ if (path19 === "") {
569
+ return config;
570
+ }
571
+ const keys = path19.split(".");
572
+ let current = config;
573
+ for (const key of keys) {
574
+ if (current === null || current === void 0) {
575
+ return void 0;
576
+ }
577
+ if (typeof current !== "object") {
578
+ return void 0;
579
+ }
580
+ current = current[key];
581
+ }
582
+ return current;
583
+ }
584
+
585
+ // src/config/merge.ts
586
+ var defaultConfig = {
587
+ repos: [],
588
+ concurrency: concurrencyConfigSchema.parse(void 0),
589
+ budget: budgetConfigSchema.parse(void 0),
590
+ recovery: recoveryConfigSchema.parse(void 0),
591
+ sessions: sessionsConfigSchema.parse(void 0),
592
+ webhooks: [],
593
+ supervisor: {
594
+ port: 7777,
595
+ heartbeatTimeoutMs: 3e5,
596
+ maxConsecutiveFailures: 3,
597
+ maxEventsPerSec: 10,
598
+ dailyCapUsd: 50,
599
+ consolidationIntervalMs: 3e5,
600
+ compactionIntervalMs: 36e5,
601
+ eventTimeoutMs: 3e5
602
+ },
603
+ memory: { embeddings: true }
604
+ };
605
+ function deepMerge(target, source) {
606
+ const result = { ...target };
607
+ for (const key of Object.keys(source)) {
608
+ const sourceValue = source[key];
609
+ const targetValue = target[key];
610
+ if (sourceValue === void 0) {
611
+ continue;
612
+ }
613
+ if (sourceValue !== null && typeof sourceValue === "object" && !Array.isArray(sourceValue) && targetValue !== null && typeof targetValue === "object" && !Array.isArray(targetValue)) {
614
+ result[key] = deepMerge(
615
+ targetValue,
616
+ sourceValue
617
+ );
618
+ } else {
619
+ result[key] = sourceValue;
620
+ }
621
+ }
622
+ return result;
623
+ }
624
+ function deepFreeze(obj) {
625
+ if (obj === null || typeof obj !== "object") {
626
+ return obj;
627
+ }
628
+ if (Array.isArray(obj)) {
629
+ for (const item of obj) {
630
+ deepFreeze(item);
631
+ }
632
+ return Object.freeze(obj);
633
+ }
634
+ for (const value of Object.values(obj)) {
635
+ deepFreeze(value);
636
+ }
637
+ return Object.freeze(obj);
638
+ }
639
+ function mergeConfigs(defaults, globalConfig, repoConfig) {
640
+ let result = { ...defaults };
641
+ if (globalConfig) {
642
+ result = deepMerge(result, globalConfig);
643
+ }
644
+ if (repoConfig) {
645
+ result = deepMerge(result, repoConfig);
646
+ }
647
+ return deepFreeze(result);
648
+ }
649
+
650
+ // src/config/ConfigStore.ts
651
+ import { existsSync, readFileSync } from "fs";
652
+ import { homedir as homedir2 } from "os";
653
+ import { join } from "path";
654
+ import { parse as parseYaml2 } from "yaml";
655
+ var ConfigStore = class {
656
+ repoPath;
657
+ config = null;
658
+ constructor(repoPath) {
659
+ this.repoPath = repoPath;
660
+ }
661
+ // ─── Public API ────────────────────────────────────────
662
+ /**
663
+ * Loads and merges config files from all locations.
664
+ * Must be called before get() or getAll().
665
+ */
666
+ async load() {
667
+ const globalConfig = await this.loadGlobalConfig();
668
+ const repoConfig = await this.loadRepoConfig();
669
+ this.config = mergeConfigs(defaultConfig, globalConfig, repoConfig);
670
+ }
671
+ /**
672
+ * Gets a config value using dot notation.
673
+ *
674
+ * @param key - Dot-separated path (e.g., "budget.dailyCapUsd")
675
+ * @returns The value at the path
676
+ * @throws Error if load() has not been called
677
+ *
678
+ * @example
679
+ * store.get<number>('budget.dailyCapUsd') // 500
680
+ * store.get<string>('sessions.dir') // '/tmp/neo-sessions'
681
+ */
682
+ get(key) {
683
+ if (this.config === null) {
684
+ throw new Error("ConfigStore not loaded. Call load() first.");
685
+ }
686
+ return getConfigValue(this.config, key);
687
+ }
688
+ /**
689
+ * Returns the full merged configuration.
690
+ *
691
+ * @throws Error if load() has not been called
692
+ */
693
+ getAll() {
694
+ if (this.config === null) {
695
+ throw new Error("ConfigStore not loaded. Call load() first.");
696
+ }
697
+ return this.config;
698
+ }
699
+ /**
700
+ * Returns the repository path, if one was provided.
701
+ * Used by ConfigWatcher to determine which files to watch.
702
+ */
703
+ getRepoPath() {
704
+ return this.repoPath;
705
+ }
706
+ // ─── Private loaders ───────────────────────────────────
707
+ /**
708
+ * Loads global config from ~/.neo/config.yml
709
+ */
710
+ async loadGlobalConfig() {
711
+ const globalPath = join(homedir2(), ".neo", "config.yml");
712
+ const raw = await this.loadFile(globalPath);
713
+ if (raw === null) {
714
+ return null;
715
+ }
716
+ const parsed = neoConfigSchema.safeParse(raw);
717
+ if (!parsed.success) {
718
+ return null;
719
+ }
720
+ return parsed.data;
721
+ }
722
+ /**
723
+ * Loads repo-level overrides from <repoPath>/.neo/config.yml
724
+ */
725
+ async loadRepoConfig() {
726
+ if (!this.repoPath) {
727
+ return null;
728
+ }
729
+ const repoConfigPath = join(this.repoPath, ".neo", "config.yml");
730
+ const raw = await this.loadFile(repoConfigPath);
731
+ if (raw === null) {
732
+ return null;
733
+ }
734
+ const parsed = repoOverrideConfigSchema.safeParse(raw);
735
+ if (!parsed.success) {
736
+ return null;
737
+ }
738
+ return parsed.data;
739
+ }
740
+ /**
741
+ * Loads and parses a YAML config file.
742
+ *
743
+ * @param filePath - Absolute path to the config file
744
+ * @returns Parsed YAML content or null if file doesn't exist
745
+ */
746
+ async loadFile(filePath) {
747
+ if (!existsSync(filePath)) {
748
+ return null;
749
+ }
750
+ try {
751
+ const content = readFileSync(filePath, "utf-8");
752
+ const parsed = parseYaml2(content);
753
+ if (parsed === null || typeof parsed !== "object") {
754
+ return null;
755
+ }
756
+ return parsed;
757
+ } catch {
758
+ return null;
759
+ }
760
+ }
761
+ };
762
+
763
+ // src/config/ConfigWatcher.ts
764
+ import { EventEmitter } from "events";
765
+ import { homedir as homedir3 } from "os";
766
+ import { join as join2 } from "path";
767
+ import { watch } from "chokidar";
768
+ var ConfigWatcher = class extends EventEmitter {
769
+ store;
770
+ debounceMs;
771
+ repoPath;
772
+ watcher = null;
773
+ debounceTimer = null;
774
+ constructor(store, options) {
775
+ super();
776
+ this.store = store;
777
+ this.debounceMs = options?.debounceMs ?? 500;
778
+ this.repoPath = store.getRepoPath();
779
+ }
780
+ /**
781
+ * Starts watching config files for changes.
782
+ * Watches both global (~/.neo/config.yml) and repo config files.
783
+ */
784
+ start() {
785
+ if (this.watcher) {
786
+ return;
787
+ }
788
+ const paths = this.getConfigPaths();
789
+ this.watcher = watch(paths, {
790
+ ignoreInitial: true,
791
+ // Don't error if files don't exist — they may be created later
792
+ ignorePermissionErrors: true
793
+ });
794
+ this.watcher.on("change", () => this.handleChange());
795
+ this.watcher.on("add", () => this.handleChange());
796
+ this.watcher.on("unlink", () => this.handleChange());
797
+ }
798
+ /**
799
+ * Stops watching config files.
800
+ */
801
+ stop() {
802
+ if (this.debounceTimer) {
803
+ clearTimeout(this.debounceTimer);
804
+ this.debounceTimer = null;
805
+ }
806
+ if (this.watcher) {
807
+ this.watcher.close();
808
+ this.watcher = null;
809
+ }
810
+ }
811
+ // ─── Private ─────────────────────────────────────────────
812
+ /**
813
+ * Returns the list of config file paths to watch.
814
+ */
815
+ getConfigPaths() {
816
+ const globalPath = join2(homedir3(), ".neo", "config.yml");
817
+ const paths = [globalPath];
818
+ if (this.repoPath) {
819
+ const repoConfigPath = join2(this.repoPath, ".neo", "config.yml");
820
+ paths.push(repoConfigPath);
821
+ }
822
+ return paths;
823
+ }
824
+ /**
825
+ * Handles file change events with debouncing.
826
+ */
827
+ handleChange() {
828
+ if (this.debounceTimer) {
829
+ clearTimeout(this.debounceTimer);
830
+ }
831
+ this.debounceTimer = setTimeout(() => {
832
+ this.debounceTimer = null;
833
+ this.reloadConfig();
834
+ }, this.debounceMs);
835
+ }
836
+ /**
837
+ * Reloads the config and emits 'change' event.
838
+ */
839
+ async reloadConfig() {
840
+ try {
841
+ await this.store.load();
842
+ this.emit("change");
843
+ } catch {
844
+ }
845
+ }
846
+ };
847
+
848
+ // src/config.ts
551
849
  var DEFAULT_GLOBAL_CONFIG = {
552
850
  repos: [],
553
851
  concurrency: {
@@ -562,7 +860,7 @@ var DEFAULT_GLOBAL_CONFIG = {
562
860
  };
563
861
  function parseYamlFile(raw, filePath) {
564
862
  try {
565
- return parseYaml2(raw);
863
+ return parseYaml3(raw);
566
864
  } catch (err) {
567
865
  throw new Error(
568
866
  `Invalid YAML in ${filePath}: ${err instanceof Error ? err.message : String(err)}. Check YAML syntax at the indicated line.`
@@ -590,7 +888,7 @@ async function loadConfig(configPath) {
590
888
  }
591
889
  async function loadGlobalConfig() {
592
890
  const configPath = path4.join(getDataDir(), "config.yml");
593
- if (!existsSync(configPath)) {
891
+ if (!existsSync2(configPath)) {
594
892
  await mkdir(getDataDir(), { recursive: true });
595
893
  await writeFile(configPath, stringifyYaml(DEFAULT_GLOBAL_CONFIG), "utf-8");
596
894
  return globalConfigSchema.parse(DEFAULT_GLOBAL_CONFIG);
@@ -698,9 +996,9 @@ var CostJournal = class {
698
996
  };
699
997
 
700
998
  // src/events/emitter.ts
701
- import { EventEmitter } from "events";
999
+ import { EventEmitter as EventEmitter2 } from "events";
702
1000
  var NeoEventEmitter = class {
703
- emitter = new EventEmitter();
1001
+ emitter = new EventEmitter2();
704
1002
  emit(event) {
705
1003
  this.safeEmit(event.type, event);
706
1004
  this.safeEmit("*", event);
@@ -842,7 +1140,7 @@ function toSerializable(event) {
842
1140
 
843
1141
  // src/isolation/clone.ts
844
1142
  import { execFile } from "child_process";
845
- import { existsSync as existsSync2 } from "fs";
1143
+ import { existsSync as existsSync3 } from "fs";
846
1144
  import { mkdir as mkdir3, readdir as readdir2, rm } from "fs/promises";
847
1145
  import { dirname, resolve } from "path";
848
1146
  import { promisify } from "util";
@@ -886,14 +1184,14 @@ async function createSessionClone(options) {
886
1184
  }
887
1185
  async function removeSessionClone(sessionPath) {
888
1186
  const absPath = resolve(sessionPath);
889
- if (!existsSync2(absPath)) {
1187
+ if (!existsSync3(absPath)) {
890
1188
  return;
891
1189
  }
892
1190
  await rm(absPath, { recursive: true, force: true });
893
1191
  }
894
1192
  async function listSessionClones(sessionsBaseDir) {
895
1193
  const absBase = resolve(sessionsBaseDir);
896
- if (!existsSync2(absBase)) {
1194
+ if (!existsSync3(absBase)) {
897
1195
  return [];
898
1196
  }
899
1197
  const entries = await readdir2(absBase, { withFileTypes: true });
@@ -1191,11 +1489,11 @@ function loopDetection(options) {
1191
1489
  // src/orchestrator.ts
1192
1490
  import { randomUUID as randomUUID3 } from "crypto";
1193
1491
  import { existsSync as existsSync6 } from "fs";
1194
- import { mkdir as mkdir6, readFile as readFile7 } from "fs/promises";
1195
- import path11 from "path";
1492
+ import { mkdir as mkdir6, readFile as readFile6 } from "fs/promises";
1493
+ import path10 from "path";
1196
1494
 
1197
1495
  // src/orchestrator/run-store.ts
1198
- import { existsSync as existsSync3 } from "fs";
1496
+ import { existsSync as existsSync4 } from "fs";
1199
1497
  import { mkdir as mkdir5, readdir as readdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
1200
1498
  import path7 from "path";
1201
1499
 
@@ -1245,7 +1543,7 @@ var RunStore = class {
1245
1543
  * Returns them so the caller can emit failure events and update status.
1246
1544
  */
1247
1545
  async recoverOrphanedRuns() {
1248
- if (!existsSync3(this.runsDir)) return [];
1546
+ if (!existsSync4(this.runsDir)) return [];
1249
1547
  const orphaned = [];
1250
1548
  try {
1251
1549
  const jsonFiles = await this.collectRunFiles();
@@ -1296,9 +1594,137 @@ var RunStore = class {
1296
1594
  }
1297
1595
  };
1298
1596
 
1299
- // src/runner/session-executor.ts
1597
+ // src/orchestrator/prompt-builder.ts
1300
1598
  import { readFile as readFile5 } from "fs/promises";
1301
1599
  import path8 from "path";
1600
+ var INSTRUCTIONS_PATH = ".neo/INSTRUCTIONS.md";
1601
+ async function loadRepoInstructions(repoPath) {
1602
+ const filePath = path8.join(repoPath, INSTRUCTIONS_PATH);
1603
+ try {
1604
+ return await readFile5(filePath, "utf-8");
1605
+ } catch {
1606
+ return void 0;
1607
+ }
1608
+ }
1609
+ function buildGitStrategyInstructions(strategy, agent, branch, baseBranch, remote, metadata) {
1610
+ const prNumber = metadata?.prNumber;
1611
+ if (agent.sandbox !== "writable") {
1612
+ if (prNumber) {
1613
+ return `## Pull Request
1614
+
1615
+ PR #${String(prNumber)} is open for this task. After your review, leave your findings as a comment: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1616
+ }
1617
+ return null;
1618
+ }
1619
+ if (strategy === "pr") {
1620
+ if (prNumber) {
1621
+ return `## Git workflow
1622
+
1623
+ You are on branch \`${branch}\`.
1624
+ An open PR exists: #${String(prNumber)}.
1625
+ After committing, push your changes to the branch. The PR will be updated automatically.
1626
+ Leave a review comment on the PR summarizing what you did: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1627
+ }
1628
+ return `## Git workflow
1629
+
1630
+ You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1631
+ After committing:
1632
+ 1. Push: \`git push -u ${remote} ${branch}\`
1633
+ 2. Create a PR against \`${baseBranch}\` \u2014 choose a title and description that reflect the work you completed. End the PR body with: \`\u{1F916} Generated with [neo](https://neotx.dev)\`
1634
+ 3. Output the PR URL on a dedicated line: \`PR_URL: <url>\``;
1635
+ }
1636
+ return `## Git workflow
1637
+
1638
+ You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1639
+ Commit your changes. The branch will be pushed automatically.`;
1640
+ }
1641
+ function buildReportingInstructions(_runId) {
1642
+ return `## Reporting & Memory
1643
+
1644
+ You have two tools to communicate what you learn and do: \`neo log\` (real-time visibility) and \`neo memory\` (persistent knowledge for future agents). Use both deliberately throughout your work.
1645
+
1646
+ ### Progress reporting \u2014 \`neo log\`
1647
+
1648
+ Chain \`neo log\` with the command that triggered the event \u2014 never call it standalone.
1649
+
1650
+ <log-types>
1651
+ | Type | When to use | Example |
1652
+ |------|------------|---------|
1653
+ | \`milestone\` | A meaningful goal is achieved (tests pass, build succeeds, feature complete) | \`neo log milestone "auth middleware passing all tests"\` |
1654
+ | \`action\` | You performed a significant action (push, PR, deploy) | \`neo log action "pushed 3 commits to feat/auth"\` |
1655
+ | \`decision\` | You made a non-obvious choice \u2014 record WHY | \`neo log decision "chose JWT over sessions \u2014 stateless, simpler for MVP"\` |
1656
+ | \`blocker\` | Something is preventing progress | \`neo log blocker "CI fails: missing DATABASE_URL in test env"\` |
1657
+ | \`discovery\` | You found something surprising or important about the codebase | \`neo log discovery "API rate limiter is disabled in dev \u2014 tests hit real endpoints"\` |
1658
+ | \`progress\` | General progress update | \`neo log progress "3/5 endpoints migrated"\` |
1659
+ </log-types>
1660
+
1661
+ <log-rules>
1662
+ - **Chain with commands**: \`pnpm test && neo log milestone "tests passing" || neo log blocker "tests failing"\`
1663
+ - **Log decisions with reasoning**: the "why" is more valuable than the "what"
1664
+ - **Log blockers immediately**: do not continue silently \u2014 surface problems so the supervisor can act
1665
+ - **Log at natural boundaries**: after completing a subtask, before switching context, when hitting an obstacle
1666
+ </log-rules>
1667
+
1668
+ ### Memory \u2014 \`neo memory\`
1669
+
1670
+ Memory is persistent knowledge injected into future agent prompts. Write a memory when you learn something that would change HOW the next agent approaches work on this repo.
1671
+
1672
+ <memory-types>
1673
+ | Type | When to write | Example |
1674
+ |------|--------------|---------|
1675
+ | \`fact\` | Stable truth that affects workflow decisions | Build quirks, CI config, auth patterns, deployment constraints |
1676
+ | \`procedure\` | Non-obvious multi-step workflow learned from failure | "Run X before Y otherwise Z breaks" |
1677
+ </memory-types>
1678
+
1679
+ <memory-decision-tree>
1680
+ Before writing a memory, apply this test:
1681
+ 1. Can \`cat package.json\`, \`ls\`, or reading the README answer this? \u2192 Do NOT memorize.
1682
+ 2. Would knowing this change how you approach the task? \u2192 Write a \`fact\`.
1683
+ 3. Did you fail before discovering this workflow? \u2192 Write a \`procedure\`.
1684
+ 4. Is this just a detail about what you did (file counts, line numbers, component names)? \u2192 Do NOT memorize.
1685
+ </memory-decision-tree>
1686
+
1687
+ <memory-examples type="good">
1688
+ # Affects workflow \u2014 non-obvious build/CI constraints
1689
+ neo memory write --type fact --scope $NEO_REPOSITORY "CI requires pnpm build before push \u2014 no auto-rebuild in pipeline"
1690
+ neo memory write --type fact --scope $NEO_REPOSITORY "Biome enforces complexity max 20 \u2014 extract helpers for large functions"
1691
+
1692
+ # Learned from failure \u2014 save the next agent from the same mistake
1693
+ neo memory write --type procedure --scope $NEO_REPOSITORY "Run pnpm db:generate after any schema.prisma change \u2014 TypeScript types won't update otherwise"
1694
+ neo memory write --type procedure --scope $NEO_REPOSITORY "E2E tests need STRIPE_TEST_KEY in .env.test \u2014 tests hang silently without it"
1695
+ </memory-examples>
1696
+
1697
+ <memory-examples type="bad">
1698
+ # NEVER write these \u2014 trivial or derivable
1699
+ # "packages/core has 71 files" \u2192 derivable from ls
1700
+ # "Uses React 19" \u2192 visible in package.json
1701
+ # "Main entry is src/index.ts" \u2192 visible in package.json
1702
+ # "Tests use vitest" \u2192 visible in config files
1703
+ </memory-examples>
1704
+
1705
+ <when-to-write>
1706
+ Write memories at these key moments:
1707
+ - **After resolving a non-obvious issue**: the fix revealed a constraint future agents should know
1708
+ - **After discovering a build/CI/deploy quirk**: the next agent will hit the same wall without this
1709
+ - **Before finishing your task**: review what you learned \u2014 anything that would save the next agent 10+ minutes?
1710
+ - **After a failed attempt**: if you tried something that seemed right but failed, document why
1711
+ </when-to-write>`;
1712
+ }
1713
+ function buildFullPrompt(agentPrompt, repoInstructions, gitInstructions, taskPrompt, memoryContext, cwdInstructions, reportingInstructions) {
1714
+ const sections = [];
1715
+ if (agentPrompt) sections.push(agentPrompt);
1716
+ if (cwdInstructions) sections.push(cwdInstructions);
1717
+ if (memoryContext) sections.push(memoryContext);
1718
+ if (repoInstructions) sections.push(`## Repository instructions
1719
+
1720
+ ${repoInstructions}`);
1721
+ if (gitInstructions) sections.push(gitInstructions);
1722
+ if (reportingInstructions) sections.push(reportingInstructions);
1723
+ sections.push(`## Task
1724
+
1725
+ ${taskPrompt}`);
1726
+ return sections.join("\n\n---\n\n");
1727
+ }
1302
1728
 
1303
1729
  // src/runner/output-parser.ts
1304
1730
  function extractJson(raw) {
@@ -1542,115 +1968,27 @@ async function runWithRecovery(options) {
1542
1968
  }
1543
1969
 
1544
1970
  // src/runner/session-executor.ts
1545
- var INSTRUCTIONS_PATH = ".neo/INSTRUCTIONS.md";
1546
- async function loadRepoInstructions(repoPath) {
1547
- const filePath = path8.join(repoPath, INSTRUCTIONS_PATH);
1548
- try {
1549
- return await readFile5(filePath, "utf-8");
1550
- } catch {
1551
- return void 0;
1552
- }
1971
+ function buildMiddlewareContext(runId, step, agent, repo, getContextValue) {
1972
+ const store = /* @__PURE__ */ new Map();
1973
+ return {
1974
+ runId,
1975
+ step,
1976
+ agent,
1977
+ repo,
1978
+ get: ((key) => {
1979
+ const value = getContextValue(key);
1980
+ if (value !== void 0) return value;
1981
+ return store.get(key);
1982
+ }),
1983
+ set: ((key, value) => {
1984
+ store.set(key, value);
1985
+ })
1986
+ };
1553
1987
  }
1554
- function buildGitStrategyInstructions(strategy, agent, branch, baseBranch, remote, metadata) {
1555
- const prNumber = metadata?.prNumber;
1556
- if (agent.sandbox !== "writable") {
1557
- if (prNumber) {
1558
- return `## Pull Request
1559
-
1560
- PR #${String(prNumber)} is open for this task. After your review, leave your findings as a comment: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1561
- }
1562
- return null;
1563
- }
1564
- if (strategy === "pr") {
1565
- if (prNumber) {
1566
- return `## Git workflow
1567
-
1568
- You are on branch \`${branch}\`.
1569
- An open PR exists: #${String(prNumber)}.
1570
- After committing, push your changes to the branch. The PR will be updated automatically.
1571
- Leave a review comment on the PR summarizing what you did: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
1572
- }
1573
- return `## Git workflow
1574
-
1575
- You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1576
- After committing:
1577
- 1. Push: \`git push -u ${remote} ${branch}\`
1578
- 2. Create a PR against \`${baseBranch}\` \u2014 choose a title and description that reflect the work you completed. End the PR body with: \`\u{1F916} Generated with [neo](https://neotx.dev)\`
1579
- 3. Output the PR URL on a dedicated line: \`PR_URL: <url>\``;
1580
- }
1581
- return `## Git workflow
1582
-
1583
- You are on branch \`${branch}\` (base: \`${baseBranch}\`).
1584
- Commit your changes. The branch will be pushed automatically.`;
1585
- }
1586
- function buildReportingInstructions(_runId) {
1587
- return `## Reporting & Memory
1588
-
1589
- ### Progress reporting (real-time, visible in TUI)
1590
- Chain \`neo log\` with the command that triggered it \u2014 never standalone:
1591
- \`\`\`bash
1592
- pnpm test && neo log milestone "all tests passing" || neo log blocker "tests failing"
1593
- git push origin HEAD && neo log action "pushed to branch"
1594
- neo log decision "chose JWT over sessions \u2014 simpler for MVP"
1595
- \`\`\`
1596
-
1597
- ### Memory (persistent, injected into future agent prompts)
1598
- Write discoveries so the next agent on this repo starts smarter.
1599
-
1600
- **Be selective** \u2014 only write a memory if it would change HOW you or future agents approach work:
1601
- \`\`\`bash
1602
- # GOOD: affects workflow decisions
1603
- neo memory write --type fact --scope $NEO_REPOSITORY "CI requires pnpm build before push \u2014 no auto-rebuild in pipeline"
1604
- neo memory write --type fact --scope $NEO_REPOSITORY "Biome enforces complexity max 20 \u2014 extract helpers for large functions"
1605
- neo memory write --type procedure --scope $NEO_REPOSITORY "Integration tests require DATABASE_URL env var \u2014 set before running"
1606
-
1607
- # BAD: trivial or derivable \u2014 do NOT write these
1608
- # "packages/core has 71 files" \u2014 derivable from ls
1609
- # "Uses React 19" \u2014 visible in package.json
1610
- # "apps/web has no test framework" \u2014 derivable from ls/cat
1611
- \`\`\`
1612
-
1613
- **The test**: if \`cat package.json\`, \`ls\`, or reading the README can answer it, do NOT memorize it. Only memorize truths that affect decisions or non-obvious workflows learned from failure.
1614
-
1615
- Write at key moments: after resolving a non-obvious issue, after discovering a build/CI quirk, before finishing.`;
1616
- }
1617
- function buildFullPrompt(agentPrompt, repoInstructions, gitInstructions, taskPrompt, memoryContext, cwdInstructions, reportingInstructions) {
1618
- const sections = [];
1619
- if (agentPrompt) sections.push(agentPrompt);
1620
- if (cwdInstructions) sections.push(cwdInstructions);
1621
- if (memoryContext) sections.push(memoryContext);
1622
- if (repoInstructions) sections.push(`## Repository instructions
1623
-
1624
- ${repoInstructions}`);
1625
- if (gitInstructions) sections.push(gitInstructions);
1626
- if (reportingInstructions) sections.push(reportingInstructions);
1627
- sections.push(`## Task
1628
-
1629
- ${taskPrompt}`);
1630
- return sections.join("\n\n---\n\n");
1631
- }
1632
- function buildMiddlewareContext(runId, workflow, step, agent, repo, getContextValue) {
1633
- const store = /* @__PURE__ */ new Map();
1634
- return {
1635
- runId,
1636
- workflow,
1637
- step,
1638
- agent,
1639
- repo,
1640
- get: ((key) => {
1641
- const value = getContextValue(key);
1642
- if (value !== void 0) return value;
1643
- return store.get(key);
1644
- }),
1645
- set: ((key, value) => {
1646
- store.set(key, value);
1647
- })
1648
- };
1649
- }
1650
- var SessionExecutor = class {
1651
- constructor(config, getContextValue) {
1652
- this.config = config;
1653
- this.getContextValue = getContextValue;
1988
+ var SessionExecutor = class {
1989
+ constructor(config, getContextValue) {
1990
+ this.config = config;
1991
+ this.getContextValue = getContextValue;
1654
1992
  }
1655
1993
  /**
1656
1994
  * Execute an agent session with the given input and dependencies.
@@ -1660,7 +1998,6 @@ var SessionExecutor = class {
1660
1998
  const {
1661
1999
  runId,
1662
2000
  agent,
1663
- stepDef,
1664
2001
  repoConfig,
1665
2002
  repoPath,
1666
2003
  prompt: taskPrompt,
@@ -1681,7 +2018,6 @@ var SessionExecutor = class {
1681
2018
  const chain = buildMiddlewareChain(middleware);
1682
2019
  const middlewareContext = buildMiddlewareContext(
1683
2020
  runId,
1684
- stepDef.prompt ? "workflow" : "direct",
1685
2021
  "execute",
1686
2022
  agent.name,
1687
2023
  repoPath,
@@ -1706,12 +2042,11 @@ ALWAYS run commands from this directory. NEVER cd to or operate on any other rep
1706
2042
  agent.definition.prompt,
1707
2043
  repoInstructions,
1708
2044
  gitInstructions,
1709
- stepDef.prompt ?? taskPrompt,
2045
+ taskPrompt,
1710
2046
  memoryContext,
1711
2047
  cwdInstructions,
1712
2048
  reportingInstructions
1713
2049
  );
1714
- const recoveryOpts = stepDef.recovery;
1715
2050
  const agentEnv = {
1716
2051
  NEO_RUN_ID: runId,
1717
2052
  NEO_AGENT_NAME: agent.name,
@@ -1726,11 +2061,10 @@ ALWAYS run commands from this directory. NEVER cd to or operate on any other rep
1726
2061
  env: agentEnv,
1727
2062
  initTimeoutMs: this.config.initTimeoutMs,
1728
2063
  maxDurationMs: this.config.maxDurationMs,
1729
- maxRetries: recoveryOpts?.maxRetries ?? this.config.maxRetries,
2064
+ maxRetries: this.config.maxRetries,
1730
2065
  backoffBaseMs: this.config.backoffBaseMs,
1731
2066
  ...sessionPath ? { sessionPath } : {},
1732
2067
  ...mcpServers ? { mcpServers } : {},
1733
- ...recoveryOpts?.nonRetryable ? { nonRetryable: recoveryOpts.nonRetryable } : {},
1734
2068
  ...onAttempt ? { onAttempt } : {}
1735
2069
  });
1736
2070
  const parsed = parseOutput(sessionResult.output);
@@ -1869,7 +2203,7 @@ ${sections.join("\n\n")}`;
1869
2203
 
1870
2204
  // src/supervisor/memory/store.ts
1871
2205
  import { randomUUID as randomUUID2 } from "crypto";
1872
- import { existsSync as existsSync4, mkdirSync } from "fs";
2206
+ import { existsSync as existsSync5, mkdirSync } from "fs";
1873
2207
  import { createRequire } from "module";
1874
2208
  import path9 from "path";
1875
2209
  var esmRequire = createRequire(import.meta.url);
@@ -1879,7 +2213,7 @@ var MemoryStore = class {
1879
2213
  hasVec;
1880
2214
  constructor(dbPath, embedder) {
1881
2215
  const dir = path9.dirname(dbPath);
1882
- if (!existsSync4(dir)) {
2216
+ if (!existsSync5(dir)) {
1883
2217
  mkdirSync(dir, { recursive: true });
1884
2218
  }
1885
2219
  const Database = esmRequire("better-sqlite3");
@@ -2082,7 +2416,6 @@ var MemoryStore = class {
2082
2416
  case "createdAt":
2083
2417
  orderBy = "ORDER BY created_at DESC";
2084
2418
  break;
2085
- case "relevance":
2086
2419
  default:
2087
2420
  orderBy = "ORDER BY (access_count * MAX(0, 1.0 - (julianday('now') - julianday(last_accessed_at)) / 60.0)) DESC";
2088
2421
  break;
@@ -2222,128 +2555,6 @@ function rowToEntry(row) {
2222
2555
  };
2223
2556
  }
2224
2557
 
2225
- // src/workflows/registry.ts
2226
- import { existsSync as existsSync5 } from "fs";
2227
- import { readdir as readdir4 } from "fs/promises";
2228
- import path10 from "path";
2229
-
2230
- // src/workflows/loader.ts
2231
- import { readFile as readFile6 } from "fs/promises";
2232
- import { parse } from "yaml";
2233
- import { z as z4 } from "zod";
2234
- var workflowStepDefSchema = z4.object({
2235
- type: z4.literal("step").optional().default("step"),
2236
- agent: z4.string(),
2237
- dependsOn: z4.array(z4.string()).optional(),
2238
- prompt: z4.string().optional(),
2239
- sandbox: z4.enum(["writable", "readonly"]).optional(),
2240
- maxTurns: z4.number().int().positive().optional(),
2241
- mcpServers: z4.array(z4.string()).optional(),
2242
- recovery: z4.object({
2243
- maxRetries: z4.number().int().nonnegative().optional(),
2244
- nonRetryable: z4.array(z4.string()).optional()
2245
- }).optional(),
2246
- condition: z4.string().optional()
2247
- });
2248
- var workflowGateDefSchema = z4.object({
2249
- type: z4.literal("gate"),
2250
- dependsOn: z4.array(z4.string()).optional(),
2251
- description: z4.string(),
2252
- timeout: z4.string().optional(),
2253
- autoApprove: z4.boolean().optional()
2254
- });
2255
- var workflowHeaderSchema = z4.object({
2256
- name: z4.string().min(1),
2257
- description: z4.string().optional(),
2258
- steps: z4.record(z4.string(), z4.unknown())
2259
- });
2260
- function parseStepEntry(stepName, stepValue) {
2261
- const obj = stepValue;
2262
- const schema = obj.type === "gate" ? workflowGateDefSchema : workflowStepDefSchema;
2263
- const result = schema.safeParse(stepValue);
2264
- if (result.success) {
2265
- return { step: result.data, errors: [] };
2266
- }
2267
- return {
2268
- step: stepValue,
2269
- errors: result.error.issues.map(
2270
- (i) => ` - steps.${stepName}.${i.path.join(".")}: ${i.message}`
2271
- )
2272
- };
2273
- }
2274
- function parseSteps(rawSteps, filePath) {
2275
- if (Object.keys(rawSteps).length === 0) {
2276
- throw new Error(
2277
- `Invalid workflow definition in ${filePath}:
2278
- - steps: Workflow must have at least one step`
2279
- );
2280
- }
2281
- const steps = {};
2282
- const errors = [];
2283
- for (const [name, value] of Object.entries(rawSteps)) {
2284
- const { step, errors: stepErrors } = parseStepEntry(name, value);
2285
- if (stepErrors.length > 0) {
2286
- errors.push(...stepErrors);
2287
- } else {
2288
- steps[name] = step;
2289
- }
2290
- }
2291
- if (errors.length > 0) {
2292
- throw new Error(`Invalid workflow definition in ${filePath}:
2293
- ${errors.join("\n")}`);
2294
- }
2295
- return steps;
2296
- }
2297
- async function loadWorkflow(filePath) {
2298
- const content = await readFile6(filePath, "utf-8");
2299
- const raw = parse(content);
2300
- const headerResult = workflowHeaderSchema.safeParse(raw);
2301
- if (!headerResult.success) {
2302
- const issues = headerResult.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
2303
- throw new Error(`Invalid workflow definition in ${filePath}:
2304
- ${issues}`);
2305
- }
2306
- const { name, description, steps: rawSteps } = headerResult.data;
2307
- const steps = parseSteps(rawSteps, filePath);
2308
- return { name, description, steps };
2309
- }
2310
-
2311
- // src/workflows/registry.ts
2312
- var WorkflowRegistry = class {
2313
- builtInDir;
2314
- customDir;
2315
- workflows = /* @__PURE__ */ new Map();
2316
- constructor(builtInDir, customDir) {
2317
- this.builtInDir = builtInDir;
2318
- this.customDir = customDir;
2319
- }
2320
- async load() {
2321
- await this.loadFromDir(this.builtInDir);
2322
- if (this.customDir) {
2323
- await this.loadFromDir(this.customDir);
2324
- }
2325
- }
2326
- get(name) {
2327
- return this.workflows.get(name);
2328
- }
2329
- list() {
2330
- return [...this.workflows.values()];
2331
- }
2332
- has(name) {
2333
- return this.workflows.has(name);
2334
- }
2335
- async loadFromDir(dir) {
2336
- if (!existsSync5(dir)) return;
2337
- const files = await readdir4(dir);
2338
- for (const file of files) {
2339
- if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
2340
- const filePath = path10.join(dir, file);
2341
- const workflow = await loadWorkflow(filePath);
2342
- this.workflows.set(workflow.name, workflow);
2343
- }
2344
- }
2345
- };
2346
-
2347
2558
  // src/orchestrator.ts
2348
2559
  var MAX_PROMPT_SIZE = 100 * 1024;
2349
2560
  var MAX_METADATA_DEPTH = 5;
@@ -2353,7 +2564,6 @@ var Orchestrator = class extends NeoEventEmitter {
2353
2564
  config;
2354
2565
  semaphore;
2355
2566
  userMiddleware;
2356
- workflows = /* @__PURE__ */ new Map();
2357
2567
  registeredAgents = /* @__PURE__ */ new Map();
2358
2568
  _activeSessions = /* @__PURE__ */ new Map();
2359
2569
  idempotencyCache = /* @__PURE__ */ new Map();
@@ -2361,8 +2571,6 @@ var Orchestrator = class extends NeoEventEmitter {
2361
2571
  repoIndex = /* @__PURE__ */ new Map();
2362
2572
  runStore = new RunStore();
2363
2573
  journalDir;
2364
- builtInWorkflowDir;
2365
- customWorkflowDir;
2366
2574
  costJournal = null;
2367
2575
  eventJournal = null;
2368
2576
  webhookDispatcher = null;
@@ -2377,11 +2585,9 @@ var Orchestrator = class extends NeoEventEmitter {
2377
2585
  this.config = config;
2378
2586
  this.userMiddleware = options.middleware ?? [];
2379
2587
  this.journalDir = options.journalDir ?? getJournalsDir();
2380
- this.builtInWorkflowDir = options.builtInWorkflowDir;
2381
- this.customWorkflowDir = options.customWorkflowDir;
2382
2588
  this.skipOrphanRecovery = options.skipOrphanRecovery ?? false;
2383
2589
  for (const repo of config.repos) {
2384
- const resolvedPath = path11.resolve(repo.path);
2590
+ const resolvedPath = path10.resolve(repo.path);
2385
2591
  const normalizedRepo = { ...repo, path: resolvedPath };
2386
2592
  this.repoIndex.set(resolvedPath, normalizedRepo);
2387
2593
  }
@@ -2414,9 +2620,6 @@ var Orchestrator = class extends NeoEventEmitter {
2414
2620
  );
2415
2621
  }
2416
2622
  // ─── Registration ──────────────────────────────────────
2417
- registerWorkflow(definition) {
2418
- this.workflows.set(definition.name, definition);
2419
- }
2420
2623
  registerAgent(agent) {
2421
2624
  this.registeredAgents.set(agent.name, agent);
2422
2625
  }
@@ -2490,13 +2693,6 @@ var Orchestrator = class extends NeoEventEmitter {
2490
2693
  );
2491
2694
  }
2492
2695
  this._costToday = await this.costJournal.getDayTotal();
2493
- if (this.builtInWorkflowDir) {
2494
- const registry = new WorkflowRegistry(this.builtInWorkflowDir, this.customWorkflowDir);
2495
- await registry.load();
2496
- for (const workflow of registry.list()) {
2497
- this.registerWorkflow(workflow);
2498
- }
2499
- }
2500
2696
  if (!this.skipOrphanRecovery) {
2501
2697
  await this.recoverOrphanedRuns();
2502
2698
  }
@@ -2568,21 +2764,18 @@ var Orchestrator = class extends NeoEventEmitter {
2568
2764
  buildDispatchContext(input) {
2569
2765
  const runId = input.runId ?? randomUUID3();
2570
2766
  const sessionId = randomUUID3();
2571
- const workflow = this.workflows.get(input.workflow);
2572
- if (!workflow) {
2573
- const available = [...this.workflows.keys()].join(", ") || "none";
2767
+ const agent = this.registeredAgents.get(input.agent);
2768
+ if (!agent) {
2769
+ const available = [...this.registeredAgents.keys()].join(", ") || "none";
2574
2770
  throw new Error(
2575
- `Workflow "${input.workflow}" not found. Available workflows: ${available}. Check the workflow name or register it first.`
2771
+ `Agent "${input.agent}" not found. Available agents: ${available}. Register the agent first.`
2576
2772
  );
2577
2773
  }
2578
- const [stepName, stepDef] = this.getFirstStep(workflow, input);
2579
- const agent = this.resolveStepAgent(stepDef, workflow.name);
2580
2774
  const repoConfig = this.resolveRepo(input.repo);
2581
2775
  const activeSession = {
2582
2776
  sessionId,
2583
2777
  runId,
2584
- workflow: input.workflow,
2585
- step: stepName,
2778
+ step: "execute",
2586
2779
  agent: agent.name,
2587
2780
  repo: input.repo,
2588
2781
  status: "queued",
@@ -2594,8 +2787,6 @@ var Orchestrator = class extends NeoEventEmitter {
2594
2787
  runId,
2595
2788
  sessionId,
2596
2789
  startedAt: Date.now(),
2597
- stepName,
2598
- stepDef,
2599
2790
  agent,
2600
2791
  repoConfig,
2601
2792
  activeSession
@@ -2607,7 +2798,7 @@ var Orchestrator = class extends NeoEventEmitter {
2607
2798
  await this.persistRun({
2608
2799
  version: 1,
2609
2800
  runId,
2610
- workflow: input.workflow,
2801
+ agent: agent.name,
2611
2802
  repo: input.repo,
2612
2803
  prompt: input.prompt,
2613
2804
  pid: process.pid,
@@ -2619,7 +2810,7 @@ var Orchestrator = class extends NeoEventEmitter {
2619
2810
  });
2620
2811
  try {
2621
2812
  const branchName = input.branch || repoConfig.defaultBranch;
2622
- const sessionDir = path11.join(this.config.sessions.dir, runId);
2813
+ const sessionDir = path10.join(this.config.sessions.dir, runId);
2623
2814
  const info = await createSessionClone({
2624
2815
  repoPath: input.repo,
2625
2816
  branch: branchName,
@@ -2692,13 +2883,12 @@ var Orchestrator = class extends NeoEventEmitter {
2692
2883
  }
2693
2884
  }
2694
2885
  async runAgentSession(ctx, sessionPath) {
2695
- const { input, runId, sessionId, stepName, stepDef, agent, repoConfig, activeSession } = ctx;
2886
+ const { input, runId, sessionId, agent, repoConfig, activeSession } = ctx;
2696
2887
  this.emit({
2697
2888
  type: "session:start",
2698
2889
  sessionId,
2699
2890
  runId,
2700
- workflow: input.workflow,
2701
- step: stepName,
2891
+ step: "execute",
2702
2892
  agent: agent.name,
2703
2893
  repo: input.repo,
2704
2894
  metadata: input.metadata,
@@ -2718,15 +2908,13 @@ var Orchestrator = class extends NeoEventEmitter {
2718
2908
  }
2719
2909
  );
2720
2910
  const strategy = input.gitStrategy ?? repoConfig.gitStrategy ?? "branch";
2721
- const mcpServers = this.resolveMcpServers(stepDef, agent);
2911
+ const mcpServers = this.resolveMcpServers(agent);
2722
2912
  const memoryContext = this.loadMemoryContext(input.repo);
2723
- const recoveryOpts = stepDef.recovery;
2724
2913
  const result = await executor.execute(
2725
2914
  {
2726
2915
  runId,
2727
2916
  sessionId,
2728
2917
  agent,
2729
- stepDef,
2730
2918
  repoConfig,
2731
2919
  repoPath: input.repo,
2732
2920
  prompt: input.prompt,
@@ -2748,7 +2936,7 @@ var Orchestrator = class extends NeoEventEmitter {
2748
2936
  runId,
2749
2937
  error: `Retrying with strategy: ${strategy2}`,
2750
2938
  attempt: attempt - 1,
2751
- maxRetries: recoveryOpts?.maxRetries ?? this.config.recovery.maxRetries,
2939
+ maxRetries: this.config.recovery.maxRetries,
2752
2940
  willRetry: true,
2753
2941
  metadata: input.metadata,
2754
2942
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -2773,13 +2961,13 @@ var Orchestrator = class extends NeoEventEmitter {
2773
2961
  return result;
2774
2962
  }
2775
2963
  async finalizeDispatch(ctx, stepResult, idempotencyKey) {
2776
- const { input, runId, stepName, activeSession } = ctx;
2964
+ const { input, runId, agent, activeSession } = ctx;
2777
2965
  const taskResult = {
2778
2966
  runId,
2779
- workflow: input.workflow,
2967
+ agent: agent.name,
2780
2968
  repo: input.repo,
2781
2969
  status: stepResult.status === "success" ? "success" : "failure",
2782
- steps: { [stepName]: stepResult },
2970
+ steps: { execute: stepResult },
2783
2971
  branch: stepResult.status === "success" && activeSession.sessionPath ? input.branch : void 0,
2784
2972
  costUsd: stepResult.costUsd,
2785
2973
  durationMs: Date.now() - ctx.startedAt,
@@ -2795,7 +2983,7 @@ var Orchestrator = class extends NeoEventEmitter {
2795
2983
  await this.persistRun({
2796
2984
  version: 1,
2797
2985
  runId,
2798
- workflow: input.workflow,
2986
+ agent: agent.name,
2799
2987
  repo: input.repo,
2800
2988
  prompt: input.prompt,
2801
2989
  pid: process.pid,
@@ -2818,8 +3006,8 @@ var Orchestrator = class extends NeoEventEmitter {
2818
3006
  // ─── Private: Memory injection ──────────────────────────
2819
3007
  getMemoryStore() {
2820
3008
  if (!this.memoryStore) {
2821
- const supervisorDir = path11.join(getSupervisorsDir(), "supervisor");
2822
- this.memoryStore = new MemoryStore(path11.join(supervisorDir, "memory.sqlite"));
3009
+ const supervisorDir = path10.join(getSupervisorsDir(), "supervisor");
3010
+ this.memoryStore = new MemoryStore(path10.join(supervisorDir, "memory.sqlite"));
2823
3011
  }
2824
3012
  return this.memoryStore;
2825
3013
  }
@@ -2846,8 +3034,7 @@ var Orchestrator = class extends NeoEventEmitter {
2846
3034
  const costEntry = {
2847
3035
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2848
3036
  runId: ctx.runId,
2849
- workflow: ctx.input.workflow,
2850
- step: ctx.stepName,
3037
+ step: "execute",
2851
3038
  sessionId,
2852
3039
  agent: ctx.agent.name,
2853
3040
  costUsd: sessionCost,
@@ -2916,8 +3103,8 @@ var Orchestrator = class extends NeoEventEmitter {
2916
3103
  if (!existsSync6(input.repo)) {
2917
3104
  throw new Error(`Validation error: repo path does not exist: ${input.repo}`);
2918
3105
  }
2919
- if (!this.workflows.has(input.workflow)) {
2920
- throw new Error(`Validation error: workflow "${input.workflow}" not found in registry`);
3106
+ if (!this.registeredAgents.has(input.agent)) {
3107
+ throw new Error(`Validation error: agent "${input.agent}" not found in registry`);
2921
3108
  }
2922
3109
  if (input.metadata !== void 0) {
2923
3110
  if (!isPlainObject(input.metadata)) {
@@ -2948,45 +3135,12 @@ var Orchestrator = class extends NeoEventEmitter {
2948
3135
  if (!idempotency?.enabled) return null;
2949
3136
  const key = idempotency.key ?? "metadata";
2950
3137
  if (key === "prompt") {
2951
- return `${input.workflow}:${input.repo}:${input.prompt}`;
2952
- }
2953
- return `${input.workflow}:${input.repo}:${JSON.stringify(input.metadata ?? {})}`;
2954
- }
2955
- getFirstStep(workflow, input) {
2956
- if (input.step) {
2957
- const step = workflow.steps[input.step];
2958
- if (!step || step.type === "gate") {
2959
- throw new Error(
2960
- `Step "${input.step}" not found in workflow "${workflow.name}" or is a gate step. Check the step name in the workflow definition.`
2961
- );
2962
- }
2963
- return [input.step, step];
2964
- }
2965
- for (const [name, step] of Object.entries(workflow.steps)) {
2966
- if (step.type === "gate") continue;
2967
- const stepDef = step;
2968
- if (!stepDef.dependsOn || stepDef.dependsOn.length === 0) {
2969
- return [name, stepDef];
2970
- }
2971
- }
2972
- const entries = Object.entries(workflow.steps);
2973
- const first = entries[0];
2974
- if (!first) {
2975
- throw new Error(`Workflow "${workflow.name}" has no steps`);
2976
- }
2977
- return [first[0], first[1]];
2978
- }
2979
- resolveStepAgent(step, workflowName) {
2980
- const agent = this.registeredAgents.get(step.agent);
2981
- if (!agent) {
2982
- throw new Error(
2983
- `Agent "${step.agent}" required by workflow "${workflowName}" not found in registry. Register the agent or check the workflow definition.`
2984
- );
3138
+ return `${input.agent}:${input.repo}:${input.prompt}`;
2985
3139
  }
2986
- return agent;
3140
+ return `${input.agent}:${input.repo}:${JSON.stringify(input.metadata ?? {})}`;
2987
3141
  }
2988
3142
  resolveRepo(repoPath) {
2989
- const repo = this.repoIndex.get(path11.resolve(repoPath));
3143
+ const repo = this.repoIndex.get(path10.resolve(repoPath));
2990
3144
  if (repo) return repo;
2991
3145
  return {
2992
3146
  path: repoPath,
@@ -3002,17 +3156,11 @@ var Orchestrator = class extends NeoEventEmitter {
3002
3156
  return Math.max(0, (cap - this._costToday) / cap * 100);
3003
3157
  }
3004
3158
  // ─── Private: MCP server resolution ────────────────────
3005
- resolveMcpServers(stepDef, agent) {
3159
+ resolveMcpServers(agent) {
3006
3160
  const configServers = this.config.mcpServers;
3007
3161
  if (!configServers) return void 0;
3008
- const names = /* @__PURE__ */ new Set();
3009
- if (stepDef.mcpServers) {
3010
- for (const name of stepDef.mcpServers) names.add(name);
3011
- }
3012
- if (agent.definition.mcpServers) {
3013
- for (const name of agent.definition.mcpServers) names.add(name);
3014
- }
3015
- if (names.size === 0) return void 0;
3162
+ const names = agent.definition.mcpServers;
3163
+ if (!names || names.length === 0) return void 0;
3016
3164
  const resolved = {};
3017
3165
  for (const name of names) {
3018
3166
  const serverConfig = configServers[name];
@@ -3034,8 +3182,8 @@ var Orchestrator = class extends NeoEventEmitter {
3034
3182
  for (const entry of entries) {
3035
3183
  if (!entry.isDirectory()) continue;
3036
3184
  try {
3037
- const statePath = path11.join(supervisorsDir, entry.name, "state.json");
3038
- const raw = await readFile7(statePath, "utf-8");
3185
+ const statePath = path10.join(supervisorsDir, entry.name, "state.json");
3186
+ const raw = await readFile6(statePath, "utf-8");
3039
3187
  const state = JSON.parse(raw);
3040
3188
  if (state.status !== "running" || !state.port) continue;
3041
3189
  if (state.pid && !isProcessAlive(state.pid)) continue;
@@ -3088,13 +3236,176 @@ function objectDepth(obj, current = 0) {
3088
3236
 
3089
3237
  // src/supervisor/schemas.ts
3090
3238
  import { z as z5 } from "zod";
3239
+
3240
+ // src/supervisor/decisions.ts
3241
+ import { randomUUID as randomUUID4 } from "crypto";
3242
+ import { appendFile as appendFile4, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
3243
+ import path11 from "path";
3244
+ import { z as z4 } from "zod";
3245
+ var decisionOptionSchema = z4.object({
3246
+ key: z4.string(),
3247
+ label: z4.string(),
3248
+ description: z4.string().optional()
3249
+ });
3250
+ var decisionSchema = z4.object({
3251
+ id: z4.string(),
3252
+ question: z4.string(),
3253
+ context: z4.string().optional(),
3254
+ options: z4.array(decisionOptionSchema).optional(),
3255
+ type: z4.string().default("generic"),
3256
+ source: z4.string(),
3257
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
3258
+ createdAt: z4.string(),
3259
+ expiresAt: z4.string().optional(),
3260
+ defaultAnswer: z4.string().optional(),
3261
+ answeredAt: z4.string().optional(),
3262
+ answer: z4.string().optional(),
3263
+ expiredAt: z4.string().optional()
3264
+ });
3265
+ var DecisionStore = class {
3266
+ filePath;
3267
+ dir;
3268
+ dirCache = /* @__PURE__ */ new Set();
3269
+ constructor(filePath) {
3270
+ this.filePath = filePath;
3271
+ this.dir = path11.dirname(filePath);
3272
+ }
3273
+ /**
3274
+ * Create a new decision and persist it.
3275
+ * @returns The generated decision ID
3276
+ */
3277
+ async create(input) {
3278
+ await ensureDir(this.dir, this.dirCache);
3279
+ const id = `dec_${randomUUID4().replace(/-/g, "").slice(0, 20)}`;
3280
+ const decision = {
3281
+ ...input,
3282
+ id,
3283
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3284
+ };
3285
+ await appendFile4(this.filePath, `${JSON.stringify(decision)}
3286
+ `, "utf-8");
3287
+ return id;
3288
+ }
3289
+ /**
3290
+ * Answer a decision by ID.
3291
+ * Reads all entries, updates the matching one, and rewrites the file.
3292
+ */
3293
+ async answer(id, answer) {
3294
+ const decisions = await this.readAll();
3295
+ const decision = decisions.find((d) => d.id === id);
3296
+ if (!decision) {
3297
+ throw new Error(`Decision not found: ${id}`);
3298
+ }
3299
+ if (decision.answer !== void 0) {
3300
+ throw new Error(`Decision already answered: ${id}`);
3301
+ }
3302
+ decision.answer = answer;
3303
+ decision.answeredAt = (/* @__PURE__ */ new Date()).toISOString();
3304
+ await this.writeAll(decisions);
3305
+ }
3306
+ /**
3307
+ * Get all pending decisions (unanswered, not expired, not timed out).
3308
+ */
3309
+ async pending() {
3310
+ const decisions = await this.readAll();
3311
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3312
+ return decisions.filter((d) => {
3313
+ if (d.answer !== void 0) return false;
3314
+ if (d.expiredAt !== void 0) return false;
3315
+ if (d.expiresAt && d.expiresAt < now) return false;
3316
+ return true;
3317
+ });
3318
+ }
3319
+ /**
3320
+ * Get answered decisions, optionally filtered by timestamp.
3321
+ * @param since - ISO timestamp to filter decisions answered after this time
3322
+ */
3323
+ async answered(since) {
3324
+ const decisions = await this.readAll();
3325
+ return decisions.filter((d) => {
3326
+ if (d.answer === void 0) return false;
3327
+ if (since && d.answeredAt && d.answeredAt < since) return false;
3328
+ return true;
3329
+ });
3330
+ }
3331
+ /**
3332
+ * Get a specific decision by ID.
3333
+ */
3334
+ async get(id) {
3335
+ const decisions = await this.readAll();
3336
+ return decisions.find((d) => d.id === id) ?? null;
3337
+ }
3338
+ /**
3339
+ * Auto-answer expired decisions with their defaultAnswer.
3340
+ * Decisions without defaultAnswer are marked as expired (expiredAt).
3341
+ * @returns The decisions that were auto-answered or marked expired
3342
+ */
3343
+ async expire() {
3344
+ const decisions = await this.readAll();
3345
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3346
+ const expired = [];
3347
+ for (const decision of decisions) {
3348
+ if (decision.answer === void 0 && decision.expiredAt === void 0 && decision.expiresAt && decision.expiresAt < now) {
3349
+ if (decision.defaultAnswer !== void 0) {
3350
+ decision.answer = decision.defaultAnswer;
3351
+ decision.answeredAt = now;
3352
+ } else {
3353
+ decision.expiredAt = now;
3354
+ }
3355
+ expired.push(decision);
3356
+ }
3357
+ }
3358
+ if (expired.length > 0) {
3359
+ await this.writeAll(decisions);
3360
+ }
3361
+ return expired;
3362
+ }
3363
+ // ─── Private helpers ─────────────────────────────────────
3364
+ async readAll() {
3365
+ let content;
3366
+ try {
3367
+ content = await readFile7(this.filePath, "utf-8");
3368
+ } catch (error) {
3369
+ if (error.code === "ENOENT") {
3370
+ return [];
3371
+ }
3372
+ throw error;
3373
+ }
3374
+ const decisions = [];
3375
+ for (const line of content.split("\n")) {
3376
+ if (!line.trim()) continue;
3377
+ try {
3378
+ const parsed = decisionSchema.parse(JSON.parse(line));
3379
+ decisions.push(parsed);
3380
+ } catch (error) {
3381
+ console.warn(
3382
+ `[DecisionStore] Skipping malformed JSONL line: ${error instanceof Error ? error.message : "unknown error"}`
3383
+ );
3384
+ }
3385
+ }
3386
+ return decisions;
3387
+ }
3388
+ async writeAll(decisions) {
3389
+ await ensureDir(this.dir, this.dirCache);
3390
+ const content = `${decisions.map((d) => JSON.stringify(d)).join("\n")}
3391
+ `;
3392
+ await writeFile3(this.filePath, content, "utf-8");
3393
+ }
3394
+ };
3395
+
3396
+ // src/supervisor/schemas.ts
3091
3397
  var wakeReasonSchema = z5.enum(["events", "timer", "active_runs", "forced"]);
3092
- var supervisorDaemonStateSchema = z5.object({
3398
+ var supervisorBaseFieldsSchema = z5.object({
3093
3399
  pid: z5.number(),
3094
3400
  sessionId: z5.string(),
3401
+ startedAt: z5.string(),
3402
+ heartbeatCount: z5.number(),
3403
+ totalCostUsd: z5.number(),
3404
+ todayCostUsd: z5.number()
3405
+ });
3406
+ var supervisorDaemonStateSchema = supervisorBaseFieldsSchema.extend({
3095
3407
  port: z5.number(),
3096
3408
  cwd: z5.string(),
3097
- startedAt: z5.string(),
3098
3409
  lastHeartbeat: z5.string().optional(),
3099
3410
  heartbeatCount: z5.number().default(0),
3100
3411
  totalCostUsd: z5.number().default(0),
@@ -3130,6 +3441,7 @@ var activityEntrySchema = z5.object({
3130
3441
  "decision",
3131
3442
  "action",
3132
3443
  "error",
3444
+ "warning",
3133
3445
  "event",
3134
3446
  "message",
3135
3447
  "thinking",
@@ -3153,10 +3465,32 @@ var logBufferEntrySchema = z5.object({
3153
3465
  consolidatedAt: z5.string().optional()
3154
3466
  });
3155
3467
  var internalEventKindSchema = z5.enum(["consolidation_timer", "active_run_check"]);
3468
+ var supervisorStatusSchema = supervisorBaseFieldsSchema.extend({
3469
+ status: z5.enum(["running", "idle", "stopping"]),
3470
+ lastHeartbeat: z5.string(),
3471
+ activeRunCount: z5.number(),
3472
+ recentActivitySummary: z5.array(z5.string())
3473
+ });
3474
+ var activityTypeFilterSchema = z5.enum([
3475
+ "decision",
3476
+ "action",
3477
+ "error",
3478
+ "event",
3479
+ "message",
3480
+ "plan",
3481
+ "dispatch"
3482
+ ]);
3483
+ var activityQueryOptionsSchema = z5.object({
3484
+ limit: z5.number().int().min(1).max(500).default(50).optional(),
3485
+ offset: z5.number().int().min(0).default(0).optional(),
3486
+ type: activityTypeFilterSchema.optional(),
3487
+ since: z5.string().datetime().optional(),
3488
+ until: z5.string().datetime().optional()
3489
+ });
3156
3490
 
3157
3491
  // src/supervisor/activity-log.ts
3158
- import { randomUUID as randomUUID4 } from "crypto";
3159
- import { appendFile as appendFile4, readFile as readFile8, rename, stat } from "fs/promises";
3492
+ import { randomUUID as randomUUID5 } from "crypto";
3493
+ import { appendFile as appendFile5, readFile as readFile8, rename, stat } from "fs/promises";
3160
3494
  import path12 from "path";
3161
3495
  var ACTIVITY_FILE = "activity.jsonl";
3162
3496
  var MAX_SIZE_BYTES = 10 * 1024 * 1024;
@@ -3175,14 +3509,14 @@ var ActivityLog = class {
3175
3509
  await this.checkRotation();
3176
3510
  const line = `${JSON.stringify(entry)}
3177
3511
  `;
3178
- await appendFile4(this.filePath, line, "utf-8");
3512
+ await appendFile5(this.filePath, line, "utf-8");
3179
3513
  }
3180
3514
  /**
3181
3515
  * Create and append a new entry with auto-generated id and timestamp.
3182
3516
  */
3183
3517
  async log(type, summary, detail) {
3184
3518
  await this.append({
3185
- id: randomUUID4(),
3519
+ id: randomUUID5(),
3186
3520
  type,
3187
3521
  summary,
3188
3522
  detail,
@@ -3224,15 +3558,15 @@ var ActivityLog = class {
3224
3558
  };
3225
3559
 
3226
3560
  // src/supervisor/daemon.ts
3227
- import { randomUUID as randomUUID6 } from "crypto";
3561
+ import { randomUUID as randomUUID7 } from "crypto";
3228
3562
  import { existsSync as existsSync8 } from "fs";
3229
- import { mkdir as mkdir7, readFile as readFile12, rm as rm2, writeFile as writeFile6 } from "fs/promises";
3230
- import { homedir as homedir3 } from "os";
3563
+ import { mkdir as mkdir7, readFile as readFile12, rm as rm2, writeFile as writeFile7 } from "fs/promises";
3564
+ import { homedir as homedir5 } from "os";
3231
3565
  import path15 from "path";
3232
3566
 
3233
3567
  // src/supervisor/event-queue.ts
3234
- import { watch } from "fs";
3235
- import { readFile as readFile9, writeFile as writeFile3 } from "fs/promises";
3568
+ import { watch as watch2 } from "fs";
3569
+ import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
3236
3570
  var EventQueue = class {
3237
3571
  queue = [];
3238
3572
  seenIds = /* @__PURE__ */ new Set();
@@ -3282,13 +3616,14 @@ var EventQueue = class {
3282
3616
  /**
3283
3617
  * Drain and group events: deduplicates messages by content,
3284
3618
  * keeps webhooks and run completions separate.
3619
+ * Returns both grouped events AND original raw events for later marking as processed.
3285
3620
  */
3286
3621
  drainAndGroup() {
3287
- const events = this.drain();
3622
+ const rawEvents = this.drain();
3288
3623
  const messageMap = /* @__PURE__ */ new Map();
3289
3624
  const webhooks = [];
3290
3625
  const runCompletions = [];
3291
- for (const event of events) {
3626
+ for (const event of rawEvents) {
3292
3627
  if (event.kind === "message") {
3293
3628
  const key = event.data.text.trim().toLowerCase();
3294
3629
  const existing = messageMap.get(key);
@@ -3304,9 +3639,12 @@ var EventQueue = class {
3304
3639
  }
3305
3640
  }
3306
3641
  return {
3307
- messages: [...messageMap.values()],
3308
- webhooks,
3309
- runCompletions
3642
+ grouped: {
3643
+ messages: [...messageMap.values()],
3644
+ webhooks,
3645
+ runCompletions
3646
+ },
3647
+ rawEvents
3310
3648
  };
3311
3649
  }
3312
3650
  size() {
@@ -3319,9 +3657,8 @@ var EventQueue = class {
3319
3657
  async startWatching(inboxPath, eventsPath) {
3320
3658
  for (const p of [inboxPath, eventsPath]) {
3321
3659
  try {
3322
- await writeFile3(p, "", { flag: "a" });
3323
- } catch (err) {
3324
- console.error(`[EventQueue] Failed to ensure file exists: ${p}`, err);
3660
+ await writeFile4(p, "", { flag: "a" });
3661
+ } catch {
3325
3662
  }
3326
3663
  }
3327
3664
  this.watchJsonlFile(inboxPath, "message");
@@ -3370,14 +3707,12 @@ var EventQueue = class {
3370
3707
  }
3371
3708
  watchJsonlFile(filePath, kind) {
3372
3709
  try {
3373
- const watcher = watch(filePath, () => {
3374
- this.readNewLines(filePath, kind).catch((err) => {
3375
- console.error(`[EventQueue] Failed to read new lines from ${filePath}:`, err);
3710
+ const watcher = watch2(filePath, () => {
3711
+ this.readNewLines(filePath, kind).catch(() => {
3376
3712
  });
3377
3713
  });
3378
3714
  this.watchers.push(watcher);
3379
- } catch (err) {
3380
- console.error(`[EventQueue] Cannot watch file (may not exist yet): ${filePath}`, err);
3715
+ } catch {
3381
3716
  }
3382
3717
  }
3383
3718
  async readNewLines(filePath, kind) {
@@ -3461,24 +3796,23 @@ var EventQueue = class {
3461
3796
  return line;
3462
3797
  });
3463
3798
  if (changed) {
3464
- await writeFile3(filePath, updated.join("\n"), "utf-8");
3799
+ await writeFile4(filePath, updated.join("\n"), "utf-8");
3465
3800
  this.fileOffsets.set(filePath, updated.join("\n").length);
3466
3801
  }
3467
- } catch (err) {
3468
- console.error(`[EventQueue] Failed to mark events as processed in ${filePath}:`, err);
3802
+ } catch {
3469
3803
  }
3470
3804
  }
3471
3805
  };
3472
3806
 
3473
3807
  // src/supervisor/heartbeat.ts
3474
- import { randomUUID as randomUUID5 } from "crypto";
3808
+ import { randomUUID as randomUUID6 } from "crypto";
3475
3809
  import { existsSync as existsSync7 } from "fs";
3476
- import { readdir as readdir5, readFile as readFile11, writeFile as writeFile5 } from "fs/promises";
3477
- import { homedir as homedir2 } from "os";
3810
+ import { readdir as readdir4, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3811
+ import { homedir as homedir4 } from "os";
3478
3812
  import path14 from "path";
3479
3813
 
3480
3814
  // src/supervisor/log-buffer.ts
3481
- import { appendFile as appendFile5, readFile as readFile10, stat as stat2, writeFile as writeFile4 } from "fs/promises";
3815
+ import { appendFile as appendFile6, readFile as readFile10, stat as stat2, writeFile as writeFile5 } from "fs/promises";
3482
3816
  import path13 from "path";
3483
3817
  var LOG_BUFFER_FILE = "log-buffer.jsonl";
3484
3818
  var MAX_FILE_BYTES = 1024 * 1024;
@@ -3532,7 +3866,7 @@ async function markConsolidated(dir, ids) {
3532
3866
  updated.push(line);
3533
3867
  }
3534
3868
  }
3535
- await writeFile4(filePath, `${updated.join("\n")}
3869
+ await writeFile5(filePath, `${updated.join("\n")}
3536
3870
  `, "utf-8");
3537
3871
  }
3538
3872
  async function compactLogBuffer(dir) {
@@ -3566,37 +3900,31 @@ async function compactLogBuffer(dir) {
3566
3900
  result = `${kept.join("\n")}
3567
3901
  `;
3568
3902
  }
3569
- await writeFile4(filePath, result, "utf-8");
3903
+ await writeFile5(filePath, result, "utf-8");
3570
3904
  }
3571
3905
  async function appendLogBuffer(dir, entry) {
3572
3906
  try {
3573
- await appendFile5(bufferPath(dir), `${JSON.stringify(entry)}
3907
+ await appendFile6(bufferPath(dir), `${JSON.stringify(entry)}
3574
3908
  `, "utf-8");
3575
3909
  } catch {
3576
3910
  }
3577
3911
  }
3578
3912
 
3579
3913
  // src/supervisor/prompt-builder.ts
3580
- var ROLE = `You are the neo autonomous supervisor \u2014 the engineering manager your agents deserve.
3581
-
3582
- You don't write code. You make sure the right work happens, at the right time, by the right agent \u2014 and you follow through until it's done.
3583
-
3584
- <mindset>
3585
- - You are accountable for delivery. A task in the queue that nobody is working on is YOUR problem.
3586
- - Be the manager you'd want: give agents clear context, check their output, unblock them when they're stuck.
3587
- - Think before dispatching. Read the task context, understand what's needed, craft a prompt that sets the agent up to succeed on the first try.
3588
- - When a run completes, ALWAYS read its output. Verify the result meets the acceptance criteria. If it doesn't, figure out why and act \u2014 re-dispatch with better instructions, file a follow-up, or escalate.
3589
- - When a run fails, diagnose before retrying. Read the output, check if the prompt was unclear, if the branch had conflicts, if the agent hit a known issue. Fix the root cause.
3590
- - Never let work stall silently. If a run has been active too long, check on it. If a task is blocked, find what unblocks it. If nothing is happening, ask why.
3591
- </mindset>
3592
-
3593
- <behavioral-contract>
3594
- - Your ONLY visible output is \`neo log\` commands. The TUI shows these and nothing else.
3595
- - Your text output is NEVER shown to anyone \u2014 every token of text is wasted cost.
3596
- - Produce tool calls, not explanations. Do not narrate your reasoning.
3597
- - You NEVER modify code \u2014 that is the agents' job.
3598
- - You can read code in the available repos (path in \`neo repos\` command)
3599
- </behavioral-contract>`;
3914
+ var ROLE = `You are the neo autonomous supervisor \u2014 accountable for delivery across parallel initiatives.
3915
+
3916
+ You do not write code directly; you ensure the right work is assigned, executed, reviewed, and completed by the right agent.`;
3917
+ var OPERATING_PRINCIPLES = `### Operating principles
3918
+
3919
+ - Own delivery end-to-end: any queued task without an active owner is your responsibility.
3920
+ - Operate like a strong engineering lead: provide clear context, dispatch deliberately, validate outcomes, and remove blockers quickly.
3921
+ - On run completion: read \`neo runs <runId>\`, verify acceptance criteria, then decide next action (done, follow-up, redispatch, escalate).
3922
+ - On run failure: diagnose root cause before retrying (prompt quality, branch conflict, known issue, environment/tooling), then fix the cause.
3923
+ - Prevent silent stalls: monitor long-running jobs, detect blocked work early, and actively unblock.
3924
+ - Keep initiative boundaries strict: decisions for initiative A must not be influenced by unrelated state from B.
3925
+ - Your user-visible channel is \`neo log\` only; produce concise tool calls (not reasoning/explanations) and avoid wasted tokens.
3926
+ - You may inspect repositories available via \`neo repos\`, read-only to launch agents.
3927
+ - Task hygiene is non-negotiable: update task outcomes EVERY heartbeat. A task without a current outcome is a blind spot.`;
3600
3928
  var COMMANDS = `### Dispatching agents
3601
3929
  \`\`\`bash
3602
3930
  neo run <agent> --prompt "..." --repo <path> --branch <name> [--priority critical|high|medium|low] [--meta '<json>']
@@ -3607,7 +3935,7 @@ neo run <agent> --prompt "..." --repo <path> --branch <name> [--priority critica
3607
3935
  | \`--prompt\` | always | Task description for the agent |
3608
3936
  | \`--repo\` | always | Target repository path |
3609
3937
  | \`--branch\` | always | Branch name for the isolated clone |
3610
- | \`--priority\` | no | \`critical\`, \`high\`, \`medium\`, \`low\` |
3938
+ | \`--priority\` | optional | \`critical\`, \`high\`, \`medium\`, \`low\` |
3611
3939
  | \`--meta\` | **always** | JSON with \`"label"\` for identification + \`"ticketId"\`, \`"stage"\`, etc. |
3612
3940
 
3613
3941
  All agents require \`--branch\`. Each agent session runs in an isolated clone on that branch.
@@ -3635,6 +3963,27 @@ neo memory search "keyword"
3635
3963
  neo memory list --type fact
3636
3964
  \`\`\`
3637
3965
 
3966
+ ### Configuration
3967
+ \`\`\`bash
3968
+ neo config get <key> # read a value (dot notation)
3969
+ neo config set <key> <value> --global # update global config (~/.neo/config.yml)
3970
+ neo config list # show full merged config
3971
+ \`\`\`
3972
+
3973
+ Keys use dot notation (e.g., \`budget.dailyCapUsd\`, \`supervisor.dailyCapUsd\`, \`concurrency.maxSessions\`).
3974
+ Changes are hot-reloaded \u2014 the new values take effect at the next heartbeat.
3975
+
3976
+ Use cases: raise budget cap mid-run, adjust concurrency, change heartbeat timeout.
3977
+
3978
+ ### Decisions
3979
+ When you need human input on something that cannot be decided autonomously:
3980
+ \`\`\`bash
3981
+ neo decision create "<question>" --options "key1:label1,key2:label2:description" [--default <key>] [--expires-in 24h] [--context "..."]
3982
+ neo decision list # show pending decisions
3983
+ neo decision answer <id> <answer> # answer a decision (usually done by human via TUI)
3984
+ \`\`\`
3985
+ The decision ID is returned by \`create\`. If no answer arrives before expiration, the \`--default\` answer is applied automatically (or the decision expires without resolution).
3986
+
3638
3987
  ### Reporting
3639
3988
  \`\`\`bash
3640
3989
  neo log <type> "<message>" # visible in TUI only
@@ -3643,7 +3992,8 @@ var COMMANDS_COMPACT = `### Commands (reference)
3643
3992
  \`neo run <agent> --prompt "..." --repo <path> --branch <name> --meta '{"label":"T1-auth",...}'\`
3644
3993
  \`neo runs [--short | <runId>]\` \xB7 \`neo runs --short --status running\` \xB7 \`neo cost --short\`
3645
3994
  \`neo memory write|update|forget|search|list\` \xB7 \`neo log <type> "<msg>"\`
3646
- ALWAYS read run output on completion: \`neo runs <runId>\` \u2014 it contains the agent's structured result.`;
3995
+ \`neo config get <key>\` \xB7 \`neo config set <key> <value> --global\` \xB7 \`neo config list\`
3996
+ \`neo decision create "<question>" --options "..." [--default <key>]\` \xB7 \`neo decision list\``;
3647
3997
  var HEARTBEAT_RULES = `### Heartbeat lifecycle
3648
3998
 
3649
3999
  <decision-tree>
@@ -3653,7 +4003,8 @@ var HEARTBEAT_RULES = `### Heartbeat lifecycle
3653
4003
  4. EVENTS? \u2014 process run completions, messages, webhooks. Parse agent JSON output.
3654
4004
  5. FOLLOW-UPS? \u2014 check CI (\`gh pr checks\`), deferred dispatches.
3655
4005
  6. DISPATCH \u2014 route work to agents. Mark tasks \`in_progress\`, add ACTIVE to focus.
3656
- 7. YIELD \u2014 log your decisions and yield. Do not poll. Completions arrive at future heartbeats.
4006
+ 7. UPDATE TASKS \u2014 review ALL in_progress/blocked tasks. For each: confirm status matches reality (run still active? PR merged? blocked resolved?). Update outcomes immediately \u2014 do not defer to next heartbeat.
4007
+ 8. SERIALIZE & YIELD \u2014 rewrite focus (see <focus>), log your decisions, and yield. Do not poll.
3657
4008
  </decision-tree>
3658
4009
 
3659
4010
  <run-monitoring>
@@ -3664,43 +4015,21 @@ Runs are your agents in the field. You MUST actively track them:
3664
4015
  - **Active runs**: check \`neo runs --short --status running\` to verify your runs are still alive. If a run disappeared, investigate.
3665
4016
  </run-monitoring>
3666
4017
 
3667
- <orchestration>
3668
- When managing a multi-task initiative (architect decomposition, feature with milestones):
3669
-
3670
- **Branch strategy:**
3671
- - Use ONE branch per initiative: \`feat/YC-2670-kanban-improvements\` \u2014 all tasks in the initiative push commits to this same branch
3672
- - Each agent inherits the previous task's work without needing merges
3673
- - The first task creates the branch. Subsequent tasks reuse it with the same \`--branch\` flag
3674
- - Open the PR after the first task completes. Later tasks push additional commits to the same PR
3675
- - Tasks within an initiative MUST be dispatched sequentially (not in parallel) since they share a branch
3676
- - Independent initiatives CAN run in parallel on different branches
3677
-
3678
- **Before dispatching a task:**
3679
- 1. Run the task's \`--category\` command to retrieve context (architect plan, previous run output)
3680
- 2. Write a detailed \`--prompt\` with: task description, acceptance criteria, files to modify, and context from previous tasks in the initiative
3681
- 3. Include results from completed sibling tasks: what was built, which files were changed, which APIs were added
3682
- 4. Always pass the same \`--branch\` as previous tasks in the initiative
3683
-
3684
- **After a run completes:**
3685
- 1. \`neo runs <runId>\` \u2014 read the FULL output, not just status
3686
- 2. Extract: PR URL/number, files changed, test results, any issues
3687
- 3. Verify the output matches the task's acceptance criteria
3688
- 4. If the agent opened a PR: dispatch \`reviewer\` in parallel with CI (do not wait for CI)
3689
- 5. Update the task outcome and log the result with concrete details (PR#, branch, what was done)
3690
- 6. Update the initiative note with the completed milestone
3691
-
3692
- **Cross-task context:**
3693
- - Each task builds on previous ones. When dispatching T6, tell the agent what T1-T5 produced (commits, APIs added, files changed)
3694
- - Store key outputs as facts if they affect future tasks: "T5 added dateRange param to fetchAllFstRecords"
3695
- - Use notes for initiative-level plans: \`cat notes/plan-<initiative>.md\` \u2014 update as tasks complete
3696
- </orchestration>
3697
-
3698
- <rules>
3699
- - Work queue IS your plan. Never re-plan existing tasks.
3700
- - Maximize parallelism: dispatch independent tasks in the same heartbeat.
3701
- - After dispatch: update focus, yield immediately. Do NOT wait for results.
3702
- - Deferred work (CI pending): MUST check at next heartbeat.
3703
- </rules>`;
4018
+ <multi-task-initiatives>
4019
+ **Branch strategy:** one branch per initiative \u2014 all tasks push to the same branch sequentially (never in parallel). First task creates the branch; open PR after it completes. Later tasks add commits to the same PR. Independent initiatives CAN run in parallel on different branches.
4020
+
4021
+ **Dispatch quality:** write a detailed \`--prompt\` with acceptance criteria, files to modify, and context from completed sibling tasks (commits, APIs added, files changed). When dispatching task N, summarize what tasks 1..N-1 produced.
4022
+
4023
+ **Post-completion:** if agent opened a PR, dispatch \`reviewer\` in parallel with CI (do not wait). Update task outcome with concrete details (PR#, what was done) and update the initiative note.
4024
+
4025
+ **Task tracking discipline:**
4026
+ - On dispatch: \`neo memory update <id> --outcome in_progress\` immediately \u2014 never dispatch without updating the task.
4027
+ - On run completion: update to \`done\` with details OR \`blocked\` with reason. Do this in the SAME heartbeat you read the run output.
4028
+ - On run failure: update to \`blocked\` with root cause. Never leave a failed run's task as \`in_progress\`.
4029
+ - Every heartbeat: cross-check active tasks against \`neo runs --short\`. If a run finished but the task is still \`in_progress\`, something was missed \u2014 fix it now.
4030
+
4031
+ **Memory:** store key outputs as facts if they affect future tasks (e.g. "T5 added dateRange param to fetchAllFstRecords").
4032
+ </multi-task-initiatives>`;
3704
4033
  var REPORTING_RULES = `### Reporting
3705
4034
 
3706
4035
  \`neo log\` is your ONLY visible output. Use telegraphic format.
@@ -3711,19 +4040,14 @@ neo log action "<agent> <repo>:<branch> run:<runId> | <context>"
3711
4040
  neo log discovery "<what> in <where>"
3712
4041
  </log-format>
3713
4042
 
3714
- <examples>
3715
- <example type="good">
4043
+ <examples type="good">
3716
4044
  neo log decision "YC-42 \u2192 developer | clear spec, complexity 3"
3717
4045
  neo log action "developer standards:feat/YC-42-auth run:5900a64a | task T1"
3718
4046
  neo log discovery "CI requires node 20 in api-service"
3719
- </example>
3720
- <example type="bad">
3721
- neo log plan "Good! Now let me check the status and update things accordingly."
3722
- neo log decision "Heartbeat #309: Idle cycle - no action required. All 4 repositories stable."
3723
- neo log action "I've dispatched a developer agent to work on the authentication feature."
3724
- </example>
3725
4047
  </examples>`;
3726
- var MEMORY_RULES_CORE = `### Memory
4048
+ function buildMemoryRulesCore(supervisorDir) {
4049
+ const notesDir = `${supervisorDir}/notes`;
4050
+ return `### Memory
3727
4051
 
3728
4052
  <memory-types>
3729
4053
  | Type | Store when | TTL |
@@ -3736,7 +4060,7 @@ var MEMORY_RULES_CORE = `### Memory
3736
4060
  </memory-types>
3737
4061
 
3738
4062
  <memory-rules>
3739
- - Focus MUST use structured format: ACTIVE/PENDING/WAITING/PROCESSED lines only.
4063
+ - Focus is free-form working memory \u2014 rewrite at end of EVERY heartbeat (see <focus>).
3740
4064
  - NEVER store: file counts, line numbers, completed work details, data available via \`neo runs <id>\`.
3741
4065
  - After PR merge: forget related facts unless they are reusable architectural truths.
3742
4066
  - Pattern escalation: same failure 3+ times \u2192 write a \`procedure\`.
@@ -3744,53 +4068,43 @@ var MEMORY_RULES_CORE = `### Memory
3744
4068
  </memory-rules>
3745
4069
 
3746
4070
  <task-workflow>
3747
- Tasks are your work queue. The work queue section above shows them with markers (\`\u25CB\` pending, \`[ACTIVE]\` in_progress, \`[BLOCKED]\` blocked).
4071
+ Queue markers: \u25CB pending \xB7 [ACTIVE] in_progress \xB7 [BLOCKED] blocked.
4072
+ Create tasks for: incoming tickets, architect decompositions, sub-tickets, follow-ups, CI fixes.
4073
+ - \`--tags "initiative:<name>"\` \u2014 groups related tasks
4074
+ - \`--tags "depends:mem_<id>"\` \u2014 blocks until dependency is done
4075
+ - \`--category\` \u2014 retrieval command (MANDATORY). Examples: \`"neo runs <runId>"\` \xB7 \`"cat ${notesDir}/plan-feature.md"\` \xB7 \`"API-retrieve-a-page <notionPageId>"\`
4076
+ Lifecycle: create \u2192 in_progress (on dispatch) \u2192 done | blocked | abandoned
3748
4077
 
3749
- Create a task for any planned work: incoming tickets, architect decompositions, refiner sub-tickets, follow-up actions, CI fixes.
3750
- - \`--severity critical|high|medium|low\` \u2014 dispatch highest severity first
3751
- - \`--tags "initiative:<name>"\` \u2014 groups related tasks (shown as [initiative] headers in queue)
3752
- - \`--tags "depends:mem_<id>"\` \u2014 task cannot start until dependency is done
3753
- - \`--category\` \u2014 **MANDATORY** \u2014 the command to retrieve context for this task (shown as \`\u2192 <command>\` in queue)
4078
+ **Update frequency:** task outcomes MUST be updated in the same heartbeat as the triggering event. Never defer a task update to "next heartbeat" \u2014 by then you will have forgotten. Stale task states cause duplicate dispatches and wasted budget.
3754
4079
 
3755
- **Context retrieval rule**: every task and relevant memory MUST include a way for you to access its source context at a future heartbeat. You are stateless \u2014 without this, you lose the context.
3756
- - Agent output: \`--category "neo runs <runId>"\`
3757
- - Note/plan: \`--category "cat notes/plan-feature.md"\`
3758
- - Notion ticket: \`--category "API-retrieve-a-page <notionPageId>"\`
3759
- - Architect decomposition: \`--category "neo runs <architectRunId>"\` (contains milestones + tasks)
3760
-
3761
- Lifecycle: create \u2192 \`neo memory update <id> --outcome in_progress\` (on dispatch) \u2192 \`done\` (on success) / \`blocked\` (on failure, will retry) / \`abandoned\` (terminal, won't retry)
3762
-
3763
- Dispatch rule: pick the highest-severity task with no unmet dependencies. Dispatch independent tasks in parallel. Before dispatching, run the \`--category\` command to retrieve task context.
4080
+ **Mandatory cross-check:** before yielding, verify that:
4081
+ 1. Every dispatched run has a corresponding \`in_progress\` task
4082
+ 2. Every completed run has a corresponding \`done\` or \`blocked\` task
4083
+ 3. No task is \`in_progress\` without an active run (unless manually worked)
3764
4084
  </task-workflow>
3765
4085
 
3766
- <focus-format>
3767
- ACTIVE: <runId> <agent> "<task>" branch:<name>
3768
- PENDING: <taskId> "<description>" depends:<taskId>
3769
- WAITING: <what> since:HB<N>
3770
- PROCESSED: <runId> \u2192 <outcome> PR#<N>
3771
- </focus-format>
3772
-
3773
- <notes>
3774
-
3775
- You have a notes/ directory for rich markdown documents that persist across heartbeats.
4086
+ <focus>
4087
+ You are stateless between heartbeats. Focus is your scratchpad \u2014 the only thing future-you will read before acting.
3776
4088
 
3777
- When to use notes:
3778
- - Architect decompositions: save the full plan with milestones, tasks, acceptance criteria, dependency graph
3779
- - Initiative tracking: progress log with completed/pending tasks, PRs merged, blockers
3780
- - Complex debugging: accumulate findings across multiple heartbeats
3781
- - Review checklists: aggregate reviewer feedback across fix/review cycles
4089
+ Write it like a handoff note to yourself: what's happening, what you decided, what to do next, what to watch for. Free-form. No format imposed. The only rule: if you don't write it down, you lose it.
3782
4090
 
3783
- How to use:
3784
- - Write: \`cat > notes/plan-YC-2670-kanban.md << 'EOF' ... EOF\` \u2014 include milestones checklist, acceptance criteria, file paths
3785
- - Read: \`cat notes/plan-YC-2670-kanban.md\` \u2014 retrieve full context at any heartbeat
3786
- - Link to tasks: \`neo memory write --type task --category "cat notes/plan-YC-2670-kanban.md" "M3: UI"\`
3787
- - Update: check off completed milestones, add PR numbers, note blockers after each task completes
3788
- - Cleanup: \`rm notes/plan-*.md\` when the initiative is done
4091
+ Rewrite focus at the END of every heartbeat. Never leave it empty after a heartbeat with activity.
4092
+ </focus>
3789
4093
 
3790
- Use notes for every initiative with 3+ tasks. They are your project management tool.
4094
+ <notes>
4095
+ Notes directory: \`${notesDir}/\`
4096
+ Use notes for any initiative with 3+ tasks (persists across heartbeats).
4097
+ - Write: \`cat > ${notesDir}/plan-<initiative>.md << 'EOF' ... EOF\`
4098
+ - Link to tasks: \`--category "cat ${notesDir}/plan-<initiative>.md"\`
4099
+ - Update after each task: check off milestones, add PR numbers, note blockers
4100
+ - Delete when initiative is done
4101
+ Use cases: architect decompositions, initiative tracking, debugging across heartbeats, review checklists.
3791
4102
  </notes>`;
3792
- var MEMORY_RULES_EXAMPLES = `<memory-examples>
3793
- neo memory write --type focus --expires 2h "ACTIVE: 5900a64a developer 'T1' branch:feat/x (cat notes/plan-YC-2670-kanban.md)"
4103
+ }
4104
+ function buildMemoryRulesExamples(supervisorDir) {
4105
+ const notesDir = `${supervisorDir}/notes`;
4106
+ return `<memory-examples>
4107
+ neo memory write --type focus --expires 2h "ACTIVE: 5900a64a developer 'T1' branch:feat/x (cat ${notesDir}/plan-YC-2670-kanban.md)"
3794
4108
  neo memory write --type fact --scope /repo "main branch uses protected merges \u2014 agents must create PRs, never push directly"
3795
4109
  neo memory write --type fact --scope /repo "pnpm build must pass before push \u2014 CI does not rebuild, run 2g589f34a5a failed without it"
3796
4110
  neo memory write --type procedure --scope /repo "After architect run: parse milestones from JSON output, create one task per milestone with --tags initiative:<name>"
@@ -3800,42 +4114,156 @@ neo memory write --type task --scope /repo --severity high --category "neo runs
3800
4114
  neo memory update <id> --outcome in_progress|done|blocked|abandoned
3801
4115
  neo memory forget <id>
3802
4116
  </memory-examples>`;
4117
+ }
4118
+ function buildRoleSection(heartbeatCount, label) {
4119
+ const suffix = label ? ` (${label})` : "";
4120
+ return `<role>
4121
+ ${ROLE}
4122
+ Heartbeat #${heartbeatCount}${suffix}
4123
+ </role>`;
4124
+ }
3803
4125
  function getCommandsSection(heartbeatCount) {
3804
4126
  return heartbeatCount <= 3 ? COMMANDS : COMMANDS_COMPACT;
3805
4127
  }
3806
- function buildContextSections(opts) {
3807
- const parts = [];
3808
- if (opts.repos.length > 0) {
3809
- const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
3810
- parts.push(`Repositories:
3811
- ${repoList}`);
3812
- }
3813
- if (opts.mcpServerNames.length > 0) {
3814
- const mcpList = opts.mcpServerNames.map((n) => `- ${n}`).join("\n");
3815
- parts.push(`Integrations (MCP):
3816
- ${mcpList}`);
3817
- }
3818
- parts.push(
3819
- `Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`
3820
- );
3821
- return parts;
4128
+ function buildReferenceSection(heartbeatCount) {
4129
+ return `<reference>
4130
+ ${getCommandsSection(heartbeatCount)}
4131
+ </reference>`;
3822
4132
  }
3823
- function buildMemorySection(memories, supervisorDir) {
4133
+ function buildFocusSection(memories) {
3824
4134
  const focusEntries = memories.filter((m) => m.type === "focus");
3825
- const factEntries = memories.filter((m) => m.type === "fact");
3826
- const procedureEntries = memories.filter((m) => m.type === "procedure");
3827
- const feedbackEntries = memories.filter((m) => m.type === "feedback");
3828
- const parts = [];
3829
4135
  if (focusEntries.length > 0) {
3830
4136
  const lines = focusEntries.map((m) => `- ${m.content}`).join("\n");
3831
- parts.push(`<focus>
4137
+ return `<focus>
3832
4138
  ${lines}
3833
- </focus>`);
3834
- } else {
3835
- parts.push(
3836
- "<focus>\n(empty \u2014 use neo memory write --type focus to set working context)\n</focus>"
3837
- );
4139
+ </focus>`;
3838
4140
  }
4141
+ return "<focus>\n(empty \u2014 use neo memory write --type focus to set working context)\n</focus>";
4142
+ }
4143
+ function buildPendingDecisionsSection(decisions) {
4144
+ if (!decisions || decisions.length === 0) {
4145
+ return "";
4146
+ }
4147
+ const lines = [];
4148
+ for (const d of decisions) {
4149
+ const expiry = d.expiresAt ? ` (expires: ${d.expiresAt})` : "";
4150
+ const defaultHint = d.defaultAnswer ? ` [default: ${d.defaultAnswer}]` : "";
4151
+ lines.push(`- **${d.id}**: ${d.question}${expiry}${defaultHint}`);
4152
+ if (d.options && d.options.length > 0) {
4153
+ for (const opt of d.options) {
4154
+ const desc = opt.description ? ` \u2014 ${opt.description}` : "";
4155
+ lines.push(` \u2022 \`${opt.key}\`: ${opt.label}${desc}`);
4156
+ }
4157
+ }
4158
+ if (d.context) {
4159
+ lines.push(` Context: ${d.context}`);
4160
+ }
4161
+ }
4162
+ return `Pending decisions (${decisions.length}):
4163
+ ${lines.join("\n")}
4164
+
4165
+ To answer a decision, emit a \`decision:answer\` event:
4166
+ \`\`\`bash
4167
+ neo event emit decision:answer --data '{"id":"<decision_id>","answer":"<option_key>"}'
4168
+ \`\`\``;
4169
+ }
4170
+ function buildAnsweredDecisionsSection(decisions) {
4171
+ if (!decisions || decisions.length === 0) {
4172
+ return "";
4173
+ }
4174
+ const lines = decisions.map((d) => {
4175
+ const answeredBy = d.source ? ` (by ${d.source})` : "";
4176
+ return `- ${d.id}: "${d.question}" \u2192 **${d.answer}**${answeredBy}`;
4177
+ });
4178
+ return `Recent decisions (${decisions.length}):
4179
+ ${lines.join("\n")}`;
4180
+ }
4181
+ function buildFullContext(opts) {
4182
+ const parts = [];
4183
+ parts.push(buildFocusSection(opts.memories));
4184
+ const workQueue = buildWorkQueueSection(opts.memories);
4185
+ if (workQueue) {
4186
+ parts.push(workQueue);
4187
+ }
4188
+ if (opts.activeRuns.length > 0) {
4189
+ parts.push(`Active runs:
4190
+ ${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
4191
+ }
4192
+ const recentActions = buildRecentActionsSection(opts.recentActions);
4193
+ if (recentActions) {
4194
+ parts.push(recentActions);
4195
+ }
4196
+ const pendingDecisions = buildPendingDecisionsSection(opts.pendingDecisions);
4197
+ if (pendingDecisions) {
4198
+ parts.push(pendingDecisions);
4199
+ }
4200
+ const answeredDecisions = buildAnsweredDecisionsSection(opts.answeredDecisions);
4201
+ if (answeredDecisions) {
4202
+ parts.push(answeredDecisions);
4203
+ }
4204
+ parts.push(buildKnowledgeSection(opts.memories));
4205
+ parts.push(...buildEnvironmentSections(opts));
4206
+ parts.push(`Events:
4207
+ ${buildEventsSection(opts.grouped)}`);
4208
+ return `<context>
4209
+ ${parts.join("\n\n")}
4210
+ </context>`;
4211
+ }
4212
+ function buildCompactionContext(opts) {
4213
+ const parts = [];
4214
+ parts.push(buildFocusSection(opts.memories));
4215
+ parts.push(buildKnowledgeSection(opts.memories));
4216
+ const workQueue = buildWorkQueueSection(opts.memories);
4217
+ if (workQueue) {
4218
+ parts.push(workQueue);
4219
+ }
4220
+ parts.push(...buildEnvironmentSections(opts));
4221
+ return `<context>
4222
+ ${parts.join("\n\n")}
4223
+ </context>`;
4224
+ }
4225
+ function buildBaseInstructions(opts, options) {
4226
+ const parts = [];
4227
+ parts.push(OPERATING_PRINCIPLES);
4228
+ parts.push(HEARTBEAT_RULES);
4229
+ parts.push(REPORTING_RULES);
4230
+ parts.push(buildMemoryRulesCore(opts.supervisorDir));
4231
+ if (options.includeExamples) {
4232
+ parts.push(buildMemoryRulesExamples(opts.supervisorDir));
4233
+ }
4234
+ if (opts.customInstructions) {
4235
+ parts.push(`### Custom instructions
4236
+ ${opts.customInstructions}`);
4237
+ }
4238
+ return parts;
4239
+ }
4240
+ function wrapInstructions(parts) {
4241
+ return `<instructions>
4242
+ ${parts.join("\n\n")}
4243
+ </instructions>`;
4244
+ }
4245
+ function buildEnvironmentSections(opts) {
4246
+ const parts = [];
4247
+ if (opts.repos.length > 0) {
4248
+ const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
4249
+ parts.push(`Repositories:
4250
+ ${repoList}`);
4251
+ }
4252
+ if (opts.mcpServerNames.length > 0) {
4253
+ const mcpList = opts.mcpServerNames.map((n) => `- ${n}`).join("\n");
4254
+ parts.push(`Integrations (MCP):
4255
+ ${mcpList}`);
4256
+ }
4257
+ parts.push(
4258
+ `Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`
4259
+ );
4260
+ return parts;
4261
+ }
4262
+ function buildKnowledgeSection(memories) {
4263
+ const factEntries = memories.filter((m) => m.type === "fact");
4264
+ const procedureEntries = memories.filter((m) => m.type === "procedure");
4265
+ const feedbackEntries = memories.filter((m) => m.type === "feedback");
4266
+ const parts = [];
3839
4267
  if (factEntries.length > 0) {
3840
4268
  const byScope = /* @__PURE__ */ new Map();
3841
4269
  for (const m of factEntries) {
@@ -3871,12 +4299,6 @@ ${lines}`);
3871
4299
  parts.push(`Recurring review issues:
3872
4300
  ${lines}`);
3873
4301
  }
3874
- parts.push(`For detailed plans and checklists, use notes:
3875
- \`\`\`bash
3876
- cat > ${supervisorDir}/notes/plan-feature.md << 'EOF'
3877
- <your detailed plan here>
3878
- EOF
3879
- \`\`\``);
3880
4302
  return parts.join("\n\n");
3881
4303
  }
3882
4304
  var DONE_OUTCOMES = /* @__PURE__ */ new Set(["done", "abandoned"]);
@@ -3925,18 +4347,72 @@ function groupTasksByInitiative(tasks) {
3925
4347
  }
3926
4348
  return groups;
3927
4349
  }
4350
+ var SEVERITY_ORDER = {
4351
+ critical: 0,
4352
+ high: 1,
4353
+ medium: 2,
4354
+ low: 3
4355
+ };
4356
+ function bySeverity(a, b) {
4357
+ const aOrder = SEVERITY_ORDER[a.severity ?? "medium"] ?? 2;
4358
+ const bOrder = SEVERITY_ORDER[b.severity ?? "medium"] ?? 2;
4359
+ return aOrder - bOrder;
4360
+ }
4361
+ function partitionTasks(tasks) {
4362
+ const active = [];
4363
+ const blocked = [];
4364
+ const pending = [];
4365
+ for (const t of tasks) {
4366
+ if (t.outcome === "in_progress") active.push(t);
4367
+ else if (t.outcome === "blocked") blocked.push(t);
4368
+ else pending.push(t);
4369
+ }
4370
+ return { active, blocked, pending };
4371
+ }
4372
+ function renderInitiativeSummary(group) {
4373
+ const { active, pending } = partitionTasks(group.tasks);
4374
+ const nextEligible = [...pending].sort(bySeverity)[0];
4375
+ const cat = nextEligible?.category ? ` -> ${nextEligible.category}` : "";
4376
+ const nextLabel = nextEligible ? ` (next: ${nextEligible.content.slice(0, 30)}${nextEligible.content.length > 30 ? "..." : ""} [${nextEligible.severity ?? "medium"}])` : "";
4377
+ return `[${group.initiative}] ${active.length} active, ${pending.length} pending${nextLabel}${cat}`;
4378
+ }
4379
+ function renderCompactInitiative(group, lines, rendered) {
4380
+ lines.push(` ${renderInitiativeSummary(group)}`);
4381
+ const { active, blocked, pending } = partitionTasks(group.tasks);
4382
+ const nextEligible = [...pending].sort(bySeverity)[0];
4383
+ for (const task of [...active, ...blocked]) {
4384
+ if (rendered >= MAX_TASKS) break;
4385
+ lines.push(` ${formatTaskLine(task)}`);
4386
+ rendered++;
4387
+ }
4388
+ if (nextEligible && active.length === 0 && blocked.length === 0 && rendered < MAX_TASKS) {
4389
+ lines.push(` ${formatTaskLine(nextEligible)}`);
4390
+ rendered++;
4391
+ }
4392
+ return rendered;
4393
+ }
4394
+ function renderFlatGroup(group, showHeader, lines, rendered) {
4395
+ if (showHeader && group.initiative) {
4396
+ lines.push(` [${group.initiative}]`);
4397
+ }
4398
+ for (const task of group.tasks) {
4399
+ if (rendered >= MAX_TASKS) break;
4400
+ lines.push(` ${formatTaskLine(task)}`);
4401
+ rendered++;
4402
+ }
4403
+ return rendered;
4404
+ }
3928
4405
  function renderTaskGroups(groups) {
3929
4406
  const lines = [];
3930
4407
  let rendered = 0;
3931
4408
  for (const group of groups) {
3932
4409
  if (rendered >= MAX_TASKS) break;
3933
- if (group.initiative && groups.length > 1) {
3934
- lines.push(` [${group.initiative}]`);
3935
- }
3936
- for (const task of group.tasks) {
3937
- if (rendered >= MAX_TASKS) break;
3938
- lines.push(` ${formatTaskLine(task)}`);
3939
- rendered++;
4410
+ const useCompactMode = group.initiative && group.tasks.length >= 3;
4411
+ if (useCompactMode) {
4412
+ rendered = renderCompactInitiative(group, lines, rendered);
4413
+ } else {
4414
+ const showHeader = group.initiative !== null && groups.length > 1;
4415
+ rendered = renderFlatGroup(group, showHeader, lines, rendered);
3940
4416
  }
3941
4417
  }
3942
4418
  return lines;
@@ -4017,112 +4493,100 @@ ${JSON.stringify(event.data.payload ?? {}, null, 2)}
4017
4493
  return `Internal event: ${event.eventKind}`;
4018
4494
  }
4019
4495
  }
4496
+ function countEvents(grouped) {
4497
+ return grouped.messages.length + grouped.webhooks.length + grouped.runCompletions.length;
4498
+ }
4020
4499
  function isIdleHeartbeat(opts) {
4021
- const { messages, webhooks, runCompletions } = opts.grouped;
4022
- const totalEvents = messages.length + webhooks.length + runCompletions.length;
4023
4500
  const hasWork = buildWorkQueueSection(opts.memories) !== "";
4024
- return totalEvents === 0 && opts.activeRuns.length === 0 && !hasWork;
4501
+ return countEvents(opts.grouped) === 0 && opts.activeRuns.length === 0 && !hasWork;
4025
4502
  }
4026
4503
  function buildIdlePrompt(opts) {
4027
- return `<role>
4028
- ${ROLE}
4029
- Heartbeat #${opts.heartbeatCount}
4030
- </role>
4504
+ const budgetLine = `Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`;
4505
+ const hasRepos = opts.repos.length > 0;
4506
+ const hasBudget = opts.budgetStatus.remainingPct > 10;
4507
+ const hasPendingDecisions = (opts.pendingDecisions?.length ?? 0) > 0;
4508
+ if (!hasRepos || !hasBudget) {
4509
+ return `${buildRoleSection(opts.heartbeatCount)}
4031
4510
 
4032
4511
  <context>
4033
4512
  No events. No active runs. No pending tasks.
4034
- Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)
4513
+ ${budgetLine}
4035
4514
  </context>
4036
4515
 
4037
4516
  <directive>
4038
4517
  Nothing to do. Run \`neo log discovery "idle"\` and yield. Do not produce any other output.
4039
4518
  </directive>`;
4040
- }
4041
- function buildStandardPrompt(opts) {
4042
- const sections = [];
4043
- sections.push(`<role>
4044
- ${ROLE}
4045
- Heartbeat #${opts.heartbeatCount}
4046
- </role>`);
4047
- const contextParts = [];
4048
- const workQueue = buildWorkQueueSection(opts.memories);
4049
- if (workQueue) {
4050
- contextParts.push(workQueue);
4051
4519
  }
4052
- if (opts.activeRuns.length > 0) {
4053
- contextParts.push(`Active runs:
4054
- ${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
4055
- }
4056
- contextParts.push(...buildContextSections(opts));
4057
- contextParts.push(buildMemorySection(opts.memories, opts.supervisorDir));
4058
- const recentActions = buildRecentActionsSection(opts.recentActions);
4059
- if (recentActions) {
4060
- contextParts.push(recentActions);
4520
+ const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
4521
+ if (hasPendingDecisions) {
4522
+ const pendingSection = buildPendingDecisionsSection(opts.pendingDecisions);
4523
+ return `${buildRoleSection(opts.heartbeatCount)}
4524
+
4525
+ <context>
4526
+ No events. No active runs. No pending tasks.
4527
+ ${budgetLine}
4528
+
4529
+ ${pendingSection}
4530
+
4531
+ Repositories:
4532
+ ${repoList}
4533
+ </context>
4534
+
4535
+ <directive>
4536
+ Idle \u2014 but there are pending decisions awaiting user response.
4537
+ Run \`neo log discovery "idle \u2014 waiting on ${String(opts.pendingDecisions?.length ?? 0)} pending decision(s)"\` and yield.
4538
+ </directive>`;
4061
4539
  }
4062
- contextParts.push(`Events:
4063
- ${buildEventsSection(opts.grouped)}`);
4064
- sections.push(`<context>
4065
- ${contextParts.join("\n\n")}
4066
- </context>`);
4067
- sections.push(`<reference>
4540
+ return `${buildRoleSection(opts.heartbeatCount)}
4541
+
4542
+ <context>
4543
+ No events. No active runs. No pending tasks.
4544
+ ${budgetLine}
4545
+
4546
+ Repositories:
4547
+ ${repoList}
4548
+ </context>
4549
+
4550
+ <reference>
4068
4551
  ${getCommandsSection(opts.heartbeatCount)}
4069
- </reference>`);
4070
- const instructionParts = [];
4071
- instructionParts.push(HEARTBEAT_RULES);
4072
- instructionParts.push(REPORTING_RULES);
4073
- instructionParts.push(MEMORY_RULES_CORE);
4074
- if (opts.customInstructions) {
4075
- instructionParts.push(`### Custom instructions
4076
- ${opts.customInstructions}`);
4077
- }
4078
- const { messages, webhooks, runCompletions } = opts.grouped;
4079
- const hasEvents = messages.length + webhooks.length + runCompletions.length > 0;
4552
+ </reference>
4553
+
4554
+ <directive>
4555
+ Idle \u2014 no work in progress. Use this downtime to dispatch a \`scout\` agent on one of your repositories.
4556
+
4557
+ The scout explores the codebase and surfaces bugs, improvements, security issues, and tech debt. It creates decisions (via \`neo decision create\`) for each critical or high-impact finding, so the user can choose what to act on.
4558
+
4559
+ **Rules:**
4560
+ - Pick the repo that was least recently scouted (check your memory for previous scout runs).
4561
+ - Only ONE scout at a time \u2014 never dispatch multiple scouts in parallel.
4562
+ - Use \`--branch main\` (or the repo's default branch) \u2014 scouts are read-only.
4563
+ - Log your decision before dispatching.
4564
+
4565
+ **Example:**
4566
+ \`\`\`bash
4567
+ neo log decision "Idle \u2014 dispatching scout on <repo>"
4568
+ neo run scout --prompt "Explore this repository. Surface bugs, improvements, security issues, and tech debt. Create decisions for critical and high-impact findings." \\
4569
+ --repo <path> \\
4570
+ --branch <default-branch> \\
4571
+ --meta '{"stage":"scout","label":"scout-<repo-name>"}'
4572
+ \`\`\`
4573
+ </directive>`;
4574
+ }
4575
+ function buildStandardPrompt(opts) {
4576
+ const instructionParts = buildBaseInstructions(opts, { includeExamples: false });
4577
+ const hasEvents = countEvents(opts.grouped) > 0;
4080
4578
  instructionParts.push(
4081
4579
  hasEvents ? "Process events, dispatch eligible work, yield. Each heartbeat costs ~$0.10 \u2014 be efficient." : "No events. If pending work exists, dispatch it. Otherwise yield immediately."
4082
4580
  );
4083
- sections.push(`<instructions>
4084
- ${instructionParts.join("\n\n")}
4085
- </instructions>`);
4086
- return sections.join("\n\n");
4581
+ return [
4582
+ buildRoleSection(opts.heartbeatCount),
4583
+ buildFullContext(opts),
4584
+ buildReferenceSection(opts.heartbeatCount),
4585
+ wrapInstructions(instructionParts)
4586
+ ].join("\n\n");
4087
4587
  }
4088
4588
  function buildConsolidationPrompt(opts) {
4089
- const sections = [];
4090
- sections.push(`<role>
4091
- ${ROLE}
4092
- Heartbeat #${opts.heartbeatCount} (CONSOLIDATION)
4093
- </role>`);
4094
- const contextParts = [];
4095
- const workQueueConsolidation = buildWorkQueueSection(opts.memories);
4096
- if (workQueueConsolidation) {
4097
- contextParts.push(workQueueConsolidation);
4098
- }
4099
- if (opts.activeRuns.length > 0) {
4100
- contextParts.push(`Active runs:
4101
- ${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
4102
- }
4103
- contextParts.push(...buildContextSections(opts));
4104
- contextParts.push(buildMemorySection(opts.memories, opts.supervisorDir));
4105
- const recentActions = buildRecentActionsSection(opts.recentActions);
4106
- if (recentActions) {
4107
- contextParts.push(recentActions);
4108
- }
4109
- contextParts.push(`Events:
4110
- ${buildEventsSection(opts.grouped)}`);
4111
- sections.push(`<context>
4112
- ${contextParts.join("\n\n")}
4113
- </context>`);
4114
- sections.push(`<reference>
4115
- ${getCommandsSection(opts.heartbeatCount)}
4116
- </reference>`);
4117
- const instructionParts = [];
4118
- instructionParts.push(HEARTBEAT_RULES);
4119
- instructionParts.push(REPORTING_RULES);
4120
- instructionParts.push(MEMORY_RULES_CORE);
4121
- instructionParts.push(MEMORY_RULES_EXAMPLES);
4122
- if (opts.customInstructions) {
4123
- instructionParts.push(`### Custom instructions
4124
- ${opts.customInstructions}`);
4125
- }
4589
+ const instructionParts = buildBaseInstructions(opts, { includeExamples: true });
4126
4590
  instructionParts.push(
4127
4591
  `### Consolidation
4128
4592
  This is a CONSOLIDATION heartbeat.
@@ -4137,39 +4601,16 @@ If there IS active work, your job:
4137
4601
  4. **Prune completed work** \u2014 if a PR is merged or an initiative is done, forget related facts that are no longer actionable. Keep only reusable architectural truths.
4138
4602
  5. **Prune done tasks** \u2014 forget tasks with outcome \`done\` or \`abandoned\` older than 7 days.`
4139
4603
  );
4140
- sections.push(`<instructions>
4141
- ${instructionParts.join("\n\n")}
4142
- </instructions>`);
4143
- return sections.join("\n\n");
4604
+ return [
4605
+ buildRoleSection(opts.heartbeatCount, "CONSOLIDATION"),
4606
+ buildFullContext(opts),
4607
+ buildReferenceSection(opts.heartbeatCount),
4608
+ wrapInstructions(instructionParts)
4609
+ ].join("\n\n");
4144
4610
  }
4145
4611
  function buildCompactionPrompt(opts) {
4146
- const sections = [];
4147
- sections.push(`<role>
4148
- ${ROLE}
4149
- Heartbeat #${opts.heartbeatCount} (COMPACTION)
4150
- </role>`);
4151
- const contextParts = [];
4152
- contextParts.push(...buildContextSections(opts));
4153
- contextParts.push(buildMemorySection(opts.memories, opts.supervisorDir));
4154
- const workQueueCompaction = buildWorkQueueSection(opts.memories);
4155
- if (workQueueCompaction) {
4156
- contextParts.push(workQueueCompaction);
4157
- }
4158
- sections.push(`<context>
4159
- ${contextParts.join("\n\n")}
4160
- </context>`);
4161
- sections.push(`<reference>
4162
- ${getCommandsSection(opts.heartbeatCount)}
4163
- </reference>`);
4164
- const instructionParts = [];
4165
- instructionParts.push(HEARTBEAT_RULES);
4166
- instructionParts.push(REPORTING_RULES);
4167
- instructionParts.push(MEMORY_RULES_CORE);
4168
- instructionParts.push(MEMORY_RULES_EXAMPLES);
4169
- if (opts.customInstructions) {
4170
- instructionParts.push(`### Custom instructions
4171
- ${opts.customInstructions}`);
4172
- }
4612
+ const notesDir = `${opts.supervisorDir}/notes`;
4613
+ const instructionParts = buildBaseInstructions(opts, { includeExamples: true });
4173
4614
  instructionParts.push(`### Compaction
4174
4615
  This is a COMPACTION heartbeat. Deep-clean your ENTIRE memory.
4175
4616
 
@@ -4179,7 +4620,7 @@ This is a COMPACTION heartbeat. Deep-clean your ENTIRE memory.
4179
4620
  4. **Merge duplicates** \u2014 combine similar facts within the same scope into one.
4180
4621
  5. **Clean up focus** \u2014 forget resolved items, rewrite remaining in structured format.
4181
4622
  6. **Prune done tasks** \u2014 forget tasks with outcome \`done\` or \`abandoned\` older than 7 days.
4182
- 7. **Delete completed notes** from notes/ directory.
4623
+ 7. **Delete completed notes** from \`${notesDir}/\` directory.
4183
4624
  8. **Stay under 15 facts per scope** \u2014 prioritize facts that affect dispatch decisions.
4184
4625
 
4185
4626
  Flag contradictions: if two facts contradict, keep the newer one.
@@ -4188,12 +4629,66 @@ Flag contradictions: if two facts contradict, keep the newer one.
4188
4629
  neo memory list --type fact
4189
4630
  neo memory forget <stale-id>
4190
4631
  \`\`\``);
4191
- sections.push(`<instructions>
4192
- ${instructionParts.join("\n\n")}
4193
- </instructions>`);
4194
- return sections.join("\n\n");
4632
+ return [
4633
+ buildRoleSection(opts.heartbeatCount, "COMPACTION"),
4634
+ buildCompactionContext(opts),
4635
+ buildReferenceSection(opts.heartbeatCount),
4636
+ wrapInstructions(instructionParts)
4637
+ ].join("\n\n");
4195
4638
  }
4196
4639
 
4640
+ // src/supervisor/webhookEvents.ts
4641
+ import { z as z6 } from "zod";
4642
+ var supervisorStartedEventSchema = z6.object({
4643
+ type: z6.literal("supervisor_started"),
4644
+ supervisorId: z6.string(),
4645
+ startedAt: z6.string().datetime()
4646
+ });
4647
+ var heartbeatEventSchema = z6.object({
4648
+ type: z6.literal("heartbeat"),
4649
+ supervisorId: z6.string(),
4650
+ heartbeatNumber: z6.number().int().min(0),
4651
+ timestamp: z6.string().datetime(),
4652
+ runsActive: z6.number().int().min(0),
4653
+ budget: z6.object({
4654
+ todayUsd: z6.number().min(0),
4655
+ limitUsd: z6.number().min(0)
4656
+ })
4657
+ });
4658
+ var runDispatchedEventSchema = z6.object({
4659
+ type: z6.literal("run_dispatched"),
4660
+ supervisorId: z6.string(),
4661
+ runId: z6.string(),
4662
+ agent: z6.string(),
4663
+ repo: z6.string(),
4664
+ branch: z6.string(),
4665
+ prompt: z6.string().max(500)
4666
+ // truncated
4667
+ });
4668
+ var runCompletedEventSchema = z6.object({
4669
+ type: z6.literal("run_completed"),
4670
+ supervisorId: z6.string(),
4671
+ runId: z6.string(),
4672
+ status: z6.enum(["completed", "failed", "cancelled"]),
4673
+ output: z6.string().max(1e3).optional(),
4674
+ // truncated
4675
+ costUsd: z6.number().min(0),
4676
+ durationMs: z6.number().int().min(0)
4677
+ });
4678
+ var supervisorStoppedEventSchema = z6.object({
4679
+ type: z6.literal("supervisor_stopped"),
4680
+ supervisorId: z6.string(),
4681
+ stoppedAt: z6.string().datetime(),
4682
+ reason: z6.enum(["shutdown", "budget_exceeded", "error", "manual"])
4683
+ });
4684
+ var supervisorWebhookEventSchema = z6.discriminatedUnion("type", [
4685
+ supervisorStartedEventSchema,
4686
+ heartbeatEventSchema,
4687
+ runDispatchedEventSchema,
4688
+ runCompletedEventSchema,
4689
+ supervisorStoppedEventSchema
4690
+ ]);
4691
+
4197
4692
  // src/supervisor/heartbeat.ts
4198
4693
  var DEFAULT_IDLE_SKIP_MAX = 20;
4199
4694
  var DEFAULT_ACTIVE_WORK_SKIP_MAX = 3;
@@ -4208,6 +4703,23 @@ function shouldCompact(heartbeatCount, lastCompactionHeartbeat, compactionInterv
4208
4703
  const since = heartbeatCount - lastCompactionHeartbeat;
4209
4704
  return since >= compactionInterval;
4210
4705
  }
4706
+ var STALE_GRACE_PERIOD_MS = 3e4;
4707
+ function isRunActive(run, isAlive = isProcessAlive, now = Date.now()) {
4708
+ if (run.status !== "running" && run.status !== "paused") {
4709
+ return false;
4710
+ }
4711
+ if (run.status === "paused") {
4712
+ return true;
4713
+ }
4714
+ if (run.pid && isAlive(run.pid)) {
4715
+ return true;
4716
+ }
4717
+ if (run.pid) {
4718
+ return false;
4719
+ }
4720
+ const ageMs = now - new Date(run.createdAt).getTime();
4721
+ return ageMs < STALE_GRACE_PERIOD_MS;
4722
+ }
4211
4723
  var HeartbeatLoop = class {
4212
4724
  stopping = false;
4213
4725
  consecutiveFailures = 0;
@@ -4218,10 +4730,18 @@ var HeartbeatLoop = class {
4218
4730
  sessionId;
4219
4731
  eventQueue;
4220
4732
  activityLog;
4733
+ _eventsPath;
4221
4734
  customInstructions;
4222
4735
  defaultInstructionsPath;
4223
4736
  memoryStore = null;
4224
4737
  memoryDbPath;
4738
+ onWebhookEvent;
4739
+ decisionStore = null;
4740
+ /** ConfigWatcher for hot-reload support */
4741
+ configWatcher = null;
4742
+ configStore = null;
4743
+ repoPath;
4744
+ configWatcherDebounceMs;
4225
4745
  constructor(options) {
4226
4746
  this.config = options.config;
4227
4747
  this.supervisorDir = options.supervisorDir;
@@ -4229,8 +4749,16 @@ var HeartbeatLoop = class {
4229
4749
  this.sessionId = options.sessionId;
4230
4750
  this.eventQueue = options.eventQueue;
4231
4751
  this.activityLog = options.activityLog;
4752
+ this._eventsPath = options.eventsPath;
4232
4753
  this.defaultInstructionsPath = options.defaultInstructionsPath;
4233
4754
  this.memoryDbPath = options.memoryDbPath;
4755
+ this.onWebhookEvent = options.onWebhookEvent;
4756
+ this.repoPath = options.repoPath;
4757
+ this.configWatcherDebounceMs = options.configWatcherDebounceMs;
4758
+ }
4759
+ /** Path to the inbox/events directory for markProcessed() calls */
4760
+ get eventsPath() {
4761
+ return this._eventsPath;
4234
4762
  }
4235
4763
  getMemoryStore() {
4236
4764
  if (!this.memoryStore && this.memoryDbPath) {
@@ -4241,9 +4769,17 @@ var HeartbeatLoop = class {
4241
4769
  }
4242
4770
  return this.memoryStore;
4243
4771
  }
4772
+ getDecisionStore() {
4773
+ if (!this.decisionStore) {
4774
+ this.decisionStore = new DecisionStore(path14.join(this.supervisorDir, "decisions.jsonl"));
4775
+ }
4776
+ return this.decisionStore;
4777
+ }
4244
4778
  async start() {
4245
4779
  this.customInstructions = await this.loadInstructions();
4780
+ await this.initConfigWatcher();
4246
4781
  await this.activityLog.log("heartbeat", "Supervisor heartbeat loop started");
4782
+ await this.emitSupervisorStarted();
4247
4783
  while (!this.stopping) {
4248
4784
  try {
4249
4785
  await this.runHeartbeat();
@@ -4269,23 +4805,60 @@ var HeartbeatLoop = class {
4269
4805
  if (this.stopping) break;
4270
4806
  await this.eventQueue.waitForEvent(this.config.supervisor.eventTimeoutMs);
4271
4807
  }
4808
+ await this.emitSupervisorStopped("shutdown");
4272
4809
  await this.activityLog.log("heartbeat", "Supervisor heartbeat loop stopped");
4273
4810
  }
4274
4811
  stop() {
4275
4812
  this.stopping = true;
4276
4813
  this.activeAbort?.abort(new Error("Supervisor shutting down"));
4277
4814
  this.eventQueue.interrupt();
4815
+ if (this.configWatcher) {
4816
+ this.configWatcher.stop();
4817
+ this.configWatcher = null;
4818
+ }
4819
+ }
4820
+ /**
4821
+ * Initialize and start the ConfigWatcher for hot-reload support.
4822
+ * Subscribes to config file changes and logs reload events.
4823
+ */
4824
+ async initConfigWatcher() {
4825
+ this.configStore = new ConfigStore(this.repoPath);
4826
+ await this.configStore.load();
4827
+ const watcherOptions = this.configWatcherDebounceMs !== void 0 ? { debounceMs: this.configWatcherDebounceMs } : void 0;
4828
+ this.configWatcher = new ConfigWatcher(this.configStore, watcherOptions);
4829
+ this.configWatcher.on("change", () => {
4830
+ this.handleConfigChange();
4831
+ });
4832
+ this.configWatcher.start();
4833
+ await this.activityLog.log("event", "ConfigWatcher started for hot-reload");
4834
+ }
4835
+ /**
4836
+ * Handle config file changes. Propagates reloaded config to the running
4837
+ * loop and triggers an immediate heartbeat.
4838
+ */
4839
+ handleConfigChange() {
4840
+ if (this.configStore) {
4841
+ this.config = this.configStore.getAll();
4842
+ }
4843
+ this.activityLog.log("event", "Configuration reloaded (hot-reload)").catch(() => {
4844
+ });
4845
+ this.eventQueue.interrupt();
4278
4846
  }
4279
4847
  async runHeartbeat() {
4280
4848
  const startTime = Date.now();
4281
- const heartbeatId = randomUUID5();
4849
+ const heartbeatId = randomUUID6();
4282
4850
  const state = await this.readState();
4283
4851
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4284
4852
  const budgetCheck = await this.checkBudgetExceeded(state, today);
4285
4853
  if (budgetCheck.exceeded) return;
4286
- const grouped = this.eventQueue.drainAndGroup();
4854
+ const { grouped, rawEvents } = this.eventQueue.drainAndGroup();
4287
4855
  const totalEventCount = grouped.messages.length + grouped.webhooks.length + grouped.runCompletions.length;
4288
4856
  const activeRuns = await this.getActiveRuns();
4857
+ const decisionStore = this.getDecisionStore();
4858
+ await this.processDecisionAnswers(rawEvents, decisionStore);
4859
+ await decisionStore.expire();
4860
+ const _pendingDecisions = await decisionStore.pending();
4861
+ void _pendingDecisions;
4289
4862
  const skipResult = await this.handleSkipLogic({
4290
4863
  state,
4291
4864
  totalEventCount,
@@ -4320,6 +4893,17 @@ var HeartbeatLoop = class {
4320
4893
  }
4321
4894
  );
4322
4895
  const { costUsd, turnCount } = await this.callSdk(prompt, heartbeatId);
4896
+ if (turnCount === 0) {
4897
+ await this.activityLog.log(
4898
+ "warning",
4899
+ `Heartbeat #${modeResult.heartbeatCount} completed with turnCount=0. SDK stream may have timed out before any turns completed.`,
4900
+ { heartbeatId }
4901
+ );
4902
+ }
4903
+ if (rawEvents.length > 0) {
4904
+ const inboxPath = path14.join(this.supervisorDir, "inbox.jsonl");
4905
+ await this.eventQueue.markProcessed(inboxPath, this.eventsPath, rawEvents);
4906
+ }
4323
4907
  if (modeResult.isConsolidation) {
4324
4908
  const allIds = modeResult.unconsolidated.map((e) => e.id);
4325
4909
  if (allIds.length > 0) {
@@ -4349,6 +4933,27 @@ var HeartbeatLoop = class {
4349
4933
  isConsolidation: modeResult.isConsolidation
4350
4934
  }
4351
4935
  );
4936
+ await this.emitHeartbeatCompleted({
4937
+ heartbeatNumber: modeResult.heartbeatCount + 1,
4938
+ runsActive: activeRuns.length,
4939
+ todayUsd: budgetCheck.todayCost + costUsd,
4940
+ limitUsd: this.config.supervisor.dailyCapUsd
4941
+ });
4942
+ for (const event of rawEvents) {
4943
+ if (event.kind === "run_complete") {
4944
+ const runData = await this.readPersistedRun(event.runId);
4945
+ const emitOpts = {
4946
+ runId: event.runId,
4947
+ status: runData?.status === "failed" ? "failed" : "completed",
4948
+ costUsd: runData?.totalCostUsd ?? 0,
4949
+ durationMs: runData?.durationMs ?? 0
4950
+ };
4951
+ if (runData?.output) {
4952
+ emitOpts.output = runData.output;
4953
+ }
4954
+ await this.emitRunCompleted(emitOpts);
4955
+ }
4956
+ }
4352
4957
  }
4353
4958
  /**
4354
4959
  * Check if supervisor daily budget is exceeded.
@@ -4424,7 +5029,6 @@ var HeartbeatLoop = class {
4424
5029
  isCompaction,
4425
5030
  unconsolidated,
4426
5031
  heartbeatCount,
4427
- lastConsolidation,
4428
5032
  lastConsolidationTs
4429
5033
  };
4430
5034
  }
@@ -4504,6 +5108,12 @@ var HeartbeatLoop = class {
4504
5108
  }
4505
5109
  /**
4506
5110
  * Call the Claude SDK and stream results.
5111
+ *
5112
+ * Uses Promise.race to enable non-blocking abort detection. The standard
5113
+ * `for await (const message of stream)` pattern only checks the abort signal
5114
+ * AFTER each yield — if the SDK hangs (no messages), the abort never executes.
5115
+ * This implementation races each iterator.next() against an abort promise,
5116
+ * allowing immediate response to shutdown/timeout signals.
4507
5117
  */
4508
5118
  async callSdk(prompt, heartbeatId) {
4509
5119
  const abortController = new AbortController();
@@ -4523,25 +5133,48 @@ var HeartbeatLoop = class {
4523
5133
  }
4524
5134
  }
4525
5135
  const queryOptions = {
4526
- cwd: homedir2(),
5136
+ cwd: homedir4(),
4527
5137
  allowedTools,
4528
5138
  permissionMode: "bypassPermissions",
4529
5139
  allowDangerouslySkipPermissions: true,
4530
- mcpServers: this.config.mcpServers ?? {}
5140
+ mcpServers: this.config.mcpServers ?? {},
5141
+ // Don't persist session history — each heartbeat is a fresh conversation.
5142
+ // Without this, supervisor restarts could replay old messages.
5143
+ persistSession: false
4531
5144
  };
4532
5145
  const stream = sdk.query({ prompt, options: queryOptions });
4533
- for await (const message of stream) {
4534
- if (abortController.signal.aborted) break;
4535
- const msg = message;
4536
- if (isInitMessage(msg)) {
4537
- this.sessionId = msg.session_id;
5146
+ const abortPromise = new Promise((resolve4) => {
5147
+ if (abortController.signal.aborted) {
5148
+ resolve4({ aborted: true });
5149
+ return;
4538
5150
  }
4539
- if (isResultMessage(msg)) {
4540
- output = msg.result ?? "";
4541
- costUsd = msg.total_cost_usd ?? 0;
4542
- turnCount = msg.num_turns ?? 0;
5151
+ abortController.signal.addEventListener("abort", () => resolve4({ aborted: true }), {
5152
+ once: true
5153
+ });
5154
+ });
5155
+ const iterator = stream[Symbol.asyncIterator]();
5156
+ try {
5157
+ while (true) {
5158
+ const raceResult = await Promise.race([iterator.next(), abortPromise]);
5159
+ if ("aborted" in raceResult) {
5160
+ await this.activityLog.log("heartbeat", "Heartbeat aborted", { heartbeatId });
5161
+ break;
5162
+ }
5163
+ const iterResult = raceResult;
5164
+ if (iterResult.done) break;
5165
+ const msg = iterResult.value;
5166
+ if (isInitMessage(msg)) {
5167
+ this.sessionId = msg.session_id;
5168
+ }
5169
+ if (isResultMessage(msg)) {
5170
+ output = msg.result ?? "";
5171
+ costUsd = msg.total_cost_usd ?? 0;
5172
+ turnCount = msg.num_turns ?? 0;
5173
+ }
5174
+ await this.logStreamMessage(msg, heartbeatId);
4543
5175
  }
4544
- await this.logStreamMessage(msg, heartbeatId);
5176
+ } finally {
5177
+ await iterator.return?.();
4545
5178
  }
4546
5179
  } finally {
4547
5180
  clearTimeout(timeout);
@@ -4562,29 +5195,33 @@ var HeartbeatLoop = class {
4562
5195
  const raw = await readFile11(this.statePath, "utf-8");
4563
5196
  const state = JSON.parse(raw);
4564
5197
  Object.assign(state, updates);
4565
- await writeFile5(this.statePath, JSON.stringify(state, null, 2), "utf-8");
5198
+ await writeFile6(this.statePath, JSON.stringify(state, null, 2), "utf-8");
4566
5199
  } catch {
4567
5200
  }
4568
5201
  }
4569
- /** Read persisted run files and return summaries of active (running/paused) runs. */
5202
+ /**
5203
+ * Read persisted run files and return summaries of active (running/paused) runs.
5204
+ * Validates that "running" runs are actually alive by checking their PID.
5205
+ * Stale runs (dead PID past grace period) are filtered out to prevent ghost runs.
5206
+ */
4570
5207
  async getActiveRuns() {
4571
5208
  const runsDir = getRunsDir();
4572
5209
  if (!existsSync7(runsDir)) return [];
4573
5210
  try {
4574
- const entries = await readdir5(runsDir, { withFileTypes: true });
5211
+ const entries = await readdir4(runsDir, { withFileTypes: true });
4575
5212
  const active = [];
4576
5213
  for (const entry of entries) {
4577
5214
  if (!entry.isDirectory()) continue;
4578
5215
  const subDir = path14.join(runsDir, entry.name);
4579
- const files = await readdir5(subDir);
5216
+ const files = await readdir4(subDir);
4580
5217
  for (const f of files) {
4581
5218
  if (!f.endsWith(".json")) continue;
4582
5219
  try {
4583
5220
  const raw = await readFile11(path14.join(subDir, f), "utf-8");
4584
5221
  const run = JSON.parse(raw);
4585
- if (run.status === "running" || run.status === "paused") {
5222
+ if (isRunActive(run)) {
4586
5223
  active.push(
4587
- `${run.runId} [${run.status}] ${run.workflow} on ${path14.basename(run.repo)}`
5224
+ `${run.runId} [${run.status}] ${run.agent} on ${path14.basename(run.repo)}`
4588
5225
  );
4589
5226
  }
4590
5227
  } catch {
@@ -4663,21 +5300,191 @@ var HeartbeatLoop = class {
4663
5300
  if (!isToolResultMessage(msg)) return;
4664
5301
  const result = msg.result ?? "";
4665
5302
  const runMatch = /Run\s+(\S+)\s+dispatched/i.exec(result);
4666
- if (runMatch) {
4667
- await this.activityLog.log("dispatch", `Agent dispatched: ${runMatch[1]}`, {
5303
+ const runId = runMatch?.[1];
5304
+ if (runId) {
5305
+ await this.activityLog.log("dispatch", `Agent dispatched: ${runId}`, {
4668
5306
  heartbeatId,
4669
- runId: runMatch[1]
5307
+ runId
5308
+ });
5309
+ const agentMatch = /agent[:\s]+(\S+)/i.exec(result);
5310
+ const repoMatch = /repo[:\s]+(\S+)/i.exec(result);
5311
+ const branchMatch = /branch[:\s]+(\S+)/i.exec(result);
5312
+ const agent = agentMatch?.[1] ?? "unknown";
5313
+ const repo = repoMatch?.[1] ?? "unknown";
5314
+ const branch = branchMatch?.[1] ?? "unknown";
5315
+ await this.emitRunDispatched({
5316
+ runId,
5317
+ agent,
5318
+ repo,
5319
+ branch,
5320
+ prompt: result.slice(0, 500)
4670
5321
  });
4671
5322
  }
4672
5323
  }
4673
5324
  sleep(ms) {
4674
5325
  return new Promise((resolve4) => setTimeout(resolve4, ms));
4675
5326
  }
5327
+ /**
5328
+ * Process decision:answer events from inbox messages.
5329
+ * Expected format: "decision:answer <decisionId> <answer>"
5330
+ */
5331
+ async processDecisionAnswers(rawEvents, store) {
5332
+ for (const event of rawEvents) {
5333
+ if (event.kind !== "message") continue;
5334
+ const text = event.data.text.trim();
5335
+ const match = /^decision:answer\s+(\S+)\s+(.+)$/i.exec(text);
5336
+ if (!match) continue;
5337
+ const decisionId = match[1];
5338
+ const answer = match[2];
5339
+ if (!decisionId || !answer) continue;
5340
+ try {
5341
+ await store.answer(decisionId, answer);
5342
+ await this.activityLog.log("event", `Decision answered: ${decisionId}`, {
5343
+ decisionId,
5344
+ answer
5345
+ });
5346
+ } catch (error) {
5347
+ const msg = error instanceof Error ? error.message : String(error);
5348
+ await this.activityLog.log("error", `Failed to answer decision ${decisionId}: ${msg}`, {
5349
+ decisionId,
5350
+ answer
5351
+ });
5352
+ }
5353
+ }
5354
+ }
5355
+ /**
5356
+ * Read persisted run data to extract actual status, cost, and duration.
5357
+ * Returns null if the run file cannot be found or parsed.
5358
+ */
5359
+ async readPersistedRun(runId) {
5360
+ const runsDir = getRunsDir();
5361
+ if (!existsSync7(runsDir)) return null;
5362
+ try {
5363
+ const entries = await readdir4(runsDir, { withFileTypes: true });
5364
+ for (const entry of entries) {
5365
+ if (!entry.isDirectory()) continue;
5366
+ const subDir = path14.join(runsDir, entry.name);
5367
+ const runPath = path14.join(subDir, `${runId}.json`);
5368
+ if (existsSync7(runPath)) {
5369
+ const raw = await readFile11(runPath, "utf-8");
5370
+ const run = JSON.parse(raw);
5371
+ const totalCostUsd = Object.values(run.steps).reduce(
5372
+ (sum, step) => sum + (step.costUsd ?? 0),
5373
+ 0
5374
+ );
5375
+ const durationMs = new Date(run.updatedAt).getTime() - new Date(run.createdAt).getTime();
5376
+ const completedSteps = Object.values(run.steps).filter(
5377
+ (s) => s.status === "success" || s.status === "failure"
5378
+ );
5379
+ const lastStep = completedSteps[completedSteps.length - 1];
5380
+ const output = typeof lastStep?.rawOutput === "string" ? lastStep.rawOutput.slice(0, 1e3) : void 0;
5381
+ return { status: run.status, totalCostUsd, durationMs, output };
5382
+ }
5383
+ }
5384
+ } catch {
5385
+ }
5386
+ return null;
5387
+ }
5388
+ // ─── Webhook event emission ───────────────────────────────
5389
+ /**
5390
+ * Emit a webhook event if a callback is configured.
5391
+ * Validates the event against its schema before emission.
5392
+ */
5393
+ async emitWebhookEvent(event) {
5394
+ if (!this.onWebhookEvent) return;
5395
+ try {
5396
+ switch (event.type) {
5397
+ case "supervisor_started":
5398
+ supervisorStartedEventSchema.parse(event);
5399
+ break;
5400
+ case "heartbeat":
5401
+ heartbeatEventSchema.parse(event);
5402
+ break;
5403
+ case "run_dispatched":
5404
+ runDispatchedEventSchema.parse(event);
5405
+ break;
5406
+ case "run_completed":
5407
+ runCompletedEventSchema.parse(event);
5408
+ break;
5409
+ case "supervisor_stopped":
5410
+ supervisorStoppedEventSchema.parse(event);
5411
+ break;
5412
+ }
5413
+ await this.onWebhookEvent(event);
5414
+ } catch (error) {
5415
+ const msg = error instanceof Error ? error.message : String(error);
5416
+ await this.activityLog.log("error", `Webhook event emission failed: ${msg}`, {
5417
+ eventType: event.type
5418
+ });
5419
+ }
5420
+ }
5421
+ /** Emit SupervisorStartedEvent */
5422
+ async emitSupervisorStarted() {
5423
+ const event = {
5424
+ type: "supervisor_started",
5425
+ supervisorId: this.sessionId,
5426
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5427
+ };
5428
+ await this.emitWebhookEvent(event);
5429
+ }
5430
+ /** Emit SupervisorStoppedEvent */
5431
+ async emitSupervisorStopped(reason) {
5432
+ const event = {
5433
+ type: "supervisor_stopped",
5434
+ supervisorId: this.sessionId,
5435
+ stoppedAt: (/* @__PURE__ */ new Date()).toISOString(),
5436
+ reason
5437
+ };
5438
+ await this.emitWebhookEvent(event);
5439
+ }
5440
+ /** Emit HeartbeatEvent */
5441
+ async emitHeartbeatCompleted(opts) {
5442
+ const event = {
5443
+ type: "heartbeat",
5444
+ supervisorId: this.sessionId,
5445
+ heartbeatNumber: opts.heartbeatNumber,
5446
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5447
+ runsActive: opts.runsActive,
5448
+ budget: {
5449
+ todayUsd: opts.todayUsd,
5450
+ limitUsd: opts.limitUsd
5451
+ }
5452
+ };
5453
+ await this.emitWebhookEvent(event);
5454
+ }
5455
+ /** Emit RunDispatchedEvent from tool result detection */
5456
+ async emitRunDispatched(opts) {
5457
+ const event = {
5458
+ type: "run_dispatched",
5459
+ supervisorId: this.sessionId,
5460
+ runId: opts.runId,
5461
+ agent: opts.agent,
5462
+ repo: opts.repo,
5463
+ branch: opts.branch,
5464
+ prompt: opts.prompt.slice(0, 500)
5465
+ // Truncate to schema max
5466
+ };
5467
+ await this.emitWebhookEvent(event);
5468
+ }
5469
+ /** Emit RunCompletedEvent when processing run_complete events */
5470
+ async emitRunCompleted(opts) {
5471
+ const event = {
5472
+ type: "run_completed",
5473
+ supervisorId: this.sessionId,
5474
+ runId: opts.runId,
5475
+ status: opts.status,
5476
+ output: opts.output?.slice(0, 1e3),
5477
+ // Truncate to schema max
5478
+ costUsd: opts.costUsd,
5479
+ durationMs: opts.durationMs
5480
+ };
5481
+ await this.emitWebhookEvent(event);
5482
+ }
4676
5483
  };
4677
5484
 
4678
5485
  // src/supervisor/webhook-server.ts
4679
5486
  import { createHmac as createHmac2, timingSafeEqual } from "crypto";
4680
- import { appendFile as appendFile6 } from "fs/promises";
5487
+ import { appendFile as appendFile7 } from "fs/promises";
4681
5488
  import { createServer } from "http";
4682
5489
  var MAX_BODY_SIZE = 1024 * 1024;
4683
5490
  var WebhookServer = class {
@@ -4762,7 +5569,7 @@ var WebhookServer = class {
4762
5569
  payload: parsed.payload ?? parsed,
4763
5570
  receivedAt: (/* @__PURE__ */ new Date()).toISOString()
4764
5571
  };
4765
- await appendFile6(this.eventsPath, `${JSON.stringify(event)}
5572
+ await appendFile7(this.eventsPath, `${JSON.stringify(event)}
4766
5573
  `, "utf-8");
4767
5574
  this.onEvent(event);
4768
5575
  this.sendJson(res, 200, { ok: true, id: event.id });
@@ -4802,6 +5609,7 @@ var SupervisorDaemon = class {
4802
5609
  eventQueue = null;
4803
5610
  heartbeatLoop = null;
4804
5611
  activityLog = null;
5612
+ decisionStore = null;
4805
5613
  sessionId = "";
4806
5614
  constructor(options) {
4807
5615
  this.name = options.name;
@@ -4822,16 +5630,17 @@ var SupervisorDaemon = class {
4822
5630
  await rm2(lockPath, { force: true });
4823
5631
  }
4824
5632
  const tempLock = `${lockPath}.${process.pid}`;
4825
- await writeFile6(tempLock, String(process.pid), "utf-8");
5633
+ await writeFile7(tempLock, String(process.pid), "utf-8");
4826
5634
  const { rename: rename2 } = await import("fs/promises");
4827
5635
  await rename2(tempLock, lockPath);
4828
5636
  const existingState = await this.readState();
4829
5637
  if (existingState?.sessionId && existingState.status !== "stopped") {
4830
5638
  this.sessionId = existingState.sessionId;
4831
5639
  } else {
4832
- this.sessionId = randomUUID6();
5640
+ this.sessionId = randomUUID7();
4833
5641
  }
4834
5642
  this.activityLog = new ActivityLog(this.dir);
5643
+ this.decisionStore = new DecisionStore(getSupervisorDecisionsPath(this.name));
4835
5644
  this.eventQueue = new EventQueue({
4836
5645
  maxEventsPerSec: this.config.supervisor.maxEventsPerSec
4837
5646
  });
@@ -4845,6 +5654,12 @@ var SupervisorDaemon = class {
4845
5654
  eventsPath,
4846
5655
  onEvent: (event) => {
4847
5656
  this.eventQueue?.push({ kind: "webhook", data: event });
5657
+ this.handleDecisionAnswer(event).catch((err) => {
5658
+ this.activityLog?.log(
5659
+ "error",
5660
+ `Failed to handle decision:answer: ${err instanceof Error ? err.message : String(err)}`
5661
+ );
5662
+ });
4848
5663
  if ((event.event === "session:complete" || event.event === "session:fail") && event.payload) {
4849
5664
  const runId = typeof event.payload.runId === "string" ? event.payload.runId : void 0;
4850
5665
  if (runId) {
@@ -4863,7 +5678,7 @@ var SupervisorDaemon = class {
4863
5678
  pid: process.pid,
4864
5679
  sessionId: this.sessionId,
4865
5680
  port: this.config.supervisor.port,
4866
- cwd: homedir3(),
5681
+ cwd: homedir5(),
4867
5682
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4868
5683
  lastHeartbeat: existingState?.lastHeartbeat,
4869
5684
  heartbeatCount: existingState?.heartbeatCount ?? 0,
@@ -4894,6 +5709,7 @@ var SupervisorDaemon = class {
4894
5709
  sessionId: this.sessionId,
4895
5710
  eventQueue: this.eventQueue,
4896
5711
  activityLog: this.activityLog,
5712
+ eventsPath,
4897
5713
  defaultInstructionsPath: this.defaultInstructionsPath
4898
5714
  });
4899
5715
  await this.heartbeatLoop.start();
@@ -4936,7 +5752,7 @@ var SupervisorDaemon = class {
4936
5752
  }
4937
5753
  async writeState(state) {
4938
5754
  const statePath = path15.join(this.dir, "state.json");
4939
- await writeFile6(statePath, JSON.stringify(state, null, 2), "utf-8");
5755
+ await writeFile7(statePath, JSON.stringify(state, null, 2), "utf-8");
4940
5756
  }
4941
5757
  async readLockPid(lockPath) {
4942
5758
  try {
@@ -4947,14 +5763,248 @@ var SupervisorDaemon = class {
4947
5763
  return null;
4948
5764
  }
4949
5765
  }
5766
+ /**
5767
+ * Handle decision:answer webhook events.
5768
+ * Extracts decisionId and answer from the payload and records the answer.
5769
+ */
5770
+ async handleDecisionAnswer(event) {
5771
+ if (event.event !== "decision:answer") return;
5772
+ if (!this.decisionStore || !event.payload) return;
5773
+ const decisionId = typeof event.payload.decisionId === "string" ? event.payload.decisionId : void 0;
5774
+ const answer = typeof event.payload.answer === "string" ? event.payload.answer : void 0;
5775
+ if (!decisionId || !answer) {
5776
+ await this.activityLog?.log(
5777
+ "error",
5778
+ `decision:answer webhook missing required fields (decisionId: ${decisionId}, answer: ${answer})`
5779
+ );
5780
+ return;
5781
+ }
5782
+ try {
5783
+ await this.decisionStore.answer(decisionId, answer);
5784
+ await this.activityLog?.log(
5785
+ "decision",
5786
+ `Decision ${decisionId} answered via webhook: "${answer}"`
5787
+ );
5788
+ } catch (err) {
5789
+ await this.activityLog?.log(
5790
+ "error",
5791
+ `Failed to answer decision ${decisionId}: ${err instanceof Error ? err.message : String(err)}`
5792
+ );
5793
+ }
5794
+ }
5795
+ };
5796
+
5797
+ // src/supervisor/StatusReader.ts
5798
+ import { readFileSync as readFileSync2 } from "fs";
5799
+ import { readFile as readFile13 } from "fs/promises";
5800
+ import path16 from "path";
5801
+ var STATE_FILE = "state.json";
5802
+ var ACTIVITY_FILE2 = "activity.jsonl";
5803
+ var StatusReader = class {
5804
+ dataDir;
5805
+ statePath;
5806
+ activityPath;
5807
+ constructor(dataDir) {
5808
+ this.dataDir = dataDir;
5809
+ this.statePath = path16.join(dataDir, STATE_FILE);
5810
+ this.activityPath = path16.join(dataDir, ACTIVITY_FILE2);
5811
+ }
5812
+ /**
5813
+ * Read and parse supervisor status from disk.
5814
+ * Returns null if the state file doesn't exist (supervisor not running).
5815
+ */
5816
+ async getStatus() {
5817
+ let raw;
5818
+ try {
5819
+ raw = await readFile13(this.statePath, "utf-8");
5820
+ } catch {
5821
+ return null;
5822
+ }
5823
+ let parsed;
5824
+ try {
5825
+ parsed = JSON.parse(raw);
5826
+ } catch {
5827
+ return null;
5828
+ }
5829
+ const result = supervisorDaemonStateSchema.safeParse(parsed);
5830
+ if (!result.success) {
5831
+ return null;
5832
+ }
5833
+ const daemon = result.data;
5834
+ const statusMap = {
5835
+ running: "running",
5836
+ draining: "stopping",
5837
+ stopped: "idle"
5838
+ };
5839
+ const recentActivity = this.queryActivity({ limit: 5 });
5840
+ return {
5841
+ pid: daemon.pid,
5842
+ sessionId: daemon.sessionId,
5843
+ startedAt: daemon.startedAt,
5844
+ heartbeatCount: daemon.heartbeatCount,
5845
+ totalCostUsd: daemon.totalCostUsd,
5846
+ todayCostUsd: daemon.todayCostUsd,
5847
+ status: statusMap[daemon.status],
5848
+ lastHeartbeat: daemon.lastHeartbeat ?? daemon.startedAt,
5849
+ activeRunCount: 0,
5850
+ // TODO: count active runs from .neo/runs/
5851
+ recentActivitySummary: recentActivity.map((e) => `[${e.type}] ${e.summary}`)
5852
+ };
5853
+ }
5854
+ /**
5855
+ * Query activity entries with optional filtering.
5856
+ * Returns empty array if the activity file doesn't exist or is empty.
5857
+ */
5858
+ queryActivity(options = {}) {
5859
+ const { limit = 50, offset = 0, type, since, until } = options;
5860
+ let content;
5861
+ try {
5862
+ content = readFileSync2(this.activityPath, "utf-8");
5863
+ } catch {
5864
+ return [];
5865
+ }
5866
+ const lines = content.trim().split("\n").filter(Boolean);
5867
+ let entries = [];
5868
+ for (const line of lines) {
5869
+ try {
5870
+ const parsed = JSON.parse(line);
5871
+ const result = activityEntrySchema.safeParse(parsed);
5872
+ if (result.success) {
5873
+ entries.push(result.data);
5874
+ }
5875
+ } catch {
5876
+ }
5877
+ }
5878
+ if (type) {
5879
+ entries = entries.filter((e) => e.type === type);
5880
+ }
5881
+ if (since) {
5882
+ const sinceDate = new Date(since);
5883
+ entries = entries.filter((e) => new Date(e.timestamp) >= sinceDate);
5884
+ }
5885
+ if (until) {
5886
+ const untilDate = new Date(until);
5887
+ entries = entries.filter((e) => new Date(e.timestamp) <= untilDate);
5888
+ }
5889
+ return entries.slice(offset, offset + limit);
5890
+ }
4950
5891
  };
4951
5892
 
5893
+ // src/supervisor/shutdown.ts
5894
+ import { existsSync as existsSync9 } from "fs";
5895
+ import { readdir as readdir5, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
5896
+ import path17 from "path";
5897
+
5898
+ // src/webhook-config.ts
5899
+ import { existsSync as existsSync10 } from "fs";
5900
+ import { mkdir as mkdir8, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
5901
+ import path18 from "path";
5902
+ import { z as z7 } from "zod";
5903
+ var webhookEntrySchema = z7.object({
5904
+ url: z7.string().url(),
5905
+ events: z7.array(z7.string()).optional(),
5906
+ secret: z7.string().optional(),
5907
+ timeoutMs: z7.number().default(5e3),
5908
+ createdAt: z7.string().default(() => (/* @__PURE__ */ new Date()).toISOString())
5909
+ });
5910
+ var webhooksConfigSchema = z7.object({
5911
+ webhooks: z7.array(webhookEntrySchema).default([])
5912
+ });
5913
+ function getWebhooksConfigPath() {
5914
+ return path18.join(getDataDir(), "webhooks.json");
5915
+ }
5916
+ async function loadWebhooksConfig() {
5917
+ const configPath = getWebhooksConfigPath();
5918
+ if (!existsSync10(configPath)) {
5919
+ return { webhooks: [] };
5920
+ }
5921
+ const raw = await readFile15(configPath, "utf-8");
5922
+ const parsed = JSON.parse(raw);
5923
+ return webhooksConfigSchema.parse(parsed);
5924
+ }
5925
+ async function saveWebhooksConfig(config) {
5926
+ const configPath = getWebhooksConfigPath();
5927
+ await mkdir8(getDataDir(), { recursive: true });
5928
+ await writeFile9(configPath, JSON.stringify(config, null, 2), "utf-8");
5929
+ }
5930
+ async function addWebhook(input) {
5931
+ const config = await loadWebhooksConfig();
5932
+ const entry = webhookEntrySchema.parse(input);
5933
+ const existing = config.webhooks.findIndex((w) => w.url === entry.url);
5934
+ if (existing >= 0) {
5935
+ config.webhooks[existing] = entry;
5936
+ } else {
5937
+ config.webhooks.push(entry);
5938
+ }
5939
+ await saveWebhooksConfig(config);
5940
+ return entry;
5941
+ }
5942
+ async function removeWebhook(url) {
5943
+ const config = await loadWebhooksConfig();
5944
+ const initialLength = config.webhooks.length;
5945
+ config.webhooks = config.webhooks.filter((w) => w.url !== url);
5946
+ if (config.webhooks.length === initialLength) {
5947
+ return false;
5948
+ }
5949
+ await saveWebhooksConfig(config);
5950
+ return true;
5951
+ }
5952
+ async function listWebhooks() {
5953
+ const config = await loadWebhooksConfig();
5954
+ return config.webhooks;
5955
+ }
5956
+ async function testWebhooks() {
5957
+ const webhooks = await listWebhooks();
5958
+ if (webhooks.length === 0) {
5959
+ return [];
5960
+ }
5961
+ const payload = {
5962
+ type: "test",
5963
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5964
+ runId: `test-${Date.now()}`,
5965
+ status: "test",
5966
+ summary: "Test webhook from neo CLI"
5967
+ };
5968
+ const results = await Promise.all(
5969
+ webhooks.map(async (webhook) => {
5970
+ const start = Date.now();
5971
+ const body = JSON.stringify(payload);
5972
+ try {
5973
+ const response = await fetch(webhook.url, {
5974
+ method: "POST",
5975
+ headers: {
5976
+ "Content-Type": "application/json"
5977
+ },
5978
+ body,
5979
+ signal: AbortSignal.timeout(webhook.timeoutMs)
5980
+ });
5981
+ return {
5982
+ url: webhook.url,
5983
+ success: response.ok,
5984
+ statusCode: response.status,
5985
+ durationMs: Date.now() - start
5986
+ };
5987
+ } catch (err) {
5988
+ return {
5989
+ url: webhook.url,
5990
+ success: false,
5991
+ error: err instanceof Error ? err.message : String(err),
5992
+ durationMs: Date.now() - start
5993
+ };
5994
+ }
5995
+ })
5996
+ );
5997
+ return results;
5998
+ }
5999
+
4952
6000
  // src/index.ts
4953
6001
  var VERSION = "0.1.0";
4954
6002
  export {
4955
6003
  ActivityLog,
4956
6004
  AgentRegistry,
6005
+ ConfigStore,
4957
6006
  CostJournal,
6007
+ DecisionStore,
4958
6008
  EventJournal,
4959
6009
  EventQueue,
4960
6010
  HeartbeatLoop,
@@ -4965,13 +6015,14 @@ export {
4965
6015
  Semaphore,
4966
6016
  SessionError,
4967
6017
  SessionExecutor,
6018
+ StatusReader,
4968
6019
  SupervisorDaemon,
4969
6020
  VERSION,
4970
6021
  WebhookDispatcher,
4971
6022
  WebhookServer,
4972
- WorkflowRegistry,
4973
6023
  activityEntrySchema,
4974
6024
  addRepoToGlobalConfig,
6025
+ addWebhook,
4975
6026
  agentConfigSchema,
4976
6027
  agentModelSchema,
4977
6028
  agentSandboxSchema,
@@ -4988,6 +6039,8 @@ export {
4988
6039
  buildSandboxConfig,
4989
6040
  createBranch,
4990
6041
  createSessionClone,
6042
+ decisionOptionSchema,
6043
+ decisionSchema,
4991
6044
  deleteBranch,
4992
6045
  fetchRemote,
4993
6046
  getBranchName,
@@ -4999,6 +6052,7 @@ export {
4999
6052
  getRunLogPath,
5000
6053
  getRunsDir,
5001
6054
  getSupervisorActivityPath,
6055
+ getSupervisorDecisionsPath,
5002
6056
  getSupervisorDir,
5003
6057
  getSupervisorEventsPath,
5004
6058
  getSupervisorInboxPath,
@@ -5010,11 +6064,11 @@ export {
5010
6064
  isProcessAlive,
5011
6065
  listReposFromGlobalConfig,
5012
6066
  listSessionClones,
6067
+ listWebhooks,
5013
6068
  loadAgentFile,
5014
6069
  loadConfig,
5015
6070
  loadGlobalConfig,
5016
6071
  loadRepoInstructions,
5017
- loadWorkflow,
5018
6072
  loopDetection,
5019
6073
  matchesFilter,
5020
6074
  mcpServerConfigSchema,
@@ -5024,15 +6078,18 @@ export {
5024
6078
  pushSessionBranch,
5025
6079
  removeRepoFromGlobalConfig,
5026
6080
  removeSessionClone,
6081
+ removeWebhook,
5027
6082
  repoConfigSchema,
6083
+ repoOverrideConfigSchema,
5028
6084
  resolveAgent,
5029
6085
  runSession,
5030
6086
  runWithRecovery,
5031
6087
  supervisorDaemonStateSchema,
5032
6088
  supervisorDaemonStateSchema as supervisorStateSchema,
6089
+ supervisorStatusSchema,
6090
+ testWebhooks,
5033
6091
  toRepoSlug,
5034
- webhookIncomingEventSchema,
5035
- workflowGateDefSchema,
5036
- workflowStepDefSchema
6092
+ webhookEntrySchema,
6093
+ webhookIncomingEventSchema
5037
6094
  };
5038
6095
  //# sourceMappingURL=index.js.map