claude-maestro 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/out/main/index.js +1333 -98
  2. package/out/preload/index.js +13 -0
  3. package/out/renderer/assets/{index-H1mXv84m.js → index--LsdCcT2.js} +2 -2
  4. package/out/renderer/assets/{index-tMx4AmRy.js → index-2Q5NJLLa.js} +2 -2
  5. package/out/renderer/assets/{index-BW8lIWcc.css → index-B4pxYlCv.css} +5249 -4514
  6. package/out/renderer/assets/{index-DVz1TdHX.js → index-B9wQ40iJ.js} +4 -4
  7. package/out/renderer/assets/{index-C3tXFLVi.js → index-BD5wVgZG.js} +2 -2
  8. package/out/renderer/assets/{index-CYds92PN.js → index-BKVqTAbd.js} +3 -3
  9. package/out/renderer/assets/{index-CngVaxBn.js → index-BSnEdUx8.js} +5 -5
  10. package/out/renderer/assets/{index-D2J11DOI.js → index-BUr9qTcP.js} +5 -5
  11. package/out/renderer/assets/{index-3GYmITDo.js → index-BX4eMUiW.js} +2 -2
  12. package/out/renderer/assets/{index-dBdOx5at.js → index-BYrBOYyo.js} +5 -5
  13. package/out/renderer/assets/{index-C27Lfjw4.js → index-BdkQGOfF.js} +5 -5
  14. package/out/renderer/assets/{index-DTKHKVCm.js → index-BeXyvqqV.js} +2993 -1543
  15. package/out/renderer/assets/{index-DSDf1IPU.js → index-C2xG1-2F.js} +2 -2
  16. package/out/renderer/assets/{index-C9LaSnRB.js → index-CALj_g-h.js} +2 -2
  17. package/out/renderer/assets/{index-BuCQKc-Z.js → index-CqEbh7gN.js} +2 -2
  18. package/out/renderer/assets/{index-BTPlkF9b.js → index-CwrdXZxY.js} +2 -2
  19. package/out/renderer/assets/{index-CrDDqySQ.js → index-DFnn9t0U.js} +5 -5
  20. package/out/renderer/assets/{index-DLIiZzc_.js → index-DMLpIsZn.js} +2 -2
  21. package/out/renderer/assets/{index-DypnZCas.js → index-Dpj9EP42.js} +3 -3
  22. package/out/renderer/assets/{index-CIscupyH.js → index-DwCelZNB.js} +5 -5
  23. package/out/renderer/assets/{index-CA5CvYDB.js → index-K7jPnu8A.js} +1 -1
  24. package/out/renderer/assets/{index-D48Zxs0r.js → index-KjJOaoK3.js} +2 -2
  25. package/out/renderer/assets/{index-D14Kkf05.js → index-NXP0WfIH.js} +2 -2
  26. package/out/renderer/assets/{index-DYixubwQ.js → index-RU5VUPJx.js} +2 -2
  27. package/out/renderer/index.html +2 -2
  28. package/package.json +1 -1
package/out/main/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  const electron = require("electron");
3
+ const events = require("events");
3
4
  const path = require("path");
4
- const crypto = require("crypto");
5
5
  const fs = require("fs");
6
- const child_process = require("child_process");
7
6
  const os = require("os");
7
+ const crypto = require("crypto");
8
+ const child_process = require("child_process");
8
9
  const pty = require("node-pty");
9
10
  const chokidar = require("chokidar");
10
11
  const promises = require("fs/promises");
@@ -25,6 +26,278 @@ function _interopNamespaceDefault(e) {
25
26
  return Object.freeze(n);
26
27
  }
27
28
  const pty__namespace = /* @__PURE__ */ _interopNamespaceDefault(pty);
29
+ const USER_AGENTS_DIR = path.join(os.homedir(), ".claude", "agents");
30
+ const WATCH_DEBOUNCE_MS = 400;
31
+ class AgentRegistryService {
32
+ constructor(persistence2, getWin2) {
33
+ this.persistence = persistence2;
34
+ this.getWin = getWin2;
35
+ }
36
+ watchers = /* @__PURE__ */ new Map();
37
+ debounce = null;
38
+ /** Paths the renderer may read/reveal — everything the last snapshot listed. */
39
+ knownPaths = /* @__PURE__ */ new Set();
40
+ disposed = false;
41
+ dispose() {
42
+ this.disposed = true;
43
+ if (this.debounce) clearTimeout(this.debounce);
44
+ for (const w of this.watchers.values()) w.close();
45
+ this.watchers.clear();
46
+ }
47
+ registryPath() {
48
+ return this.persistence.state.settings.agentRegistryPath.trim();
49
+ }
50
+ /** Unique session repo folders (project-local agents may live in each). */
51
+ projectDirs() {
52
+ return [...new Set(this.persistence.state.sessions.map((s) => s.folder).filter(Boolean))];
53
+ }
54
+ /** Build a fresh snapshot and re-arm the watchers for the dirs it covers. */
55
+ snapshot() {
56
+ const registryPath = this.registryPath();
57
+ const registry = readRegistry(registryPath);
58
+ const agents = scanAgentsDir(USER_AGENTS_DIR, "user", null);
59
+ for (const dir of this.projectDirs()) {
60
+ agents.push(...scanAgentsDir(path.join(dir, ".claude", "agents"), "project", dir));
61
+ }
62
+ agents.sort((a, b) => a.name.localeCompare(b.name));
63
+ const byName = /* @__PURE__ */ new Map();
64
+ for (const e of registry.entries) byName.set(e.name.toLowerCase(), e);
65
+ const matched = /* @__PURE__ */ new Set();
66
+ for (const agent of agents) {
67
+ const entry = byName.get(agent.name.toLowerCase());
68
+ if (entry) {
69
+ agent.registry = entry;
70
+ matched.add(entry);
71
+ }
72
+ }
73
+ const missing = registry.entries.filter((e) => e.fileMissing && !matched.has(e));
74
+ const registryDir = path.dirname(registryPath);
75
+ const snapshot = {
76
+ agents,
77
+ missing,
78
+ registryPath,
79
+ registryError: registry.error,
80
+ registryVersion: registry.version,
81
+ registryUpdated: registry.updated,
82
+ factoryRunning: fs.existsSync(path.join(registryDir, ".factory.lock"))
83
+ };
84
+ this.knownPaths = /* @__PURE__ */ new Set();
85
+ for (const a of agents) this.knownPaths.add(a.filePath);
86
+ for (const e of registry.entries) if (e.filePath) this.knownPaths.add(e.filePath);
87
+ this.armWatchers(registryDir);
88
+ return snapshot;
89
+ }
90
+ /** Re-snapshot now and push it to the renderer (manual refresh / settings change). */
91
+ refresh() {
92
+ const snapshot = this.snapshot();
93
+ this.getWin()?.webContents.send("agents:changed", snapshot);
94
+ return snapshot;
95
+ }
96
+ /** Read an agent file the last snapshot listed (null when unknown/unreadable). */
97
+ readAgentFile(filePath) {
98
+ if (this.knownPaths.size === 0) this.snapshot();
99
+ if (!this.knownPaths.has(filePath)) return null;
100
+ try {
101
+ return fs.readFileSync(filePath, "utf8");
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ /** Reveal an agent file from the last snapshot in the OS file manager. */
107
+ revealAgentFile(filePath) {
108
+ if (this.knownPaths.has(filePath) && fs.existsSync(filePath)) electron.shell.showItemInFolder(filePath);
109
+ }
110
+ // ---------- file watching ----------
111
+ /**
112
+ * Watch the user agents dir, every project agents dir and the registry dir
113
+ * (the latter covers registry.json writes AND .factory.lock create/delete).
114
+ * Idempotent: only the delta of dirs is (un)watched.
115
+ */
116
+ armWatchers(registryDir) {
117
+ if (this.disposed) return;
118
+ const wanted = /* @__PURE__ */ new Set();
119
+ if (fs.existsSync(USER_AGENTS_DIR)) wanted.add(USER_AGENTS_DIR);
120
+ if (fs.existsSync(registryDir)) wanted.add(registryDir);
121
+ for (const dir of this.projectDirs()) {
122
+ const agentsDir = path.join(dir, ".claude", "agents");
123
+ if (fs.existsSync(agentsDir)) wanted.add(agentsDir);
124
+ }
125
+ for (const [dir, watcher] of this.watchers) {
126
+ if (!wanted.has(dir)) {
127
+ watcher.close();
128
+ this.watchers.delete(dir);
129
+ }
130
+ }
131
+ for (const dir of wanted) {
132
+ if (this.watchers.has(dir)) continue;
133
+ try {
134
+ const watcher = fs.watch(dir, () => this.scheduleRefresh());
135
+ watcher.on("error", () => {
136
+ watcher.close();
137
+ this.watchers.delete(dir);
138
+ });
139
+ this.watchers.set(dir, watcher);
140
+ } catch {
141
+ }
142
+ }
143
+ }
144
+ scheduleRefresh() {
145
+ if (this.disposed) return;
146
+ if (this.debounce) clearTimeout(this.debounce);
147
+ this.debounce = setTimeout(() => {
148
+ this.debounce = null;
149
+ this.refresh();
150
+ }, WATCH_DEBOUNCE_MS);
151
+ }
152
+ }
153
+ function parseAgentFrontmatter(md) {
154
+ const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(md);
155
+ if (!m) return {};
156
+ const out = {};
157
+ const lines = m[1].split(/\r?\n/);
158
+ for (let i = 0; i < lines.length; i++) {
159
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
160
+ if (!kv) continue;
161
+ const key = kv[1];
162
+ let value = kv[2].trim();
163
+ if (value === "|" || value === ">" || value === "|-" || value === ">-") {
164
+ const block = [];
165
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
166
+ block.push(lines[++i].trim());
167
+ }
168
+ value = block.join(" ").trim();
169
+ } else if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
170
+ value = value.slice(1, -1);
171
+ }
172
+ if (key === "name") out.name = value;
173
+ else if (key === "description") out.description = value;
174
+ else if (key === "model") out.model = value;
175
+ }
176
+ return out;
177
+ }
178
+ function scanAgentsDir(dir, scope, projectDir) {
179
+ if (!fs.existsSync(dir)) return [];
180
+ let entries;
181
+ try {
182
+ entries = fs.readdirSync(dir);
183
+ } catch {
184
+ return [];
185
+ }
186
+ const out = [];
187
+ for (const entry of entries) {
188
+ if (!entry.endsWith(".md")) continue;
189
+ const filePath = path.join(dir, entry);
190
+ const base = entry.replace(/\.md$/, "");
191
+ let fm = {};
192
+ try {
193
+ fm = parseAgentFrontmatter(fs.readFileSync(filePath, "utf8"));
194
+ } catch {
195
+ }
196
+ out.push({
197
+ name: fm.name?.trim() || base,
198
+ description: fm.description ?? "",
199
+ model: fm.model?.trim() || null,
200
+ scope,
201
+ projectDir,
202
+ filePath,
203
+ registry: null
204
+ });
205
+ }
206
+ return out;
207
+ }
208
+ function readRegistry(registryPath) {
209
+ if (!registryPath) return { entries: [], error: "No registry path configured.", version: null, updated: null };
210
+ if (!fs.existsSync(registryPath)) {
211
+ return { entries: [], error: `Registry not found: ${registryPath}`, version: null, updated: null };
212
+ }
213
+ let parsed;
214
+ try {
215
+ parsed = JSON.parse(fs.readFileSync(registryPath, "utf8"));
216
+ } catch (err) {
217
+ return {
218
+ entries: [],
219
+ error: `Registry unreadable: ${err.message || String(err)}`,
220
+ version: null,
221
+ updated: null
222
+ };
223
+ }
224
+ const root2 = parsed ?? {};
225
+ const meta = root2._meta ?? {};
226
+ const rawAgents = Array.isArray(root2.agents) ? root2.agents : [];
227
+ const entries = [];
228
+ for (const raw of rawAgents) {
229
+ if (!raw || typeof raw !== "object") continue;
230
+ const entry = parseEntry(raw, registryPath);
231
+ if (entry) entries.push(entry);
232
+ }
233
+ return {
234
+ entries,
235
+ error: null,
236
+ version: typeof meta.version === "string" ? meta.version : null,
237
+ updated: typeof meta.last_updated === "string" ? meta.last_updated : null
238
+ };
239
+ }
240
+ function parseEntry(r, registryPath) {
241
+ const name = String(r.name ?? "").trim();
242
+ if (!name) return null;
243
+ const filePath = resolveEntryPath(r.file_path, registryPath);
244
+ return {
245
+ name,
246
+ filePath,
247
+ type: optString(r.type),
248
+ status: optString(r.status),
249
+ archetype: optString(r.archetype),
250
+ model: optString(r.model),
251
+ scope: optString(r.scope),
252
+ description: String(r.description ?? "").trim(),
253
+ topics: toStringArray$1(r.topics),
254
+ keywords: toStringArray$1(r.keywords),
255
+ relatedAgents: toStringArray$1(r.related_agents),
256
+ confluencePages: [
257
+ .../* @__PURE__ */ new Set([...toStringArray$1(r.confluence_source), ...toStringArray$1(r.confluence_pages)])
258
+ ],
259
+ sourceVerified: r.source_verified === true,
260
+ githubRepos: toGithubRepos(r.github_repos),
261
+ githubVerified: r.github_verified === true,
262
+ knowledgeNotes: toStringArray$1(r.knowledge_note),
263
+ factoryMade: typeof r.factory_made === "boolean" ? r.factory_made : null,
264
+ created: optString(r.created),
265
+ lastUpdated: optString(r.last_updated),
266
+ fileMissing: filePath !== null && !fs.existsSync(filePath)
267
+ };
268
+ }
269
+ function resolveEntryPath(raw, registryPath) {
270
+ const fp = String(raw ?? "").trim();
271
+ if (!fp) return null;
272
+ if (path.isAbsolute(fp)) return fp;
273
+ const registryDir = path.dirname(registryPath);
274
+ const fromRepoRoot = path.resolve(path.dirname(registryDir), fp);
275
+ if (fs.existsSync(fromRepoRoot)) return fromRepoRoot;
276
+ const fromRegistryDir = path.resolve(registryDir, fp);
277
+ return fs.existsSync(fromRegistryDir) ? fromRegistryDir : fromRepoRoot;
278
+ }
279
+ function optString(v) {
280
+ if (typeof v !== "string") return null;
281
+ const s = v.trim();
282
+ return s || null;
283
+ }
284
+ function toStringArray$1(v) {
285
+ if (typeof v === "string") return v.trim() ? [v.trim()] : [];
286
+ if (!Array.isArray(v)) return [];
287
+ return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
288
+ }
289
+ function toGithubRepos(v) {
290
+ if (!Array.isArray(v)) return [];
291
+ const out = [];
292
+ for (const raw of v) {
293
+ if (!raw || typeof raw !== "object") continue;
294
+ const r = raw;
295
+ const repo = String(r.repo ?? "").trim();
296
+ if (!repo) continue;
297
+ out.push({ repo, ref: optString(r.ref), paths: toStringArray$1(r.paths) });
298
+ }
299
+ return out;
300
+ }
28
301
  function git(cwd, args, env) {
29
302
  return new Promise((resolve2, reject) => {
30
303
  child_process.execFile(
@@ -737,8 +1010,8 @@ function resolveClaude() {
737
1010
  if (!IS_WIN$1) {
738
1011
  return candidates[0] ? { file: candidates[0], argsPrefix: [] } : null;
739
1012
  }
740
- const exe = candidates.find((c) => c.toLowerCase().endsWith(".exe"));
741
- const cmd = candidates.find((c) => /\.(cmd|bat)$/i.test(c));
1013
+ const exe = candidates.find((c2) => c2.toLowerCase().endsWith(".exe"));
1014
+ const cmd = candidates.find((c2) => /\.(cmd|bat)$/i.test(c2));
742
1015
  if (exe) return { file: exe, argsPrefix: [] };
743
1016
  if (cmd) return { file: process.env.ComSpec ?? "cmd.exe", argsPrefix: ["/c", cmd] };
744
1017
  return null;
@@ -1035,7 +1308,7 @@ function extractJson(text) {
1035
1308
  return null;
1036
1309
  }
1037
1310
  }
1038
- const TICK_MS$1 = 3e4;
1311
+ const TICK_MS$2 = 3e4;
1039
1312
  const AGENT_TIMEOUT_MS = 5 * 6e4;
1040
1313
  const MAX_RUNS_PER_SESSION$1 = 30;
1041
1314
  const IDEA_COUNT = 4;
@@ -1051,10 +1324,12 @@ const ALLOWED_TOOLS$2 = [
1051
1324
  "Bash(git rev-parse:*)"
1052
1325
  ];
1053
1326
  class AutoExpandService {
1054
- constructor(persistence2, features, getWin2) {
1327
+ constructor(persistence2, features, getWin2, emitGame = () => {
1328
+ }) {
1055
1329
  this.persistence = persistence2;
1056
1330
  this.features = features;
1057
1331
  this.getWin = getWin2;
1332
+ this.emitGame = emitGame;
1058
1333
  }
1059
1334
  timer = null;
1060
1335
  /** Next scheduled run (ms epoch) per session id. */
@@ -1064,7 +1339,7 @@ class AutoExpandService {
1064
1339
  runs = /* @__PURE__ */ new Map();
1065
1340
  start() {
1066
1341
  if (this.timer) return;
1067
- this.timer = setInterval(() => this.tick(), TICK_MS$1);
1342
+ this.timer = setInterval(() => this.tick(), TICK_MS$2);
1068
1343
  this.tick();
1069
1344
  }
1070
1345
  dispose() {
@@ -1251,6 +1526,7 @@ class AutoExpandService {
1251
1526
  run.finishedAt = Date.now();
1252
1527
  run.status = status;
1253
1528
  this.broadcast(run.sessionId);
1529
+ if (status === "done") this.emitGame({ type: "autoexpand.done" });
1254
1530
  }
1255
1531
  broadcast(sessionId) {
1256
1532
  this.getWin()?.webContents.send("autoexpand:runs", sessionId, this.listRuns(sessionId));
@@ -1431,6 +1707,14 @@ class ConductorService {
1431
1707
  /** The in-flight planner child, so dispose()/a new turn can cancel it. */
1432
1708
  inFlight = null;
1433
1709
  busy = false;
1710
+ /** Observer fired after each completed (non-error) turn — wired to the Factory's
1711
+ * self-growth detector in index.ts. A post-construction setter avoids a
1712
+ * constructor cycle (conductor is built before the factory). */
1713
+ turnCompleteCb = null;
1714
+ /** Register an observer notified after each successful turn (e.g. the Factory). */
1715
+ onTurnComplete(cb) {
1716
+ this.turnCompleteCb = cb;
1717
+ }
1434
1718
  list() {
1435
1719
  return this.messages;
1436
1720
  }
@@ -1508,6 +1792,12 @@ class ConductorService {
1508
1792
  this.inFlight = null;
1509
1793
  this.busy = false;
1510
1794
  this.persistAndBroadcast();
1795
+ if (!assistantMsg.error) {
1796
+ try {
1797
+ this.turnCompleteCb?.(this.messages);
1798
+ } catch {
1799
+ }
1800
+ }
1511
1801
  }
1512
1802
  }
1513
1803
  /**
@@ -1735,9 +2025,9 @@ class ConductorService {
1735
2025
  const list = focusId ? this.sessions.list().filter((s) => s.config.id === focusId) : this.sessions.list();
1736
2026
  const sessions = await Promise.all(
1737
2027
  list.map(async (s) => {
1738
- const c = s.config;
1739
- const git2 = await this.sessions.getGitStatus(c.id).catch(() => null);
1740
- const feats = this.features.list(c.id).map((f) => ({
2028
+ const c2 = s.config;
2029
+ const git2 = await this.sessions.getGitStatus(c2.id).catch(() => null);
2030
+ const feats = this.features.list(c2.id).map((f) => ({
1741
2031
  id: f.id,
1742
2032
  title: f.title,
1743
2033
  status: f.status,
@@ -1745,19 +2035,19 @@ class ConductorService {
1745
2035
  openSpecs: f.specs.filter((sp) => !sp.done).length
1746
2036
  }));
1747
2037
  const entry = {
1748
- id: c.id,
1749
- name: c.name,
1750
- folder: c.folder,
1751
- isActive: c.id === activeId,
2038
+ id: c2.id,
2039
+ name: c2.name,
2040
+ folder: c2.folder,
2041
+ isActive: c2.id === activeId,
1752
2042
  status: s.status,
1753
- categoryId: c.categoryId ?? null,
2043
+ categoryId: c2.categoryId ?? null,
1754
2044
  terminals: s.terminals.map((t) => ({ kind: t.config.kind, status: t.status })),
1755
- worktree: c.worktree ? {
1756
- parentSessionId: c.worktree.parentSessionId,
1757
- branch: c.worktree.branch,
1758
- baseBranch: c.worktree.baseBranch
2045
+ worktree: c2.worktree ? {
2046
+ parentSessionId: c2.worktree.parentSessionId,
2047
+ branch: c2.worktree.branch,
2048
+ baseBranch: c2.worktree.baseBranch
1759
2049
  } : null,
1760
- autoExpand: c.autoExpand ? { enabled: c.autoExpand.enabled, branch: c.autoExpand.branch } : null,
2050
+ autoExpand: c2.autoExpand ? { enabled: c2.autoExpand.enabled, branch: c2.autoExpand.branch } : null,
1761
2051
  git: git2 ? {
1762
2052
  branch: git2.branch,
1763
2053
  ahead: git2.ahead,
@@ -1766,8 +2056,8 @@ class ConductorService {
1766
2056
  } : null,
1767
2057
  features: feats
1768
2058
  };
1769
- if (c.worktree) {
1770
- const task = await this.sessions.getWorktreeTaskState(c.id).catch(() => null);
2059
+ if (c2.worktree) {
2060
+ const task = await this.sessions.getWorktreeTaskState(c2.id).catch(() => null);
1771
2061
  if (task) {
1772
2062
  entry.task = {
1773
2063
  dirty: task.dirty,
@@ -2101,12 +2391,21 @@ function scanAgents() {
2101
2391
  }
2102
2392
  return out.sort((a, b) => a.name.localeCompare(b.name));
2103
2393
  }
2104
- const EMPTY = { artifacts: [], topics: [], lessons: [] };
2394
+ const EMPTY = { artifacts: [], topics: [], lessons: [], suggestions: [] };
2395
+ const EMPTY_GROWTH = {
2396
+ lastScannedAt: {},
2397
+ lastAutoProposeAt: 0,
2398
+ lastDetectAt: 0,
2399
+ turnsSinceDetect: 0,
2400
+ judgeDay: "",
2401
+ judgeCallsToday: 0
2402
+ };
2105
2403
  class FactoryStore {
2106
2404
  file = path.join(electron.app.getPath("userData"), "factory.json");
2107
2405
  timer = null;
2108
2406
  state = { ...EMPTY };
2109
2407
  runs = [];
2408
+ growth = { ...EMPTY_GROWTH };
2110
2409
  /** Load the saved registry (best-effort; an empty registry on any error). */
2111
2410
  load() {
2112
2411
  try {
@@ -2114,12 +2413,19 @@ class FactoryStore {
2114
2413
  this.state = {
2115
2414
  artifacts: Array.isArray(raw?.artifacts) ? raw.artifacts : [],
2116
2415
  topics: Array.isArray(raw?.topics) ? raw.topics : [],
2117
- lessons: Array.isArray(raw?.lessons) ? raw.lessons : []
2416
+ lessons: Array.isArray(raw?.lessons) ? raw.lessons : [],
2417
+ // Back-compat: older factory.json files have no `suggestions` key.
2418
+ suggestions: Array.isArray(raw?.suggestions) ? raw.suggestions : []
2118
2419
  };
2119
2420
  this.runs = Array.isArray(raw?.runs) ? raw.runs : [];
2421
+ this.growth = raw?.growth && typeof raw.growth === "object" ? { ...EMPTY_GROWTH, ...raw.growth } : { ...EMPTY_GROWTH };
2422
+ if (!this.growth.lastScannedAt || typeof this.growth.lastScannedAt !== "object" || Array.isArray(this.growth.lastScannedAt)) {
2423
+ this.growth.lastScannedAt = {};
2424
+ }
2120
2425
  } catch {
2121
2426
  this.state = { ...EMPTY };
2122
2427
  this.runs = [];
2428
+ this.growth = { ...EMPTY_GROWTH };
2123
2429
  }
2124
2430
  return this.state;
2125
2431
  }
@@ -2130,6 +2436,10 @@ class FactoryStore {
2130
2436
  loadRuns() {
2131
2437
  return this.runs;
2132
2438
  }
2439
+ /** Self-growth bookkeeping (call after load()). */
2440
+ loadGrowth() {
2441
+ return this.growth;
2442
+ }
2133
2443
  /** Replace the registry and schedule a save. */
2134
2444
  set(state) {
2135
2445
  this.state = state;
@@ -2140,6 +2450,11 @@ class FactoryStore {
2140
2450
  this.runs = runs;
2141
2451
  this.scheduleSave();
2142
2452
  }
2453
+ /** Replace the self-growth bookkeeping and schedule a save. */
2454
+ setGrowth(growth) {
2455
+ this.growth = growth;
2456
+ this.scheduleSave();
2457
+ }
2143
2458
  scheduleSave() {
2144
2459
  if (this.timer) clearTimeout(this.timer);
2145
2460
  this.timer = setTimeout(() => this.saveNow(), 500);
@@ -2152,7 +2467,11 @@ class FactoryStore {
2152
2467
  try {
2153
2468
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
2154
2469
  const tmp = this.file + ".tmp";
2155
- fs.writeFileSync(tmp, JSON.stringify({ ...this.state, runs: this.runs }, null, 2), "utf8");
2470
+ fs.writeFileSync(
2471
+ tmp,
2472
+ JSON.stringify({ ...this.state, runs: this.runs, growth: this.growth }, null, 2),
2473
+ "utf8"
2474
+ );
2156
2475
  fs.renameSync(tmp, this.file);
2157
2476
  } catch (err) {
2158
2477
  console.error("Failed to persist factory registry:", err);
@@ -2166,6 +2485,22 @@ const MAX_CANDIDATES = 8;
2166
2485
  const MAX_RUNS = 25;
2167
2486
  const MAX_TOPICS = 60;
2168
2487
  const MAX_LESSONS = 40;
2488
+ const TICK_MS$1 = 6e4;
2489
+ const AUTO_PROPOSE_INTERVAL_MS = 6 * 60 * 6e4;
2490
+ const AUTO_PROPOSE_BOOT_GRACE_MS = 10 * 6e4;
2491
+ const DETECT_DEBOUNCE_MS = 4e3;
2492
+ const DETECT_TIMEOUT_MS = 45e3;
2493
+ const MIN_TURNS_SINCE_DETECT = 3;
2494
+ const DETECT_MIN_INTERVAL_MS = 20 * 6e4;
2495
+ const DETECT_CONTEXT_MAX = 6e3;
2496
+ const MIN_CONFIDENCE = 0.6;
2497
+ const MAX_SUGGESTIONS_PER_DETECT = 2;
2498
+ const JUDGE_DAILY_CAP = 12;
2499
+ const MAX_SUGGESTIONS = 60;
2500
+ function localDay$1() {
2501
+ const d = /* @__PURE__ */ new Date();
2502
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
2503
+ }
2169
2504
  const KNOWN_LABELS = {
2170
2505
  claude_ai_Atlassian: "Atlassian (Confluence / Jira)",
2171
2506
  claude_ai_Figma: "Figma",
@@ -2173,20 +2508,46 @@ const KNOWN_LABELS = {
2173
2508
  github: "GitHub"
2174
2509
  };
2175
2510
  class FactoryService {
2176
- constructor(getWin2) {
2511
+ constructor(getWin2, emitGame = () => {
2512
+ }) {
2177
2513
  this.getWin = getWin2;
2514
+ this.emitGame = emitGame;
2178
2515
  this.state = this.store.load();
2179
2516
  this.runs = restoreRuns(this.store.loadRuns());
2517
+ for (const s of this.state.suggestions) {
2518
+ if (s.status === "creating") {
2519
+ s.status = "open";
2520
+ s.result = void 0;
2521
+ }
2522
+ }
2180
2523
  }
2181
2524
  store = new FactoryStore();
2182
2525
  state;
2183
2526
  runs = [];
2184
2527
  sources = null;
2185
- /** The in-flight agent child, so dispose()/cancel() can kill it. */
2528
+ /** The in-flight cancellable agent child (scan/author/judge), so dispose()/cancel() can kill it. */
2186
2529
  inFlight = null;
2530
+ /** Source-discovery child — a SEPARATE slot from `inFlight` so discovery (which
2531
+ * can run while `busy` is false) never nulls a live scan/author/judge child. */
2532
+ discoverChild = null;
2533
+ /** In-flight discovery promise; concurrent callers join it instead of spawning
2534
+ * a second discovery agent (the single-headless-child invariant). */
2535
+ discovering = null;
2187
2536
  busy = false;
2537
+ /** Resolves when the current heavy op (scan/author/judge) releases the lock —
2538
+ * the public listSources() awaits it before starting a discovery agent so the
2539
+ * two never run concurrently. Null while idle. */
2540
+ busyPromise = null;
2541
+ busyResolve = null;
2188
2542
  /** Set by cancel(); the in-flight scan/author reports 'cancelled' instead of 'error'. */
2189
2543
  cancelRequested = false;
2544
+ /** Self-growth background timer; null until start(). */
2545
+ timer = null;
2546
+ /** Debounce timer for the conversation detector. */
2547
+ detectTimer = null;
2548
+ /** Claimed synchronously while an auto-propose pass is dispatched (incl. its
2549
+ * pre-scan discovery await), so the 60s timer can't double-dispatch it. */
2550
+ autoProposing = false;
2190
2551
  getState() {
2191
2552
  return this.state;
2192
2553
  }
@@ -2194,13 +2555,451 @@ class FactoryService {
2194
2555
  return this.runs;
2195
2556
  }
2196
2557
  dispose() {
2558
+ if (this.timer) clearInterval(this.timer);
2559
+ this.timer = null;
2560
+ if (this.detectTimer) clearTimeout(this.detectTimer);
2561
+ this.detectTimer = null;
2197
2562
  try {
2198
2563
  this.inFlight?.kill();
2199
2564
  } catch {
2200
2565
  }
2566
+ try {
2567
+ this.discoverChild?.kill();
2568
+ } catch {
2569
+ }
2201
2570
  this.inFlight = null;
2571
+ this.discoverChild = null;
2202
2572
  this.store.saveNow();
2203
2573
  }
2574
+ // ---------- self-growth: background timer ----------
2575
+ /** Begin the self-growth loop: an infrequent token-spending auto-propose pass. */
2576
+ start() {
2577
+ if (this.timer) return;
2578
+ const g = this.store.loadGrowth();
2579
+ const earliest = Date.now() - AUTO_PROPOSE_INTERVAL_MS + AUTO_PROPOSE_BOOT_GRACE_MS;
2580
+ if (g.lastAutoProposeAt < earliest) {
2581
+ this.store.setGrowth({ ...g, lastAutoProposeAt: earliest });
2582
+ }
2583
+ this.timer = setInterval(() => this.tick(), TICK_MS$1);
2584
+ }
2585
+ tick() {
2586
+ if (this.busy || this.autoProposing) return;
2587
+ const g = this.store.loadGrowth();
2588
+ if (Date.now() - g.lastAutoProposeAt >= AUTO_PROPOSE_INTERVAL_MS) {
2589
+ void this.autoProposePass().catch(() => {
2590
+ });
2591
+ }
2592
+ }
2593
+ /**
2594
+ * Infrequent token-spending pass: scan the connected MCP source that's gone
2595
+ * longest without a scan (round-robin), then convert that scan's proposed
2596
+ * candidates into suggestions (never auto-installed). A dedicated
2597
+ * `autoProposing` guard (claimed synchronously) prevents a second tick from
2598
+ * dispatching while this one is parked in discovery; the budget/rotation slot
2599
+ * is only consumed when a scan actually runs, and we harvest exactly the run
2600
+ * this pass produced (never a stale older one).
2601
+ */
2602
+ async autoProposePass() {
2603
+ if (this.busy || this.autoProposing) return;
2604
+ this.autoProposing = true;
2605
+ try {
2606
+ const sources = await this.listSources().catch(() => []);
2607
+ if (sources.length === 0) return;
2608
+ const g = this.store.loadGrowth();
2609
+ const next = [...sources].sort(
2610
+ (a, b) => (g.lastScannedAt[a.server] ?? 0) - (g.lastScannedAt[b.server] ?? 0)
2611
+ )[0];
2612
+ const runId = await this.scan(
2613
+ next.server,
2614
+ "Automated background scan: surface only a few high-value, clearly groundable candidates."
2615
+ );
2616
+ if (!runId) return;
2617
+ const g2 = this.store.loadGrowth();
2618
+ this.store.setGrowth({
2619
+ ...g2,
2620
+ lastScannedAt: { ...g2.lastScannedAt, [next.server]: Date.now() },
2621
+ lastAutoProposeAt: Date.now()
2622
+ });
2623
+ const run = this.runs.find((r) => r.id === runId);
2624
+ if (run && run.status === "done") this.absorbScanSuggestions(run);
2625
+ } finally {
2626
+ this.autoProposing = false;
2627
+ }
2628
+ }
2629
+ absorbScanSuggestions(run) {
2630
+ let newest = null;
2631
+ let added = 0;
2632
+ for (const c2 of run.candidates) {
2633
+ if (c2.status !== "proposed") continue;
2634
+ if (this.suggestionDuplicate(c2.kind, c2.name, c2.description)) continue;
2635
+ newest = this.enqueueSuggestion({
2636
+ suggestedKind: c2.kind,
2637
+ name: c2.name,
2638
+ title: c2.description,
2639
+ description: c2.description,
2640
+ rationale: c2.rationale,
2641
+ origin: "scan",
2642
+ sourceRef: run.source,
2643
+ sourceLabel: run.sourceLabel,
2644
+ source: run.source,
2645
+ context: run.summary,
2646
+ topics: c2.topics,
2647
+ keywords: c2.keywords,
2648
+ existing: c2.existing,
2649
+ confidence: 0.8
2650
+ });
2651
+ added++;
2652
+ }
2653
+ if (added > 0) {
2654
+ this.persist();
2655
+ if (newest) this.getWin()?.webContents.send("factory:suggestion", newest);
2656
+ }
2657
+ }
2658
+ // ---------- self-growth: conversation detector ----------
2659
+ /**
2660
+ * Called (fire-and-forget) after each completed Conductor turn. Debounced and
2661
+ * heavily rate-limited; schedules a short headless judge that may queue
2662
+ * skill/agent suggestions. Never blocks or throws into the caller.
2663
+ */
2664
+ considerConversation(messages) {
2665
+ const g = this.store.loadGrowth();
2666
+ this.store.setGrowth({ ...g, turnsSinceDetect: (g.turnsSinceDetect ?? 0) + 1 });
2667
+ if (this.detectTimer) clearTimeout(this.detectTimer);
2668
+ this.detectTimer = setTimeout(() => {
2669
+ this.detectTimer = null;
2670
+ void this.maybeRunJudge(messages).catch(() => {
2671
+ });
2672
+ }, DETECT_DEBOUNCE_MS);
2673
+ }
2674
+ async maybeRunJudge(messages) {
2675
+ if (this.busy || this.discovering) return;
2676
+ const g = this.store.loadGrowth();
2677
+ const now = Date.now();
2678
+ if (g.turnsSinceDetect < MIN_TURNS_SINCE_DETECT && now - g.lastDetectAt < DETECT_MIN_INTERVAL_MS) {
2679
+ return;
2680
+ }
2681
+ const turnsAtStart = g.turnsSinceDetect;
2682
+ const day = localDay$1();
2683
+ const callsToday = g.judgeDay === day ? g.judgeCallsToday : 0;
2684
+ if (callsToday >= JUDGE_DAILY_CAP) return;
2685
+ const recent = messages.filter((m) => !m.pending && m.text?.trim()).slice(-6);
2686
+ if (recent.length === 0) return;
2687
+ this.setBusy(true);
2688
+ this.cancelRequested = false;
2689
+ try {
2690
+ const convo = recent.map((m) => `${m.role === "user" ? "USER" : "ASSISTANT"}: ${m.text.trim()}`).join("\n\n");
2691
+ const out = await runHeadlessClaude({
2692
+ cwd: process.cwd(),
2693
+ prompt: this.judgePrompt(convo),
2694
+ allowedTools: ["Read"],
2695
+ timeoutMs: DETECT_TIMEOUT_MS,
2696
+ onSpawn: (child) => this.inFlight = child
2697
+ });
2698
+ this.absorbChatSuggestions(
2699
+ this.parseJudge(out),
2700
+ recent[recent.length - 1].id,
2701
+ convo.slice(0, DETECT_CONTEXT_MAX)
2702
+ );
2703
+ } catch {
2704
+ } finally {
2705
+ this.inFlight = null;
2706
+ this.setBusy(false);
2707
+ const g2 = this.store.loadGrowth();
2708
+ const day2 = localDay$1();
2709
+ this.store.setGrowth({
2710
+ ...g2,
2711
+ lastDetectAt: Date.now(),
2712
+ turnsSinceDetect: Math.max(0, (g2.turnsSinceDetect ?? 0) - turnsAtStart),
2713
+ judgeDay: day2,
2714
+ judgeCallsToday: (g2.judgeDay === day2 ? g2.judgeCallsToday : 0) + 1
2715
+ });
2716
+ }
2717
+ }
2718
+ judgePrompt(convo) {
2719
+ const existing = this.existingNamesSnapshot();
2720
+ return [
2721
+ "You are the talent scout for an Agent & Skill Factory inside Maestro. You read a recent",
2722
+ "slice of the user's Conductor chat and decide whether it reveals a REUSABLE workflow or",
2723
+ "body of knowledge worth capturing as a Claude Code SKILL (a repeatable procedure the user",
2724
+ "invokes) or SUB-AGENT (a specialist for one bounded domain).",
2725
+ "",
2726
+ "Be conservative: most chats are one-offs and should yield NOTHING. Only suggest when the",
2727
+ "same kind of task would plausibly recur. Never suggest something that overlaps an artifact",
2728
+ "that already exists below — neither a near-duplicate name nor the same purpose.",
2729
+ "",
2730
+ `Artifacts that already exist (do NOT duplicate):
2731
+ ${JSON.stringify(existing, null, 2)}`,
2732
+ "",
2733
+ "Recent Conductor conversation (oldest first):",
2734
+ convo,
2735
+ "",
2736
+ "Respond with ONLY one JSON object — no markdown fences, no prose:",
2737
+ '{"suggest": <true|false>,',
2738
+ ' "items": [{"kind":"skill|agent",',
2739
+ ' "name":"<kebab-case-slug>",',
2740
+ ' "title":"<short human title>",',
2741
+ ' "description":"<one line: when to use it>",',
2742
+ ' "rationale":"<why this conversation shows it would recur>",',
2743
+ ' "confidence": <0..1>}]}',
2744
+ 'Return {"suggest": false, "items": []} when nothing is worth capturing.'
2745
+ ].join("\n");
2746
+ }
2747
+ /** Existing skills/agents (registered + on-disk), for judge prompts and dedupe. */
2748
+ existingNamesSnapshot() {
2749
+ return [
2750
+ ...this.state.artifacts.map((a) => ({ kind: a.kind, name: a.name, description: a.description })),
2751
+ ...scanSkills().map((s) => ({ kind: "skill", name: s.name, description: s.description ?? "" })),
2752
+ ...scanAgents().map((a) => ({ kind: "agent", name: a.name, description: a.description ?? "" }))
2753
+ ];
2754
+ }
2755
+ parseJudge(out) {
2756
+ const parsed = extractJson(out);
2757
+ if (!parsed || parsed.suggest === false) return [];
2758
+ const raw = Array.isArray(parsed.items) ? parsed.items : [];
2759
+ const items = [];
2760
+ for (const r of raw) {
2761
+ if (!r || typeof r !== "object") continue;
2762
+ const o = r;
2763
+ const kind = o.kind === "agent" ? "agent" : "skill";
2764
+ const name = slugify(String(o.name ?? ""));
2765
+ const description = String(o.description ?? "").trim();
2766
+ if (!name || !description) continue;
2767
+ let confidence = Number(o.confidence);
2768
+ if (!Number.isFinite(confidence)) confidence = 0;
2769
+ items.push({
2770
+ kind,
2771
+ name,
2772
+ title: String(o.title ?? description).trim() || description,
2773
+ description,
2774
+ rationale: String(o.rationale ?? "").trim(),
2775
+ confidence: Math.max(0, Math.min(1, confidence))
2776
+ });
2777
+ }
2778
+ return items;
2779
+ }
2780
+ absorbChatSuggestions(items, sourceMsgId, context) {
2781
+ let newest = null;
2782
+ let added = 0;
2783
+ for (const it of items) {
2784
+ if (added >= MAX_SUGGESTIONS_PER_DETECT) break;
2785
+ if (it.confidence < MIN_CONFIDENCE) continue;
2786
+ if (this.suggestionDuplicate(it.kind, it.name, it.title)) continue;
2787
+ newest = this.enqueueSuggestion({
2788
+ suggestedKind: it.kind,
2789
+ name: it.name,
2790
+ title: it.title,
2791
+ description: it.description,
2792
+ rationale: it.rationale,
2793
+ origin: "chat",
2794
+ sourceRef: sourceMsgId,
2795
+ sourceLabel: "Maestro chat",
2796
+ source: null,
2797
+ context,
2798
+ topics: [],
2799
+ keywords: [],
2800
+ existing: null,
2801
+ confidence: it.confidence
2802
+ });
2803
+ added++;
2804
+ }
2805
+ if (added > 0) {
2806
+ this.persist();
2807
+ if (newest) this.getWin()?.webContents.send("factory:suggestion", newest);
2808
+ }
2809
+ }
2810
+ // ---------- self-growth: suggestion queue ----------
2811
+ enqueueSuggestion(input) {
2812
+ const now = Date.now();
2813
+ const s = { id: crypto.randomUUID(), status: "open", createdAt: now, updatedAt: now, ...input };
2814
+ this.state.suggestions.push(s);
2815
+ this.capSuggestions();
2816
+ return s;
2817
+ }
2818
+ /**
2819
+ * Keep the queue bounded by pruning ONLY the oldest terminal (created/dismissed)
2820
+ * entries. Actionable suggestions (open/creating/error) are never dropped — if
2821
+ * those alone exceed the cap we let the queue run over rather than silently
2822
+ * losing work the user still has to act on.
2823
+ */
2824
+ capSuggestions() {
2825
+ if (this.state.suggestions.length <= MAX_SUGGESTIONS) return;
2826
+ const terminalOldestFirst = this.state.suggestions.filter((s) => s.status === "created" || s.status === "dismissed").sort((a, b) => a.updatedAt - b.updatedAt);
2827
+ const drop = /* @__PURE__ */ new Set();
2828
+ for (const s of terminalOldestFirst) {
2829
+ if (this.state.suggestions.length - drop.size <= MAX_SUGGESTIONS) break;
2830
+ drop.add(s.id);
2831
+ }
2832
+ if (drop.size > 0) {
2833
+ this.state.suggestions = this.state.suggestions.filter((x) => !drop.has(x.id));
2834
+ }
2835
+ }
2836
+ /**
2837
+ * Deterministic, token-free dedupe: is this idea already installed/registered,
2838
+ * or already in the open queue, or semantically the same as one of those?
2839
+ */
2840
+ suggestionDuplicate(kind, slug, title) {
2841
+ const key = `${kind}:${slug}`;
2842
+ const installed = /* @__PURE__ */ new Set([
2843
+ ...this.state.artifacts.map((a) => `${a.kind}:${a.name}`),
2844
+ ...listInstalled().map((i) => `${i.kind}:${i.name}`)
2845
+ ]);
2846
+ if (installed.has(key)) return "artifact";
2847
+ const words = (t2) => new Set(t2.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean));
2848
+ const jaccard = (a, b) => {
2849
+ if (a.size === 0 || b.size === 0) return 0;
2850
+ let inter = 0;
2851
+ for (const w of a) if (b.has(w)) inter++;
2852
+ return inter / (a.size + b.size - inter);
2853
+ };
2854
+ const t = words(title);
2855
+ for (const s of this.state.suggestions) {
2856
+ if (s.status !== "open" && s.status !== "creating") continue;
2857
+ if (s.suggestedKind === kind && s.name === slug) return "suggestion";
2858
+ if (jaccard(t, words(s.title)) >= 0.6) return "suggestion";
2859
+ }
2860
+ for (const a of this.state.artifacts) {
2861
+ if (jaccard(t, words(`${a.name} ${a.description}`)) >= 0.6) return "artifact";
2862
+ }
2863
+ return null;
2864
+ }
2865
+ /** Author + write + register the artifact for a suggestion (the only way one is built). */
2866
+ async createFromSuggestion(id, kind) {
2867
+ const s = this.state.suggestions.find((x) => x.id === id);
2868
+ if (!s || s.status !== "open" && s.status !== "error") return;
2869
+ if (this.busy) return;
2870
+ const useKind = kind ?? s.suggestedKind;
2871
+ this.setBusy(true);
2872
+ this.cancelRequested = false;
2873
+ s.status = "creating";
2874
+ s.suggestedKind = useKind;
2875
+ s.result = void 0;
2876
+ s.updatedAt = Date.now();
2877
+ this.persist();
2878
+ try {
2879
+ await this.discovering?.catch(() => {
2880
+ });
2881
+ const slug = slugify(s.name);
2882
+ let authored;
2883
+ if (s.origin === "scan") {
2884
+ const sources = await this.discoverCache().catch(() => []);
2885
+ const source = sources.find((x) => x.server === s.source) ?? { server: s.source ?? "mcp", label: s.sourceLabel, toolPrefix: `mcp__${s.source ?? ""}`, readTools: [] };
2886
+ const candidate = {
2887
+ id: s.id,
2888
+ kind: useKind,
2889
+ name: slug,
2890
+ description: s.description,
2891
+ topics: s.topics,
2892
+ keywords: s.keywords,
2893
+ rationale: s.rationale,
2894
+ existing: s.existing,
2895
+ status: "authoring"
2896
+ };
2897
+ const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2898
+ const out = await runHeadlessClaude({
2899
+ cwd: process.cwd(),
2900
+ prompt: this.authorPrompt(source, candidate, slug),
2901
+ allowedTools,
2902
+ timeoutMs: AUTHOR_TIMEOUT_MS,
2903
+ onSpawn: (child) => this.inFlight = child
2904
+ });
2905
+ authored = this.parseAuthor(out);
2906
+ } else {
2907
+ const out = await runHeadlessClaude({
2908
+ cwd: process.cwd(),
2909
+ prompt: this.conversationAuthorPrompt(s, useKind, slug),
2910
+ allowedTools: ["Read", "Grep", "Glob"],
2911
+ timeoutMs: AUTHOR_TIMEOUT_MS,
2912
+ onSpawn: (child) => this.inFlight = child
2913
+ });
2914
+ authored = this.parseAuthor(out);
2915
+ }
2916
+ if (!authored) throw new Error("The author agent did not return usable file content.");
2917
+ const filePath = useKind === "skill" ? writeSkill(slug, authored.content) : writeAgent(slug, authored.content);
2918
+ this.registerArtifact({
2919
+ kind: useKind,
2920
+ name: slug,
2921
+ filePath,
2922
+ description: authored.description || s.description,
2923
+ topics: authored.topics.length ? authored.topics : s.topics,
2924
+ keywords: authored.keywords.length ? authored.keywords : s.keywords,
2925
+ source: s.origin === "scan" ? s.source ?? "scan" : "conversation",
2926
+ related: authored.related
2927
+ });
2928
+ const artifact = this.state.artifacts.find((a) => a.kind === useKind && a.name === slug);
2929
+ s.status = "created";
2930
+ s.artifactId = artifact?.id;
2931
+ s.filePath = filePath;
2932
+ s.result = `Wrote ${useKind} to ${filePath}`;
2933
+ } catch (err) {
2934
+ if (this.cancelRequested) {
2935
+ s.status = "open";
2936
+ s.result = void 0;
2937
+ } else {
2938
+ s.status = "error";
2939
+ s.result = err.message || String(err);
2940
+ }
2941
+ } finally {
2942
+ s.updatedAt = Date.now();
2943
+ this.inFlight = null;
2944
+ this.setBusy(false);
2945
+ this.persist();
2946
+ }
2947
+ }
2948
+ /** Dismiss a suggestion without building it (kept as history; may resurface later). */
2949
+ dismissSuggestion(id) {
2950
+ const s = this.state.suggestions.find((x) => x.id === id);
2951
+ if (!s || s.status !== "open" && s.status !== "error") return;
2952
+ s.status = "dismissed";
2953
+ s.updatedAt = Date.now();
2954
+ this.persist();
2955
+ }
2956
+ conversationAuthorPrompt(s, kind, slug) {
2957
+ const related = this.state.artifacts.map((a) => ({ name: a.name, kind: a.kind, description: a.description }));
2958
+ const lessons = this.state.lessons.map((l) => l.text);
2959
+ const isSkill = kind === "skill";
2960
+ return [
2961
+ `You are authoring a Claude Code ${isSkill ? "SKILL" : "SUB-AGENT"} that captures a reusable`,
2962
+ "workflow the user demonstrated in a Maestro Conductor conversation. You run unattended.",
2963
+ "",
2964
+ `Target artifact:
2965
+ ${JSON.stringify({ kind, name: slug, description: s.description, topics: s.topics, rationale: s.rationale }, null, 2)}`,
2966
+ "",
2967
+ "The conversation that motivated this artifact — capture the GENERAL, reusable procedure it",
2968
+ "shows; strip one-off specifics (particular file names, ids, values) and keep the repeatable",
2969
+ "method:",
2970
+ s.context || "(no excerpt was captured; rely on the target description above)",
2971
+ "",
2972
+ "Write the COMPLETE file content as Markdown with a YAML frontmatter block.",
2973
+ isSkill ? [
2974
+ "For a SKILL the file is SKILL.md. Frontmatter MUST be exactly:",
2975
+ "---",
2976
+ `name: ${slug}`,
2977
+ "description: <one line describing WHEN to use this skill>",
2978
+ "---",
2979
+ "Then the body: a focused, step-by-step procedure the agent can follow."
2980
+ ].join("\n") : [
2981
+ "For a SUB-AGENT the file is an agent definition. Frontmatter MUST be exactly:",
2982
+ "---",
2983
+ `name: ${slug}`,
2984
+ "description: <one line: when to route to this agent — be specific with trigger terms>",
2985
+ "model: claude-sonnet-4-6",
2986
+ "---",
2987
+ "Then the body: the agent's system prompt — its scope, what it knows, and what it does NOT cover."
2988
+ ].join("\n"),
2989
+ "",
2990
+ `Other artifacts that exist (name any genuinely related):
2991
+ ${JSON.stringify(related, null, 2)}`,
2992
+ lessons.length ? `
2993
+ Lessons learned (respect these):
2994
+ - ${lessons.join("\n- ")}` : "",
2995
+ "",
2996
+ "Respond with ONLY one JSON object — no markdown fences around the whole object, no prose:",
2997
+ '{"content":"<the FULL file content, frontmatter + body, as a single string>",',
2998
+ ' "description":"<final one-line description>",',
2999
+ ' "topics":["..."], "keywords":["..."],',
3000
+ ' "related":["<names of related existing artifacts>"]}'
3001
+ ].filter((l) => l !== "").join("\n");
3002
+ }
2204
3003
  /** Cancel the in-flight scan/author agent, if any (the run reports 'cancelled'). */
2205
3004
  cancel() {
2206
3005
  if (!this.inFlight) return;
@@ -2221,24 +3020,48 @@ class FactoryService {
2221
3020
  * connectors are not in ~/.claude.json, so a no-tool headless agent reports
2222
3021
  * what it can see; we merge that with the user-scope servers from
2223
3022
  * ~/.claude.json. Cached for the app run; `refresh` forces a re-discovery.
3023
+ *
3024
+ * PUBLIC entry point (IPC / renderer refresh / auto-propose pre-scan): if a
3025
+ * heavy op holds the lock, WAIT for it before starting a discovery agent, so
3026
+ * we never run two headless `claude -p` children at once. Lock-holders
3027
+ * (scan/approve/createFromSuggestion) must call discoverCache() instead — they
3028
+ * already hold the lock and would otherwise wait on themselves (deadlock).
2224
3029
  */
2225
3030
  async listSources(refresh = false) {
2226
3031
  if (this.sources && !refresh) return this.sources;
2227
- const discovered = await this.discoverSources().catch(() => []);
2228
- const byKey = /* @__PURE__ */ new Map();
2229
- for (const s of discovered) byKey.set(s.server, s);
2230
- for (const m of readUserMcpServers()) {
2231
- if (!byKey.has(m.name)) {
2232
- byKey.set(m.name, {
2233
- server: m.name,
2234
- label: KNOWN_LABELS[m.name] ?? m.name,
2235
- toolPrefix: `mcp__${m.name}`,
2236
- readTools: []
2237
- });
3032
+ while (this.busy && this.busyPromise) await this.busyPromise;
3033
+ return this.discoverCache(refresh);
3034
+ }
3035
+ /**
3036
+ * The actual cached/memoized discovery, with NO busy-wait. Safe to call from a
3037
+ * lock-holder because discovery runs before that op spawns its own child, so
3038
+ * only one headless child is ever alive. Concurrent callers join one discovery.
3039
+ */
3040
+ discoverCache(refresh = false) {
3041
+ if (this.sources && !refresh) return Promise.resolve(this.sources);
3042
+ if (this.discovering) return this.discovering;
3043
+ this.discovering = (async () => {
3044
+ try {
3045
+ const discovered = await this.discoverSources().catch(() => []);
3046
+ const byKey = /* @__PURE__ */ new Map();
3047
+ for (const s of discovered) byKey.set(s.server, s);
3048
+ for (const m of readUserMcpServers()) {
3049
+ if (!byKey.has(m.name)) {
3050
+ byKey.set(m.name, {
3051
+ server: m.name,
3052
+ label: KNOWN_LABELS[m.name] ?? m.name,
3053
+ toolPrefix: `mcp__${m.name}`,
3054
+ readTools: []
3055
+ });
3056
+ }
3057
+ }
3058
+ this.sources = [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label));
3059
+ return this.sources;
3060
+ } finally {
3061
+ this.discovering = null;
2238
3062
  }
2239
- }
2240
- this.sources = [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label));
2241
- return this.sources;
3063
+ })();
3064
+ return this.discovering;
2242
3065
  }
2243
3066
  async discoverSources() {
2244
3067
  const prompt = [
@@ -2265,8 +3088,8 @@ class FactoryService {
2265
3088
  prompt,
2266
3089
  allowedTools: ["Read"],
2267
3090
  timeoutMs: DISCOVER_TIMEOUT_MS,
2268
- onSpawn: (child) => this.inFlight = child
2269
- }).finally(() => this.inFlight = null);
3091
+ onSpawn: (child) => this.discoverChild = child
3092
+ }).finally(() => this.discoverChild = null);
2270
3093
  const parsed = extractJson(out);
2271
3094
  const list = Array.isArray(parsed?.servers) ? parsed.servers : [];
2272
3095
  const sources = [];
@@ -2286,30 +3109,37 @@ class FactoryService {
2286
3109
  return sources;
2287
3110
  }
2288
3111
  // ---------- scan (phase 1) ----------
2289
- /** Explore a source and propose skill/agent candidates. */
3112
+ /**
3113
+ * Explore a source and propose skill/agent candidates. Claims the single
3114
+ * `busy` lock SYNCHRONOUSLY (before any await) so two headless agents can
3115
+ * never run at once. Returns the created run's id, or null if it bailed on
3116
+ * the busy guard (no run, no tokens spent) — callers (auto-propose) use this
3117
+ * to harvest exactly this run and to only consume budget when a scan ran.
3118
+ * Throws (releasing the lock) only for an unknown source / discovery failure.
3119
+ */
2290
3120
  async scan(serverKey, guidance) {
2291
- if (this.busy) return;
2292
- const sources = await this.listSources();
2293
- const source = sources.find((s) => s.server === serverKey);
2294
- if (!source) throw new Error(`Unknown source: ${serverKey}`);
2295
- this.busy = true;
3121
+ if (this.busy) return null;
3122
+ this.setBusy(true);
2296
3123
  this.cancelRequested = false;
2297
- const run = {
2298
- id: crypto.randomUUID(),
2299
- source: source.server,
2300
- sourceLabel: source.label,
2301
- guidance: guidance.trim(),
2302
- startedAt: Date.now(),
2303
- finishedAt: null,
2304
- status: "running",
2305
- phase: "discovering",
2306
- candidates: [],
2307
- summary: ""
2308
- };
2309
- this.pushRun(run);
3124
+ let run = null;
2310
3125
  try {
3126
+ const sources = await this.discoverCache();
3127
+ const source = sources.find((s) => s.server === serverKey);
3128
+ if (!source) throw new Error(`Unknown source: ${serverKey}`);
3129
+ run = {
3130
+ id: crypto.randomUUID(),
3131
+ source: source.server,
3132
+ sourceLabel: source.label,
3133
+ guidance: guidance.trim(),
3134
+ startedAt: Date.now(),
3135
+ finishedAt: null,
3136
+ status: "running",
3137
+ phase: "discovering",
3138
+ candidates: [],
3139
+ summary: ""
3140
+ };
3141
+ this.pushRun(run);
2311
3142
  const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2312
- run.phase = "discovering";
2313
3143
  this.broadcastRuns();
2314
3144
  const out = await runHeadlessClaude({
2315
3145
  cwd: process.cwd(),
@@ -2325,15 +3155,24 @@ class FactoryService {
2325
3155
  run.status = "done";
2326
3156
  this.absorbTopics(parsed.newTopics, source.server);
2327
3157
  } catch (err) {
2328
- run.status = this.cancelRequested ? "cancelled" : "error";
2329
- run.phase = "done";
2330
- run.summary = this.cancelRequested ? "Cancelled." : err.message || String(err);
3158
+ if (run) {
3159
+ run.status = this.cancelRequested ? "cancelled" : "error";
3160
+ run.phase = "done";
3161
+ run.summary = this.cancelRequested ? "Cancelled." : err.message || String(err);
3162
+ } else {
3163
+ this.inFlight = null;
3164
+ this.setBusy(false);
3165
+ throw err;
3166
+ }
2331
3167
  } finally {
2332
- run.finishedAt = Date.now();
2333
- this.inFlight = null;
2334
- this.busy = false;
2335
- this.broadcastRuns();
3168
+ if (run) {
3169
+ run.finishedAt = Date.now();
3170
+ this.inFlight = null;
3171
+ this.setBusy(false);
3172
+ this.broadcastRuns();
3173
+ }
2336
3174
  }
3175
+ return run ? run.id : null;
2337
3176
  }
2338
3177
  scanPrompt(source, guidance) {
2339
3178
  const existing = [
@@ -2429,16 +3268,16 @@ Lessons learned (respect these):
2429
3268
  /** Approve a candidate: author its file content and write it to ~/.claude. An errored candidate can be retried. */
2430
3269
  async approve(runId, candidateId) {
2431
3270
  const run = this.runs.find((r) => r.id === runId);
2432
- const candidate = run?.candidates.find((c) => c.id === candidateId);
3271
+ const candidate = run?.candidates.find((c2) => c2.id === candidateId);
2433
3272
  if (!run || !candidate || candidate.status !== "proposed" && candidate.status !== "error") return;
2434
3273
  if (this.busy) return;
2435
- this.busy = true;
3274
+ this.setBusy(true);
2436
3275
  this.cancelRequested = false;
2437
3276
  candidate.status = "authoring";
2438
3277
  candidate.result = void 0;
2439
3278
  this.broadcastRuns();
2440
3279
  try {
2441
- const source = (await this.listSources()).find((s) => s.server === run.source) ?? { server: run.source, label: run.sourceLabel, toolPrefix: `mcp__${run.source}`, readTools: [] };
3280
+ const source = (await this.discoverCache()).find((s) => s.server === run.source) ?? { server: run.source, label: run.sourceLabel, toolPrefix: `mcp__${run.source}`, readTools: [] };
2442
3281
  const allowedTools = ["Read", "Grep", "Glob", source.toolPrefix, ...source.readTools];
2443
3282
  const slug = slugify(candidate.name);
2444
3283
  const out = await runHeadlessClaude({
@@ -2474,7 +3313,7 @@ Lessons learned (respect these):
2474
3313
  }
2475
3314
  } finally {
2476
3315
  this.inFlight = null;
2477
- this.busy = false;
3316
+ this.setBusy(false);
2478
3317
  this.broadcastRuns();
2479
3318
  this.broadcastState();
2480
3319
  }
@@ -2483,14 +3322,14 @@ Lessons learned (respect these):
2483
3322
  async approveAll(runId) {
2484
3323
  const run = this.runs.find((r) => r.id === runId);
2485
3324
  if (!run) return;
2486
- for (const c of run.candidates) {
2487
- if (c.status === "proposed") await this.approve(runId, c.id);
3325
+ for (const c2 of run.candidates) {
3326
+ if (c2.status === "proposed") await this.approve(runId, c2.id);
2488
3327
  if (this.cancelRequested) break;
2489
3328
  }
2490
3329
  }
2491
3330
  reject(runId, candidateId) {
2492
3331
  const run = this.runs.find((r) => r.id === runId);
2493
- const candidate = run?.candidates.find((c) => c.id === candidateId);
3332
+ const candidate = run?.candidates.find((c2) => c2.id === candidateId);
2494
3333
  if (!candidate || candidate.status !== "proposed") return;
2495
3334
  candidate.status = "rejected";
2496
3335
  this.broadcastRuns();
@@ -2590,6 +3429,10 @@ Lessons learned (respect these):
2590
3429
  createdAt: now,
2591
3430
  updatedAt: now
2592
3431
  });
3432
+ this.emitGame({
3433
+ type: input.kind === "agent" ? "factory.agent" : "factory.skill",
3434
+ meta: { name: input.name }
3435
+ });
2593
3436
  }
2594
3437
  for (const a of this.state.artifacts) {
2595
3438
  if (related.includes(a.name) && !a.relatedArtifacts.includes(input.name)) {
@@ -2722,6 +3565,28 @@ Lessons learned (respect these):
2722
3565
  broadcastState() {
2723
3566
  this.getWin()?.webContents.send("factory:changed", this.state);
2724
3567
  }
3568
+ /**
3569
+ * Single source of truth for the headless lock. Setting it broadcasts so the
3570
+ * renderer can reflect background work (judge / author / scan) that doesn't
3571
+ * create a visible FactoryRun — preventing buttons that would silently no-op
3572
+ * against the lock from staying enabled.
3573
+ */
3574
+ setBusy(v) {
3575
+ if (this.busy === v) return;
3576
+ this.busy = v;
3577
+ if (v) {
3578
+ this.busyPromise = new Promise((resolve) => this.busyResolve = resolve);
3579
+ } else {
3580
+ this.busyResolve?.();
3581
+ this.busyResolve = null;
3582
+ this.busyPromise = null;
3583
+ }
3584
+ this.getWin()?.webContents.send("factory:busy", v);
3585
+ }
3586
+ /** Current headless-lock state (for the initial renderer fetch). */
3587
+ isBusy() {
3588
+ return this.busy;
3589
+ }
2725
3590
  broadcastRuns() {
2726
3591
  this.store.setRuns(this.runs);
2727
3592
  this.getWin()?.webContents.send("factory:runs", this.runs);
@@ -2735,10 +3600,10 @@ function restoreRuns(runs) {
2735
3600
  run.finishedAt = run.finishedAt ?? Date.now();
2736
3601
  run.summary = run.summary || "Interrupted — the app was closed mid-scan.";
2737
3602
  }
2738
- for (const c of run.candidates) {
2739
- if (c.status === "authoring") {
2740
- c.status = "proposed";
2741
- c.result = void 0;
3603
+ for (const c2 of run.candidates) {
3604
+ if (c2.status === "authoring") {
3605
+ c2.status = "proposed";
3606
+ c2.result = void 0;
2742
3607
  }
2743
3608
  }
2744
3609
  }
@@ -2771,9 +3636,11 @@ Feature: ${feature.title}
2771
3636
  Read that spec file in full, then implement every spec it lists. Make your changes in this worktree and commit them as each part works. If any spec is ambiguous, ask before guessing.`;
2772
3637
  }
2773
3638
  class FeatureService {
2774
- constructor(persistence2, sessions) {
3639
+ constructor(persistence2, sessions, emitGame = () => {
3640
+ }) {
2775
3641
  this.persistence = persistence2;
2776
3642
  this.sessions = sessions;
3643
+ this.emitGame = emitGame;
2777
3644
  }
2778
3645
  get features() {
2779
3646
  return this.persistence.state.features;
@@ -2794,8 +3661,12 @@ class FeatureService {
2794
3661
  save(feature) {
2795
3662
  const list = this.features;
2796
3663
  const idx = list.findIndex((f) => f.id === feature.id);
2797
- if (idx >= 0) list[idx] = feature;
2798
- else list.push(feature);
3664
+ if (idx >= 0) {
3665
+ list[idx] = feature;
3666
+ } else {
3667
+ list.push(feature);
3668
+ this.emitGame({ type: "feature.save" });
3669
+ }
2799
3670
  this.persistence.scheduleSave();
2800
3671
  }
2801
3672
  delete(id) {
@@ -2987,6 +3858,310 @@ class FsService {
2987
3858
  }
2988
3859
  }
2989
3860
  }
3861
+ const XP_TABLE = {
3862
+ "session.turn": 5,
3863
+ "conductor.turn": 5,
3864
+ "action.run": 8,
3865
+ "checkpoint.create": 10,
3866
+ "sentinel.run": 12,
3867
+ "session.create": 20,
3868
+ "worktree.create": 25,
3869
+ "feature.save": 30,
3870
+ "autoexpand.done": 40,
3871
+ "worktree.pr": 50,
3872
+ "worktree.merge": 60,
3873
+ "factory.skill": 75,
3874
+ "factory.agent": 75,
3875
+ "feature.merge": 80
3876
+ };
3877
+ function xpForEvent(e) {
3878
+ let xp = XP_TABLE[e.type] ?? 0;
3879
+ if (e.type === "worktree.merge") xp += Math.min(e.meta?.commits ?? 0, 10) * 3;
3880
+ return xp;
3881
+ }
3882
+ function xpForLevel(n) {
3883
+ if (n <= 1) return 0;
3884
+ const k = n - 1;
3885
+ return 50 * k * k + 50 * k;
3886
+ }
3887
+ function levelForXp(xp) {
3888
+ if (xp <= 0) return 1;
3889
+ return Math.max(1, Math.floor((-50 + Math.sqrt(2500 + 200 * xp)) / 100) + 1);
3890
+ }
3891
+ function levelInfo(xp) {
3892
+ const level = levelForXp(xp);
3893
+ const floor = xpForLevel(level);
3894
+ const next = xpForLevel(level + 1);
3895
+ return { level, xpIntoLevel: xp - floor, xpForNextLevel: next - floor };
3896
+ }
3897
+ const DEFAULT_GAME_STATE = {
3898
+ xp: 0,
3899
+ streak: { current: 0, longest: 0, lastDay: "" },
3900
+ achievements: {},
3901
+ todaysQuests: [],
3902
+ questDay: "",
3903
+ counters: {},
3904
+ nightTurns: 0,
3905
+ earlyTurns: 0,
3906
+ createdAt: 0
3907
+ };
3908
+ const c = (ctx, t) => ctx.counters[t] ?? 0;
3909
+ const ACHIEVEMENTS = [
3910
+ // sessions
3911
+ { id: "first-session", title: "Hello, Maestro", desc: "Start your first session.", icon: "🎬", category: "sessions", predicate: (x) => c(x, "session.create") >= 1 },
3912
+ { id: "ten-sessions", title: "Regular", desc: "Start 10 sessions.", icon: "📁", category: "sessions", predicate: (x) => c(x, "session.create") >= 10 },
3913
+ { id: "worktree-novice", title: "Branching Out", desc: "Create your first parallel task.", icon: "🌱", category: "sessions", predicate: (x) => c(x, "worktree.create") >= 1 },
3914
+ { id: "worktree-adept", title: "Multitasker", desc: "Create 10 parallel tasks.", icon: "🌳", category: "sessions", predicate: (x) => c(x, "worktree.create") >= 10 },
3915
+ // merges
3916
+ { id: "first-merge", title: "Merge One", desc: "Merge your first worktree.", icon: "🔀", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 1 },
3917
+ { id: "ten-merges", title: "Merge Maestro", desc: "Merge 10 worktrees.", icon: "🧬", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 10 },
3918
+ { id: "fifty-merges", title: "Merge Machine", desc: "Merge 50 worktrees.", icon: "⚙️", category: "merges", predicate: (x) => c(x, "worktree.merge") >= 50 },
3919
+ { id: "pr-opener", title: "Pull Request", desc: "Open your first PR.", icon: "📤", category: "merges", predicate: (x) => c(x, "worktree.pr") >= 1 },
3920
+ { id: "pr-prolific", title: "PR Prolific", desc: "Open 10 PRs.", icon: "🚀", category: "merges", predicate: (x) => c(x, "worktree.pr") >= 10 },
3921
+ { id: "feature-shipper", title: "Ship It", desc: "Merge a feature you specced.", icon: "📦", category: "merges", predicate: (x) => c(x, "feature.merge") >= 1 },
3922
+ // turns
3923
+ { id: "first-turn", title: "First Light", desc: "Finish your first Claude turn.", icon: "✶", category: "turns", predicate: (x) => c(x, "session.turn") >= 1 },
3924
+ { id: "hundred-turns", title: "Century", desc: "Finish 100 turns.", icon: "💯", category: "turns", predicate: (x) => c(x, "session.turn") >= 100 },
3925
+ { id: "thousand-turns", title: "Marathoner", desc: "Finish 1,000 turns.", icon: "🏃", category: "turns", predicate: (x) => c(x, "session.turn") >= 1e3 },
3926
+ { id: "conductor-curious", title: "Conductor Curious", desc: "Have your first Conductor turn.", icon: "✦", category: "turns", predicate: (x) => c(x, "conductor.turn") >= 1 },
3927
+ { id: "conductor-regular", title: "Maestro of Maestro", desc: "Have 50 Conductor turns.", icon: "🎼", category: "turns", predicate: (x) => c(x, "conductor.turn") >= 50 },
3928
+ // factory
3929
+ { id: "first-skill", title: "Skill Smith", desc: "Create your first skill.", icon: "🛠", category: "factory", predicate: (x) => c(x, "factory.skill") >= 1 },
3930
+ { id: "first-agent", title: "Agent Architect", desc: "Create your first agent.", icon: "🤖", category: "factory", predicate: (x) => c(x, "factory.agent") >= 1 },
3931
+ { id: "toolsmith", title: "Toolsmith", desc: "Create 5 skills/agents.", icon: "⚒", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 5 },
3932
+ { id: "master-toolsmith", title: "Master Toolsmith", desc: "Create 20 skills/agents.", icon: "🏭", category: "factory", predicate: (x) => c(x, "factory.skill") + c(x, "factory.agent") >= 20 },
3933
+ // streak
3934
+ { id: "streak-3", title: "Warmed Up", desc: "Keep a 3-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 3 },
3935
+ { id: "streak-7", title: "On Fire", desc: "Keep a 7-day streak.", icon: "🔥", category: "streak", predicate: (x) => x.streakLongest >= 7 },
3936
+ { id: "streak-30", title: "Unstoppable", desc: "Keep a 30-day streak.", icon: "☄️", category: "streak", predicate: (x) => x.streakLongest >= 30 },
3937
+ // time of day
3938
+ { id: "night-owl", title: "Night Owl", desc: "Finish a turn between midnight and 5am.", icon: "🦉", category: "time", predicate: (x) => x.nightTurns >= 1 },
3939
+ { id: "early-bird", title: "Early Bird", desc: "Finish a turn between 5 and 8am.", icon: "🌅", category: "time", predicate: (x) => x.earlyTurns >= 1 },
3940
+ // level
3941
+ { id: "level-10", title: "Double Digits", desc: "Reach level 10.", icon: "⭐", category: "level", predicate: (x) => x.level >= 10 },
3942
+ { id: "level-25", title: "Maestro Prime", desc: "Reach level 25.", icon: "🌟", category: "level", predicate: (x) => x.level >= 25 }
3943
+ ];
3944
+ const DAILY_QUEST_POOL = [
3945
+ { id: "q-turns-5", title: "Finish 5 Claude turns", target: 5, reward: 30, events: ["session.turn"] },
3946
+ { id: "q-merge-1", title: "Merge a worktree", target: 1, reward: 40, events: ["worktree.merge"] },
3947
+ { id: "q-merge-3", title: "Merge 3 worktrees", target: 3, reward: 100, events: ["worktree.merge"] },
3948
+ { id: "q-create-1", title: "Start a session", target: 1, reward: 20, events: ["session.create"] },
3949
+ { id: "q-worktree-2", title: "Spin up 2 parallel tasks", target: 2, reward: 50, events: ["worktree.create"] },
3950
+ { id: "q-checkpoint", title: "Make a checkpoint", target: 1, reward: 20, events: ["checkpoint.create"] },
3951
+ { id: "q-conductor-3", title: "Have 3 Conductor turns", target: 3, reward: 30, events: ["conductor.turn"] },
3952
+ { id: "q-factory-1", title: "Create a skill or agent", target: 1, reward: 75, events: ["factory.skill", "factory.agent"] },
3953
+ { id: "q-feature-1", title: "Save a feature spec", target: 1, reward: 30, events: ["feature.save"] },
3954
+ { id: "q-pr-1", title: "Open a pull request", target: 1, reward: 50, events: ["worktree.pr"] }
3955
+ ];
3956
+ const questDef = (id) => DAILY_QUEST_POOL.find((q) => q.id === id);
3957
+ function hashStr(s) {
3958
+ let h = 2166136261;
3959
+ for (let i = 0; i < s.length; i++) {
3960
+ h ^= s.charCodeAt(i);
3961
+ h = Math.imul(h, 16777619);
3962
+ }
3963
+ return h >>> 0;
3964
+ }
3965
+ function mulberry32(seed) {
3966
+ let a = seed >>> 0;
3967
+ return () => {
3968
+ a = a + 1831565813 | 0;
3969
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
3970
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
3971
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
3972
+ };
3973
+ }
3974
+ function pickDailyQuests(dayKey2, n = 3) {
3975
+ const rnd = mulberry32(hashStr("quest:" + dayKey2));
3976
+ const pool = [...DAILY_QUEST_POOL];
3977
+ for (let i = pool.length - 1; i > 0; i--) {
3978
+ const j = Math.floor(rnd() * (i + 1));
3979
+ [pool[i], pool[j]] = [pool[j], pool[i]];
3980
+ }
3981
+ return pool.slice(0, Math.min(n, pool.length)).map((q) => ({
3982
+ id: q.id,
3983
+ target: q.target,
3984
+ progress: 0,
3985
+ rewarded: false
3986
+ }));
3987
+ }
3988
+ function dayKey(d) {
3989
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
3990
+ }
3991
+ function prevDayKey(d) {
3992
+ const y = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1);
3993
+ return dayKey(y);
3994
+ }
3995
+ class GamificationStore {
3996
+ file = path.join(electron.app.getPath("userData"), "gamification.json");
3997
+ timer = null;
3998
+ state = structuredClone(DEFAULT_GAME_STATE);
3999
+ /** Load saved progress (best-effort; defaults on any error, back-compat via spread). */
4000
+ load() {
4001
+ try {
4002
+ const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
4003
+ const s = { ...structuredClone(DEFAULT_GAME_STATE), ...raw ?? {} };
4004
+ const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : Number.isFinite(Number(v)) ? Number(v) : fallback;
4005
+ if (!s.streak || typeof s.streak !== "object") s.streak = { current: 0, longest: 0, lastDay: "" };
4006
+ s.streak.current = num(s.streak.current, 0);
4007
+ s.streak.longest = num(s.streak.longest, 0);
4008
+ if (typeof s.streak.lastDay !== "string") s.streak.lastDay = "";
4009
+ if (!s.achievements || typeof s.achievements !== "object" || Array.isArray(s.achievements)) {
4010
+ s.achievements = {};
4011
+ }
4012
+ if (!Array.isArray(s.todaysQuests)) s.todaysQuests = [];
4013
+ s.todaysQuests = s.todaysQuests.filter((q) => q && typeof q.id === "string").map((q) => ({ id: q.id, target: num(q.target, 1), progress: num(q.progress, 0), rewarded: !!q.rewarded }));
4014
+ if (!s.counters || typeof s.counters !== "object" || Array.isArray(s.counters)) s.counters = {};
4015
+ for (const k of Object.keys(s.counters)) {
4016
+ s.counters[k] = num(s.counters[k], 0);
4017
+ }
4018
+ s.nightTurns = num(s.nightTurns, 0);
4019
+ s.earlyTurns = num(s.earlyTurns, 0);
4020
+ s.xp = num(s.xp, 0);
4021
+ if (!s.createdAt) s.createdAt = Date.now();
4022
+ this.state = s;
4023
+ } catch {
4024
+ this.state = structuredClone(DEFAULT_GAME_STATE);
4025
+ this.state.createdAt = Date.now();
4026
+ }
4027
+ return this.state;
4028
+ }
4029
+ get() {
4030
+ return this.state;
4031
+ }
4032
+ set(state) {
4033
+ this.state = state;
4034
+ this.scheduleSave();
4035
+ }
4036
+ scheduleSave() {
4037
+ if (this.timer) clearTimeout(this.timer);
4038
+ this.timer = setTimeout(() => this.saveNow(), 500);
4039
+ }
4040
+ saveNow() {
4041
+ if (this.timer) {
4042
+ clearTimeout(this.timer);
4043
+ this.timer = null;
4044
+ }
4045
+ try {
4046
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
4047
+ const tmp = this.file + ".tmp";
4048
+ fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), "utf8");
4049
+ fs.renameSync(tmp, this.file);
4050
+ } catch (err) {
4051
+ console.error("Failed to persist gamification state:", err);
4052
+ }
4053
+ }
4054
+ }
4055
+ const ACHIEVEMENT_XP = 25;
4056
+ class GamificationService {
4057
+ constructor(getWin2) {
4058
+ this.getWin = getWin2;
4059
+ this.state = this.store.load();
4060
+ }
4061
+ store = new GamificationStore();
4062
+ state;
4063
+ /** GameState + derived level fields (for the initial renderer fetch). */
4064
+ snapshot() {
4065
+ return { ...this.state, ...levelInfo(this.state.xp) };
4066
+ }
4067
+ dispose() {
4068
+ this.store.saveNow();
4069
+ }
4070
+ /**
4071
+ * Apply one event: bump counters, roll the day (streak + quests), add XP,
4072
+ * advance quests, unlock achievements — each guarded so nothing double-counts.
4073
+ * Wrapped so a gamification failure can never break the caller's turn/merge.
4074
+ */
4075
+ award(e) {
4076
+ try {
4077
+ const s = this.state;
4078
+ const now = /* @__PURE__ */ new Date();
4079
+ const today = dayKey(now);
4080
+ const celebrations = [];
4081
+ if (s.streak.lastDay !== today) {
4082
+ s.streak.current = s.streak.lastDay === prevDayKey(now) ? s.streak.current + 1 : 1;
4083
+ s.streak.longest = Math.max(s.streak.longest, s.streak.current);
4084
+ s.streak.lastDay = today;
4085
+ s.todaysQuests = pickDailyQuests(today);
4086
+ s.questDay = today;
4087
+ if (s.streak.current >= 2) {
4088
+ celebrations.push({
4089
+ kind: "streak",
4090
+ seed: `streak:${today}:${s.streak.current}`,
4091
+ current: s.streak.current
4092
+ });
4093
+ }
4094
+ }
4095
+ s.counters[e.type] = (s.counters[e.type] ?? 0) + 1;
4096
+ if (e.type === "session.turn" || e.type === "conductor.turn") {
4097
+ const hour = e.meta?.hour ?? now.getHours();
4098
+ if (hour >= 0 && hour < 5) s.nightTurns += 1;
4099
+ else if (hour >= 5 && hour < 8) s.earlyTurns += 1;
4100
+ }
4101
+ const beforeLevel = levelForXp(s.xp);
4102
+ s.xp += xpForEvent(e);
4103
+ for (const q of s.todaysQuests) {
4104
+ if (q.rewarded) continue;
4105
+ const def = questDef(q.id);
4106
+ if (!def || !def.events.includes(e.type)) continue;
4107
+ q.progress = Math.min(q.target, q.progress + 1);
4108
+ if (q.progress >= q.target) {
4109
+ q.rewarded = true;
4110
+ s.xp += def.reward;
4111
+ celebrations.push({
4112
+ kind: "quest",
4113
+ seed: `quest:${today}:${q.id}`,
4114
+ id: q.id,
4115
+ title: def.title,
4116
+ xp: def.reward
4117
+ });
4118
+ }
4119
+ }
4120
+ let unlockedThisPass = true;
4121
+ while (unlockedThisPass) {
4122
+ unlockedThisPass = false;
4123
+ const ctx = {
4124
+ counters: s.counters,
4125
+ nightTurns: s.nightTurns,
4126
+ earlyTurns: s.earlyTurns,
4127
+ streakLongest: s.streak.longest,
4128
+ level: levelForXp(s.xp)
4129
+ };
4130
+ for (const a of ACHIEVEMENTS) {
4131
+ if (s.achievements[a.id]) continue;
4132
+ if (a.predicate(ctx)) {
4133
+ s.achievements[a.id] = { unlockedAt: Date.now() };
4134
+ s.xp += ACHIEVEMENT_XP;
4135
+ unlockedThisPass = true;
4136
+ celebrations.push({
4137
+ kind: "achievement",
4138
+ seed: `ach:${a.id}`,
4139
+ id: a.id,
4140
+ title: a.title,
4141
+ icon: a.icon,
4142
+ xp: ACHIEVEMENT_XP
4143
+ });
4144
+ }
4145
+ }
4146
+ }
4147
+ const afterLevel = levelForXp(s.xp);
4148
+ for (let l = beforeLevel + 1; l <= afterLevel; l++) {
4149
+ celebrations.push({ kind: "level-up", seed: `level:${l}`, level: l });
4150
+ }
4151
+ this.store.set(s);
4152
+ this.broadcast();
4153
+ for (const c2 of celebrations) this.celebrate(c2);
4154
+ } catch (err) {
4155
+ console.error("Gamification award failed (ignored):", err);
4156
+ }
4157
+ }
4158
+ broadcast() {
4159
+ this.getWin()?.webContents.send("gamification:changed", this.snapshot());
4160
+ }
4161
+ celebrate(c2) {
4162
+ this.getWin()?.webContents.send("gamification:celebrate", c2);
4163
+ }
4164
+ }
2990
4165
  const IMAGE_EXTS$1 = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
2991
4166
  const THUMB_HEIGHT = 64;
2992
4167
  const MAX_LISTED = 100;
@@ -3401,7 +4576,7 @@ function tokenize(template) {
3401
4576
  while (m = re.exec(template)) tokens.push(m[1] ?? m[2]);
3402
4577
  return tokens;
3403
4578
  }
3404
- function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, tokenEff, getWin2) {
4579
+ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpand, conductor, factory, agents, tokenEff, gamification, getWin2) {
3405
4580
  const rootOf = (id) => {
3406
4581
  const config = sessions.getConfig(id);
3407
4582
  if (!config) throw new Error(`Unknown session: ${id}`);
@@ -3580,6 +4755,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3580
4755
  electron.ipcMain.handle("factory:listSources", (_e, refresh) => factory.listSources(refresh));
3581
4756
  electron.ipcMain.handle("factory:state", () => factory.getState());
3582
4757
  electron.ipcMain.handle("factory:runs", () => factory.listRuns());
4758
+ electron.ipcMain.handle("factory:isBusy", () => factory.isBusy());
3583
4759
  electron.ipcMain.handle(
3584
4760
  "factory:scan",
3585
4761
  (_e, serverKey, guidance) => factory.scan(serverKey, guidance)
@@ -3608,6 +4784,16 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3608
4784
  electron.ipcMain.handle("factory:dismissTopic", (_e, id) => factory.dismissTopic(id));
3609
4785
  electron.ipcMain.handle("factory:addLesson", (_e, text) => factory.addLesson(text));
3610
4786
  electron.ipcMain.handle("factory:deleteLesson", (_e, id) => factory.deleteLesson(id));
4787
+ electron.ipcMain.handle(
4788
+ "factory:createFromSuggestion",
4789
+ (_e, id, kind) => factory.createFromSuggestion(id, kind)
4790
+ );
4791
+ electron.ipcMain.handle("factory:dismissSuggestion", (_e, id) => factory.dismissSuggestion(id));
4792
+ electron.ipcMain.handle("gamification:get", () => gamification.snapshot());
4793
+ electron.ipcMain.handle("agents:get", () => agents.snapshot());
4794
+ electron.ipcMain.handle("agents:refresh", () => agents.refresh());
4795
+ electron.ipcMain.handle("agents:read", (_e, filePath) => agents.readAgentFile(filePath));
4796
+ electron.ipcMain.handle("agents:reveal", (_e, filePath) => agents.revealAgentFile(filePath));
3611
4797
  electron.ipcMain.handle("actions:list", () => sessions.actions);
3612
4798
  electron.ipcMain.handle("actions:save", (_e, actions) => sessions.saveActions(actions));
3613
4799
  electron.ipcMain.handle(
@@ -3763,6 +4949,7 @@ function registerIpc(sessions, fs2, persistence2, sentinels, features, autoExpan
3763
4949
  Object.assign(persistence2.state.settings, patch);
3764
4950
  persistence2.scheduleSave();
3765
4951
  getWin2()?.webContents.send("session:changed");
4952
+ if (patch.agentRegistryPath !== void 0) agents.refresh();
3766
4953
  });
3767
4954
  }
3768
4955
  const DEFAULT_TOKEN_EFFICIENCY = {
@@ -3789,7 +4976,13 @@ const DEFAULT_SETTINGS = {
3789
4976
  watchdogStallMinutes: 10,
3790
4977
  watchdogUnansweredMinutes: 5,
3791
4978
  tokenEfficiency: DEFAULT_TOKEN_EFFICIENCY,
3792
- tokenEfficiencyRepoOverrides: {}
4979
+ tokenEfficiencyRepoOverrides: {},
4980
+ agentRegistryPath: "C:\\repos\\agent-factory\\registry\\registry.json",
4981
+ theme: "dark",
4982
+ accentColor: null,
4983
+ gamificationEnabled: true,
4984
+ gamificationReduceMotion: false,
4985
+ gamificationSound: false
3793
4986
  };
3794
4987
  const DEFAULT_CATEGORIES = [
3795
4988
  {
@@ -3959,9 +5152,11 @@ function gitHead$1(folder) {
3959
5152
  });
3960
5153
  }
3961
5154
  class SentinelService {
3962
- constructor(persistence2, getWin2) {
5155
+ constructor(persistence2, getWin2, emitGame = () => {
5156
+ }) {
3963
5157
  this.persistence = persistence2;
3964
5158
  this.getWin = getWin2;
5159
+ this.emitGame = emitGame;
3965
5160
  }
3966
5161
  timer = null;
3967
5162
  /** Last observed HEAD per session id; baseline is set without firing. */
@@ -4141,6 +5336,7 @@ class SentinelService {
4141
5336
  run.summary = summary;
4142
5337
  run.findings = findings;
4143
5338
  this.broadcast(run.sessionId);
5339
+ if (status !== "error") this.emitGame({ type: "sentinel.run" });
4144
5340
  }
4145
5341
  broadcast(sessionId) {
4146
5342
  this.getWin()?.webContents.send("sentinel:runs", sessionId, this.listRuns(sessionId));
@@ -4420,11 +5616,13 @@ const STATUS_PRIORITY = [
4420
5616
  "exited"
4421
5617
  ];
4422
5618
  class SessionManager {
4423
- constructor(persistence2, fs2, tokenEff, getWin2) {
5619
+ constructor(persistence2, fs2, tokenEff, getWin2, emitGame = () => {
5620
+ }) {
4424
5621
  this.persistence = persistence2;
4425
5622
  this.fs = fs2;
4426
5623
  this.tokenEff = tokenEff;
4427
5624
  this.getWin = getWin2;
5625
+ this.emitGame = emitGame;
4428
5626
  }
4429
5627
  /** Keyed by terminal id, across all sessions. */
4430
5628
  ptys = /* @__PURE__ */ new Map();
@@ -4449,6 +5647,8 @@ class SessionManager {
4449
5647
  watchdog = /* @__PURE__ */ new Map();
4450
5648
  /** The watchdog interval; null until startWatchdog() runs. */
4451
5649
  watchdogTimer = null;
5650
+ /** Per-terminal last status, used only to award a turn on the working→done edge. */
5651
+ lastStatusForAward = /* @__PURE__ */ new Map();
4452
5652
  get state() {
4453
5653
  return this.persistence.state;
4454
5654
  }
@@ -4460,12 +5660,12 @@ class SessionManager {
4460
5660
  }
4461
5661
  categoryOf(config) {
4462
5662
  if (!config.categoryId) return null;
4463
- return this.state.categories.find((c) => c.id === config.categoryId) ?? null;
5663
+ return this.state.categories.find((c2) => c2.id === config.categoryId) ?? null;
4464
5664
  }
4465
5665
  /** Every MCP server name owned by any category — our materialization namespace. */
4466
5666
  managedServerNames() {
4467
5667
  const names = /* @__PURE__ */ new Set();
4468
- for (const c of this.state.categories) for (const s of c.mcpServers) names.add(s.name);
5668
+ for (const c2 of this.state.categories) for (const s of c2.mcpServers) names.add(s.name);
4469
5669
  return [...names];
4470
5670
  }
4471
5671
  /**
@@ -4516,6 +5716,7 @@ class SessionManager {
4516
5716
  for (const terminal of config.terminals) this.spawnTerminal(config, terminal, "fresh");
4517
5717
  this.fs.start(config.id, config.folder, []);
4518
5718
  this.notifyChanged();
5719
+ this.emitGame({ type: "session.create" });
4519
5720
  return this.toInfo(config);
4520
5721
  }
4521
5722
  close(id) {
@@ -4605,7 +5806,9 @@ class SessionManager {
4605
5806
  async createCheckpoint(sessionId, label) {
4606
5807
  const config = this.getConfig(sessionId);
4607
5808
  if (!config) throw new Error("Unknown session");
4608
- return createCheckpoint(config.folder, label);
5809
+ const checkpoint = await createCheckpoint(config.folder, label);
5810
+ this.emitGame({ type: "checkpoint.create" });
5811
+ return checkpoint;
4609
5812
  }
4610
5813
  /** Recent checkpoints for a session's repo, newest first. */
4611
5814
  async listCheckpoints(sessionId) {
@@ -4713,6 +5916,7 @@ class SessionManager {
4713
5916
  }, INITIAL_PROMPT_DELAY_MS);
4714
5917
  }
4715
5918
  this.notifyChanged();
5919
+ this.emitGame({ type: "worktree.create" });
4716
5920
  return this.toInfo(config);
4717
5921
  }
4718
5922
  /** Live git facts about a worktree task (uncommitted files, commits ahead). */
@@ -4794,7 +5998,12 @@ ${commit.output}` };
4794
5998
  }
4795
5999
  const result = await mergeBranch(baseFolder, branch, baseBranch);
4796
6000
  if (!result.ok) return { ...result, autoCommitted };
4797
- return { ...await this.pushAfterMerge(result, baseFolder, baseBranch), autoCommitted };
6001
+ const merged = { ...await this.pushAfterMerge(result, baseFolder, baseBranch), autoCommitted };
6002
+ this.emitGame({ type: "worktree.merge", meta: { commits: ahead ?? 0 } });
6003
+ if (this.state.features.some((f) => f.taskSessionId === sessionId)) {
6004
+ this.emitGame({ type: "feature.merge" });
6005
+ }
6006
+ return merged;
4798
6007
  }
4799
6008
  /** True if `branch` is the dedicated expansion branch of any auto-expand config. */
4800
6009
  isAutoExpandBranch(branch) {
@@ -4889,6 +6098,7 @@ ${commit.output}` };
4889
6098
  const title = prTitle(config.name, branch);
4890
6099
  const body = prBody(config.name, branch, baseBranch);
4891
6100
  const result = await createPullRequest(config.folder, branch, baseBranch, title, body);
6101
+ if (result.ok) this.emitGame({ type: "worktree.pr" });
4892
6102
  return { ...result, autoCommitted };
4893
6103
  }
4894
6104
  // ---------- auto-complete (auto-merge / auto-PR when claude finishes) --------
@@ -5173,6 +6383,7 @@ ${err.message}`
5173
6383
  const action = this.state.actions.find((a) => a.id === actionId);
5174
6384
  const command = action?.command.trim();
5175
6385
  if (!config || !action || !command) return null;
6386
+ this.emitGame({ type: "action.run" });
5176
6387
  if (action.shell === "claude") return this.runClaudeAction(config, command);
5177
6388
  let terminal = config.terminals.find((t) => t.actionId === action.id);
5178
6389
  let respawned = false;
@@ -5415,6 +6626,11 @@ ${err.message}`
5415
6626
  if (!config) return;
5416
6627
  const terminal = config.terminals.find((t) => t.id === terminalId);
5417
6628
  if (!terminal || terminal.kind !== "claude") return;
6629
+ const prevForAward = this.lastStatusForAward.get(terminalId);
6630
+ this.lastStatusForAward.set(terminalId, status);
6631
+ if (status === "done" && prevForAward === "working") {
6632
+ this.emitGame({ type: "session.turn" });
6633
+ }
5418
6634
  if (status === "done" || status === "idle") this.scheduleQueueDispatch(config.id);
5419
6635
  if (config.worktree?.autoComplete) {
5420
6636
  if (status === "working") this.worktreeWorked.add(config.id);
@@ -6419,17 +7635,27 @@ if (!gotLock) {
6419
7635
  electron.app.whenReady().then(() => {
6420
7636
  electron.app.setAppUserModelId("com.pedroferreira.maestro");
6421
7637
  persistence.load();
7638
+ const gameBus = new events.EventEmitter();
7639
+ const emitGame = (e) => {
7640
+ try {
7641
+ gameBus.emit("game", e);
7642
+ } catch {
7643
+ }
7644
+ };
6422
7645
  const fsService = new FsService(
6423
- (sessionId, events) => getWin()?.webContents.send("fs:events", sessionId, events),
7646
+ (sessionId, events2) => getWin()?.webContents.send("fs:events", sessionId, events2),
6424
7647
  () => persistence.state.settings.ignoreNames
6425
7648
  );
6426
7649
  const tokenEff = new TokenEfficiencyService(persistence);
6427
- const sessions = new SessionManager(persistence, fsService, tokenEff, getWin);
6428
- const sentinels = new SentinelService(persistence, getWin);
6429
- const features = new FeatureService(persistence, sessions);
6430
- const autoExpand = new AutoExpandService(persistence, features, getWin);
7650
+ const sessions = new SessionManager(persistence, fsService, tokenEff, getWin, emitGame);
7651
+ const sentinels = new SentinelService(persistence, getWin, emitGame);
7652
+ const features = new FeatureService(persistence, sessions, emitGame);
7653
+ const autoExpand = new AutoExpandService(persistence, features, getWin, emitGame);
6431
7654
  const conductor = new ConductorService(persistence, sessions, features, autoExpand, getWin);
6432
- const factory = new FactoryService(getWin);
7655
+ const factory = new FactoryService(getWin, emitGame);
7656
+ const agentRegistry = new AgentRegistryService(persistence, getWin);
7657
+ const gamification = new GamificationService(getWin);
7658
+ gameBus.on("game", (e) => gamification.award(e));
6433
7659
  registerIpc(
6434
7660
  sessions,
6435
7661
  fsService,
@@ -6439,7 +7665,9 @@ if (!gotLock) {
6439
7665
  autoExpand,
6440
7666
  conductor,
6441
7667
  factory,
7668
+ agentRegistry,
6442
7669
  tokenEff,
7670
+ gamification,
6443
7671
  getWin
6444
7672
  );
6445
7673
  createWindow();
@@ -6448,11 +7676,18 @@ if (!gotLock) {
6448
7676
  sentinels.start();
6449
7677
  autoExpand.start();
6450
7678
  tokenEff.start();
7679
+ factory.start();
7680
+ conductor.onTurnComplete((messages) => {
7681
+ factory.considerConversation(messages);
7682
+ emitGame({ type: "conductor.turn" });
7683
+ });
6451
7684
  electron.app.on("activate", () => {
6452
7685
  if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
6453
7686
  });
6454
7687
  electron.app.on("before-quit", () => {
6455
7688
  tokenEff.dispose();
7689
+ agentRegistry.dispose();
7690
+ gamification.dispose();
6456
7691
  factory.dispose();
6457
7692
  conductor.dispose();
6458
7693
  autoExpand.dispose();