iriai-build 0.1.0

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 (80) hide show
  1. package/bin/iriai-build.js +78 -0
  2. package/bridge-v3.js +98 -0
  3. package/cli/bootstrap.js +83 -0
  4. package/cli/commands/implementation.js +64 -0
  5. package/cli/commands/index.js +46 -0
  6. package/cli/commands/launch.js +153 -0
  7. package/cli/commands/plan.js +117 -0
  8. package/cli/commands/setup.js +80 -0
  9. package/cli/commands/slack.js +97 -0
  10. package/cli/commands/transfer.js +111 -0
  11. package/cli/config.js +92 -0
  12. package/cli/display.js +121 -0
  13. package/cli/terminal-input.js +666 -0
  14. package/cli/wait.js +82 -0
  15. package/index.js +1488 -0
  16. package/lib/agent-process.js +170 -0
  17. package/lib/bridge-state.js +126 -0
  18. package/lib/constants.js +137 -0
  19. package/lib/health-monitor.js +113 -0
  20. package/lib/prompt-builder.js +565 -0
  21. package/lib/signal-watcher.js +215 -0
  22. package/lib/slack-helpers.js +224 -0
  23. package/lib/state-machines/feature-lead.js +408 -0
  24. package/lib/state-machines/operator-agent.js +173 -0
  25. package/lib/state-machines/planning-role.js +161 -0
  26. package/lib/state-machines/role-agent.js +186 -0
  27. package/lib/state-machines/team-orchestrator.js +160 -0
  28. package/package.json +31 -0
  29. package/v3/.handover-html-evidence.md +35 -0
  30. package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
  31. package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
  32. package/v3/adapters/desktop-adapter.js +78 -0
  33. package/v3/adapters/interface.js +146 -0
  34. package/v3/adapters/slack-adapter.js +608 -0
  35. package/v3/adapters/slack-helpers.js +179 -0
  36. package/v3/adapters/terminal-adapter.js +249 -0
  37. package/v3/agent-supervisor.js +320 -0
  38. package/v3/artifact-portal.js +1184 -0
  39. package/v3/bridge.db +0 -0
  40. package/v3/constants.js +170 -0
  41. package/v3/db.js +76 -0
  42. package/v3/file-io.js +216 -0
  43. package/v3/helpers.js +174 -0
  44. package/v3/operator.js +364 -0
  45. package/v3/orchestrator.js +2886 -0
  46. package/v3/plan-compiler.js +440 -0
  47. package/v3/prompt-builder.js +849 -0
  48. package/v3/queries.js +461 -0
  49. package/v3/recovery.js +508 -0
  50. package/v3/review-sessions.js +360 -0
  51. package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
  52. package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
  53. package/v3/roles/architect/CLAUDE.md +809 -0
  54. package/v3/roles/backend-implementer/CLAUDE.md +97 -0
  55. package/v3/roles/code-reviewer/CLAUDE.md +89 -0
  56. package/v3/roles/database-implementer/CLAUDE.md +97 -0
  57. package/v3/roles/deployer/CLAUDE.md +42 -0
  58. package/v3/roles/designer/CLAUDE.md +386 -0
  59. package/v3/roles/documentation/CLAUDE.md +40 -0
  60. package/v3/roles/feature-lead/CLAUDE.md +233 -0
  61. package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
  62. package/v3/roles/implementer/CLAUDE.md +97 -0
  63. package/v3/roles/integration-tester/CLAUDE.md +174 -0
  64. package/v3/roles/observability-engineer/CLAUDE.md +40 -0
  65. package/v3/roles/operator/CLAUDE.md +322 -0
  66. package/v3/roles/orchestrator/CLAUDE.md +288 -0
  67. package/v3/roles/package-implementer/CLAUDE.md +47 -0
  68. package/v3/roles/performance-analyst/CLAUDE.md +49 -0
  69. package/v3/roles/plan-compiler/CLAUDE.md +163 -0
  70. package/v3/roles/planning-lead/CLAUDE.md +41 -0
  71. package/v3/roles/pm/CLAUDE.md +806 -0
  72. package/v3/roles/regression-tester/CLAUDE.md +135 -0
  73. package/v3/roles/release-manager/CLAUDE.md +43 -0
  74. package/v3/roles/security-auditor/CLAUDE.md +90 -0
  75. package/v3/roles/smoke-tester/CLAUDE.md +97 -0
  76. package/v3/roles/test-author/CLAUDE.md +42 -0
  77. package/v3/roles/verifier/CLAUDE.md +90 -0
  78. package/v3/schema.sql +134 -0
  79. package/v3/slack-adapter.js +510 -0
  80. package/v3/slack-helpers.js +346 -0
@@ -0,0 +1,408 @@
1
+ // feature-lead.js — Multi-phase Feature Lead orchestrator.
2
+ // Replaces run-feature-lead.sh. One AgentProcess at a time, fresh per phase/trigger.
3
+ //
4
+ // INIT → DISPATCHING → MONITORING → HANDLING_TRIGGER → GATE_REVIEW → MONITORING → ... → COMPLETE
5
+
6
+ import { EventEmitter } from "node:events";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import AgentProcess from "../agent-process.js";
10
+ import {
11
+ buildFeatureLeadInitPrompt,
12
+ buildFeatureLeadRefreshPrompt,
13
+ buildFeatureLeadTriggerPrompt,
14
+ } from "../prompt-builder.js";
15
+ import {
16
+ IMPL_BASE, IRIAI_TEAM_DIR,
17
+ MAX_FL_RETRIES, MAX_FL_INIT_RETRIES,
18
+ FAST_EXIT_THRESHOLD_MS, FAST_EXIT_BACKOFF_S, FL_NORMAL_BACKOFF_S,
19
+ FL_CONTEXT_EXHAUST_MS,
20
+ SIGNAL, DASHBOARD_LOG, FEATURE_REVIEW_ROLES,
21
+ } from "../constants.js";
22
+
23
+ export default class FeatureLead extends EventEmitter {
24
+ /**
25
+ * @param {object} opts
26
+ * @param {string} opts.slug - Feature slug
27
+ * @param {string} opts.featureLeadDir - Feature Lead signal directory
28
+ * @param {string} opts.featureReviewDir - Feature review signal directory
29
+ * @param {string} opts.teamSignalBase - Teams signal base
30
+ * @param {number} opts.numTeams - Number of teams
31
+ * @param {string} opts.teamType - Team type (e.g., "dynamic")
32
+ * @param {string} [opts.model] - Claude model
33
+ * @param {object} [opts.featureState] - Live feature state ref (for gate_evidence_ts dedup)
34
+ */
35
+ constructor({ slug, featureLeadDir, featureReviewDir, teamSignalBase, numTeams, teamType, model, featureState }) {
36
+ super();
37
+ this.slug = slug;
38
+ this.featureLeadDir = featureLeadDir;
39
+ this.featureReviewDir = featureReviewDir;
40
+ this.teamSignalBase = teamSignalBase;
41
+ this.numTeams = numTeams;
42
+ this.teamType = teamType || "dynamic";
43
+ this.model = model || "opus";
44
+ this.key = `fl-${slug}`;
45
+ this._featureState = featureState || null;
46
+
47
+ this._agent = null;
48
+ this._state = "idle";
49
+ this._mainCrashCount = 0;
50
+ this._retryTimer = null;
51
+ this._dashboardTimer = null;
52
+ this._planReadInstruction = "";
53
+
54
+ this._resolvePlanPaths();
55
+ }
56
+
57
+ get state() { return this._state; }
58
+
59
+ /**
60
+ * Start the initial dispatch phase.
61
+ */
62
+ startInit() {
63
+ this._state = "dispatching";
64
+
65
+ // Check if this is a resume (gate > 0 in FEATURE-STATUS.md)
66
+ const featureStatus = path.join(IRIAI_TEAM_DIR, "FEATURE-STATUS.md");
67
+ let skipInit = false;
68
+ try {
69
+ const content = fs.readFileSync(featureStatus, "utf-8");
70
+ const gateMatch = content.match(/Current Gate[:\s]*(\d+)/i);
71
+ if (gateMatch && parseInt(gateMatch[1]) > 0) {
72
+ skipInit = true;
73
+ }
74
+ } catch { /* ok */ }
75
+
76
+ if (skipInit) {
77
+ // Resume in progress — spawn a refresh session to pick up where we left off
78
+ // (dispatch next gate, continue monitoring, etc.)
79
+ this.emit("lifecycle", { key: this.key, event: "resumed" });
80
+ this._spawnRefresh();
81
+ return;
82
+ }
83
+
84
+ this._spawnInit();
85
+ }
86
+
87
+ /**
88
+ * Handle a trigger detected by the bridge or signal watcher.
89
+ * Called when gate-ready, question, crash, or idle-redispatch detected externally.
90
+ */
91
+ handleTrigger(trigger, { questionTeams, crashedTeams } = {}) {
92
+ if (this._agent) {
93
+ // Kill current session before spawning trigger handler
94
+ this._agent.kill();
95
+ this._agent = null;
96
+ }
97
+
98
+ this._state = "handling-trigger";
99
+ this._spawnTrigger(trigger, { questionTeams, crashedTeams });
100
+ }
101
+
102
+ kill() {
103
+ if (this._agent) {
104
+ this._agent.kill();
105
+ this._agent = null;
106
+ }
107
+ if (this._dashboardTimer) {
108
+ clearInterval(this._dashboardTimer);
109
+ this._dashboardTimer = null;
110
+ }
111
+ this._state = "idle";
112
+ }
113
+
114
+ /**
115
+ * Handle .needs-restart signal for the Feature Lead (context handover).
116
+ * Reads .handover from disk and immediately calls _spawnRefresh() with that content.
117
+ */
118
+ handleNeedsRestart() {
119
+ const handoverPath = path.join(this.featureLeadDir, SIGNAL.HANDOVER);
120
+ let handoverContent = null;
121
+ try {
122
+ if (fs.existsSync(handoverPath)) {
123
+ handoverContent = fs.readFileSync(handoverPath, "utf8").trim();
124
+ fs.unlinkSync(handoverPath);
125
+ }
126
+ } catch { /* ignore */ }
127
+
128
+ const restartPath = path.join(this.featureLeadDir, SIGNAL.NEEDS_RESTART);
129
+ try { fs.unlinkSync(restartPath); } catch { /* ignore */ }
130
+
131
+ // Cancel any pending crash-retry timer to prevent double-spawn
132
+ if (this._retryTimer) {
133
+ clearTimeout(this._retryTimer);
134
+ this._retryTimer = null;
135
+ }
136
+
137
+ if (this._agent) {
138
+ this._agent.kill();
139
+ this._agent = null;
140
+ }
141
+
142
+ this._mainCrashCount = 0;
143
+ this.emit("lifecycle", { key: this.key, event: "handover-restart" });
144
+ this._spawnRefresh(handoverContent);
145
+ }
146
+
147
+ // ─── Init Phase ──────────────────────────────────────────────────────────
148
+
149
+ _spawnInit() {
150
+ // Clean signals
151
+ for (const sig of [SIGNAL.CONTEXT_REFRESH, SIGNAL.PHASE_DONE]) {
152
+ try { fs.unlinkSync(path.join(this.featureLeadDir, sig)); } catch { /* ok */ }
153
+ }
154
+
155
+ const prompt = buildFeatureLeadInitPrompt({
156
+ featureName: this.slug,
157
+ numTeams: this.numTeams,
158
+ teamType: this.teamType,
159
+ teamSignalBase: this.teamSignalBase,
160
+ planReadInstruction: this._planReadInstruction,
161
+ featureLeadDir: this.featureLeadDir,
162
+ featureReviewDir: this.featureReviewDir,
163
+ dashboardLog: DASHBOARD_LOG,
164
+ });
165
+
166
+ this._spawnSession(prompt, {
167
+ onPhaseDone: () => {
168
+ this._state = "monitoring";
169
+ this.emit("lifecycle", { key: this.key, event: "init-complete" });
170
+ this._startTriggerDetection();
171
+ },
172
+ onFeatureComplete: () => {
173
+ this._state = "complete";
174
+ this.emit("featureComplete", { key: this.key, slug: this.slug });
175
+ this.emit("lifecycle", { key: this.key, event: "feature-complete" });
176
+ },
177
+ onContextRefresh: () => {
178
+ this.emit("lifecycle", { key: this.key, event: "context-refresh" });
179
+ this._spawnRefresh();
180
+ },
181
+ onCrash: (elapsed) => this._handleCrash(elapsed, () => this._spawnInit()),
182
+ maxRetries: MAX_FL_INIT_RETRIES,
183
+ });
184
+
185
+ this.emit("lifecycle", { key: this.key, event: "init-started" });
186
+ }
187
+
188
+ // ─── Trigger Phase ───────────────────────────────────────────────────────
189
+
190
+ _spawnTrigger(trigger, { questionTeams, crashedTeams, recoveryContext } = {}) {
191
+ // Clean signals
192
+ for (const sig of [SIGNAL.PHASE_DONE, SIGNAL.CONTEXT_REFRESH]) {
193
+ try { fs.unlinkSync(path.join(this.featureLeadDir, sig)); } catch { /* ok */ }
194
+ }
195
+
196
+ const prompt = buildFeatureLeadTriggerPrompt({
197
+ featureName: this.slug,
198
+ numTeams: this.numTeams,
199
+ trigger,
200
+ teamSignalBase: this.teamSignalBase,
201
+ featureLeadDir: this.featureLeadDir,
202
+ featureReviewDir: this.featureReviewDir,
203
+ dashboardLog: DASHBOARD_LOG,
204
+ questionTeams: questionTeams || [],
205
+ crashedTeams: crashedTeams || [],
206
+ recoveryContext,
207
+ });
208
+
209
+ this._spawnSession(prompt, {
210
+ onPhaseDone: () => {
211
+ this._mainCrashCount = 0; // Reset on success
212
+ this.emit("lifecycle", { key: this.key, event: "phase-complete", trigger });
213
+ // After a gate review is approved, spawn a fresh session to dispatch the
214
+ // next gate. The prompt says "a new session will handle the next gate
215
+ // dispatch" — this is where that promise is fulfilled.
216
+ if (trigger === "gate-ready") {
217
+ this._spawnRefresh();
218
+ } else {
219
+ this._state = "monitoring";
220
+ this._startTriggerDetection();
221
+ }
222
+ },
223
+ onFeatureComplete: () => {
224
+ this._state = "complete";
225
+ this.emit("featureComplete", { key: this.key, slug: this.slug });
226
+ this.emit("lifecycle", { key: this.key, event: "feature-complete" });
227
+ },
228
+ onContextRefresh: () => {
229
+ this.emit("lifecycle", { key: this.key, event: "context-refresh" });
230
+ this._spawnRefresh();
231
+ },
232
+ onCrash: (elapsed) => this._handleCrash(elapsed, () => {
233
+ this._spawnTrigger(trigger, { questionTeams, crashedTeams, recoveryContext: true });
234
+ }),
235
+ maxRetries: MAX_FL_RETRIES,
236
+ });
237
+
238
+ this.emit("lifecycle", { key: this.key, event: "trigger-started", trigger });
239
+ }
240
+
241
+ // ─── Refresh/Continue ────────────────────────────────────────────────────
242
+
243
+ _spawnRefresh(handoverContent = null) {
244
+ // Clean stale signals before spawning (matches _spawnInit and _spawnTrigger)
245
+ for (const sig of [SIGNAL.CONTEXT_REFRESH, SIGNAL.PHASE_DONE]) {
246
+ try { fs.unlinkSync(path.join(this.featureLeadDir, sig)); } catch { /* ok */ }
247
+ }
248
+
249
+ const gateEvidenceTs = this._featureState?.gate_evidence_ts || null;
250
+ const prompt = buildFeatureLeadRefreshPrompt({
251
+ featureName: this.slug,
252
+ numTeams: this.numTeams,
253
+ teamSignalBase: this.teamSignalBase,
254
+ planReadInstruction: this._planReadInstruction,
255
+ featureLeadDir: this.featureLeadDir,
256
+ featureReviewDir: this.featureReviewDir,
257
+ dashboardLog: DASHBOARD_LOG,
258
+ gateEvidenceTs,
259
+ });
260
+
261
+ const fullPrompt = handoverContent
262
+ ? `HANDOVER CONTEXT FROM PREVIOUS SESSION:\n${handoverContent}\n\n---\n\n${prompt}`
263
+ : prompt;
264
+
265
+ this._spawnSession(fullPrompt, {
266
+ onPhaseDone: () => {
267
+ this._mainCrashCount = 0;
268
+ this.emit("lifecycle", { key: this.key, event: "refresh-complete" });
269
+ // Chain to a fresh session to dispatch the next gate — same as
270
+ // _spawnTrigger("gate-ready") does. Without this, the FL drops
271
+ // into monitoring with no process and no trigger detection,
272
+ // causing the next gate to never be dispatched.
273
+ this._spawnRefresh();
274
+ },
275
+ onFeatureComplete: () => {
276
+ this._state = "complete";
277
+ this.emit("featureComplete", { key: this.key, slug: this.slug });
278
+ },
279
+ onContextRefresh: () => {
280
+ this._spawnRefresh(); // Recursive refresh
281
+ },
282
+ onCrash: (elapsed) => this._handleCrash(elapsed, () => this._spawnRefresh()),
283
+ maxRetries: MAX_FL_RETRIES,
284
+ });
285
+ }
286
+
287
+ // ─── Shared Session Spawning ─────────────────────────────────────────────
288
+
289
+ _spawnSession(prompt, { onPhaseDone, onFeatureComplete, onContextRefresh, onCrash, maxRetries }) {
290
+ this._agent = new AgentProcess({
291
+ key: this.key,
292
+ cwd: IRIAI_TEAM_DIR,
293
+ extraEnv: { IMPL_SIGNAL_BASE: IMPL_BASE },
294
+ signalDir: this.featureLeadDir,
295
+ });
296
+ this._agent.spawnClaude(prompt, { model: this.model });
297
+
298
+ this._agent.on("exit", ({ elapsed }) => {
299
+ this._agent = null;
300
+
301
+ // Check for .feature-complete FIRST
302
+ if (fs.existsSync(path.join(this.featureLeadDir, SIGNAL.FEATURE_COMPLETE))) {
303
+ onFeatureComplete();
304
+ return;
305
+ }
306
+
307
+ // Check for .context-refresh
308
+ if (fs.existsSync(path.join(this.featureLeadDir, SIGNAL.CONTEXT_REFRESH))) {
309
+ try { fs.unlinkSync(path.join(this.featureLeadDir, SIGNAL.CONTEXT_REFRESH)); } catch { /* ok */ }
310
+ onContextRefresh();
311
+ return;
312
+ }
313
+
314
+ // Check for .phase-done
315
+ if (fs.existsSync(path.join(this.featureLeadDir, SIGNAL.PHASE_DONE))) {
316
+ try { fs.unlinkSync(path.join(this.featureLeadDir, SIGNAL.PHASE_DONE)); } catch { /* ok */ }
317
+ onPhaseDone();
318
+ return;
319
+ }
320
+
321
+ // Check for .needs-restart — signal watcher will call handleNeedsRestart()
322
+ // Don't count as crash; the watcher handles the respawn.
323
+ if (fs.existsSync(path.join(this.featureLeadDir, SIGNAL.NEEDS_RESTART))) {
324
+ this.emit("lifecycle", { key: this.key, event: "needs-restart-exit" });
325
+ return;
326
+ }
327
+
328
+ // No signal — check for auto-refresh (ran > 5min = likely context exhaustion)
329
+ if (elapsed >= FL_CONTEXT_EXHAUST_MS) {
330
+ this.emit("lifecycle", { key: this.key, event: "auto-context-refresh" });
331
+ onContextRefresh();
332
+ return;
333
+ }
334
+
335
+ // Short-lived exit = real crash
336
+ onCrash(elapsed);
337
+ });
338
+ }
339
+
340
+ _handleCrash(elapsed, retryFn) {
341
+ this._mainCrashCount++;
342
+ const isFast = elapsed < FAST_EXIT_THRESHOLD_MS;
343
+ const backoffS = isFast
344
+ ? this._mainCrashCount * FAST_EXIT_BACKOFF_S
345
+ : this._mainCrashCount * FL_NORMAL_BACKOFF_S;
346
+
347
+ if (this._mainCrashCount >= MAX_FL_RETRIES) {
348
+ // Instead of pausing indefinitely, do a context-refresh restart
349
+ this._mainCrashCount = 0;
350
+ this.emit("lifecycle", { key: this.key, event: "auto-restart-after-crashes" });
351
+ this._spawnRefresh();
352
+ return;
353
+ }
354
+
355
+ this._state = "retrying";
356
+ this.emit("lifecycle", {
357
+ key: this.key,
358
+ event: "crash-retry",
359
+ retryCount: this._mainCrashCount,
360
+ backoffS,
361
+ });
362
+
363
+ this._retryTimer = setTimeout(() => {
364
+ this._retryTimer = null;
365
+ retryFn();
366
+ }, backoffS * 1000);
367
+ }
368
+
369
+ // ─── Trigger Detection ───────────────────────────────────────────────────
370
+
371
+ _startTriggerDetection() {
372
+ // In v2 bridge, trigger detection is event-driven via SignalWatcher.
373
+ // The bridge wires signal events to handleTrigger(). This method is a
374
+ // no-op; kept for documentation.
375
+ }
376
+
377
+ // ─── Plan Paths ──────────────────────────────────────────────────────────
378
+
379
+ _resolvePlanPaths() {
380
+ const defaultPath = path.join(IRIAI_TEAM_DIR, "implementation-plans", "current/");
381
+ const proposalFile = path.join(IRIAI_TEAM_DIR, ".feature-proposal");
382
+
383
+ try {
384
+ if (fs.existsSync(proposalFile)) {
385
+ const content = fs.readFileSync(proposalFile, "utf-8");
386
+ const pathsMatch = content.match(/^plan_paths:\s*(.+)$/m);
387
+ const sourceMatch = content.match(/^plan_source:\s*(.+)$/m);
388
+ if (pathsMatch) {
389
+ const savedPaths = pathsMatch[1].trim();
390
+ const source = sourceMatch ? sourceMatch[1].trim() : "dir";
391
+ const firstPath = savedPaths.split(/\s+/)[0];
392
+ if (fs.existsSync(firstPath)) {
393
+ if (source === "files") {
394
+ this._planReadInstruction = `Read the following implementation plan file(s) in order:\n${
395
+ savedPaths.split(/\s+/).map((p) => `- ${p}`).join("\n")
396
+ }`;
397
+ } else {
398
+ this._planReadInstruction = `Read the implementation plan at ${savedPaths}.`;
399
+ }
400
+ return;
401
+ }
402
+ }
403
+ }
404
+ } catch { /* ok */ }
405
+
406
+ this._planReadInstruction = `Read the implementation plan at ${defaultPath}.`;
407
+ }
408
+ }
@@ -0,0 +1,173 @@
1
+ // operator-agent.js — Per-message operator agent.
2
+ // Replaces run-operator.sh. No retries — one fresh claude session per message.
3
+ //
4
+ // State: IDLE ──[signal:userMessage]──→ RUNNING ──[process:exit]──→ IDLE
5
+
6
+ import { EventEmitter } from "node:events";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import AgentProcess from "../agent-process.js";
10
+ import { buildOperatorPrompt } from "../prompt-builder.js";
11
+ import { IMPL_BASE, IRIAI_TEAM_DIR, SIGNAL } from "../constants.js";
12
+
13
+ const POLL_INTERVAL_MS = 30_000; // Safety net: check for abandoned .user-message every 30s
14
+
15
+ export default class OperatorAgent extends EventEmitter {
16
+ /**
17
+ * @param {object} opts
18
+ * @param {string} opts.slug - Feature slug
19
+ * @param {string} opts.operatorDir - Operator signal directory
20
+ * @param {string} opts.flDir - Feature Lead signal directory
21
+ * @param {string} opts.featureDir - Feature signal tree root
22
+ */
23
+ constructor({ slug, operatorDir, flDir, featureDir }) {
24
+ super();
25
+ this.slug = slug;
26
+ this.operatorDir = operatorDir;
27
+ this.flDir = flDir;
28
+ this.featureDir = featureDir;
29
+ this.key = `op-${slug}`;
30
+ this._agent = null;
31
+ this._state = "idle";
32
+ this.historyFile = path.join(operatorDir, SIGNAL.CONVERSATION_HISTORY);
33
+ this._pollTimer = null;
34
+ this._startPoll();
35
+ }
36
+
37
+ get state() { return this._state; }
38
+
39
+ /**
40
+ * Handle an incoming user message (triggered by SignalWatcher).
41
+ */
42
+ handleUserMessage() {
43
+ if (this._state === "running") {
44
+ console.log(`[operator] ${this.key}: already running, ignoring message`);
45
+ return;
46
+ }
47
+
48
+ const msgPath = path.join(this.operatorDir, SIGNAL.USER_MESSAGE);
49
+ let userMsg;
50
+ try {
51
+ userMsg = fs.readFileSync(msgPath, "utf-8").trim();
52
+ fs.unlinkSync(msgPath);
53
+ } catch {
54
+ return;
55
+ }
56
+ if (!userMsg) return;
57
+
58
+ this._state = "running";
59
+ this._appendHistory("user", userMsg);
60
+ const history = this._getHistory();
61
+
62
+ let prompt;
63
+ try {
64
+ prompt = buildOperatorPrompt({
65
+ featureName: this.slug,
66
+ operatorDir: this.operatorDir,
67
+ flDir: this.flDir,
68
+ featureDir: this.featureDir,
69
+ history,
70
+ userMessage: userMsg,
71
+ });
72
+ } catch (err) {
73
+ console.error(`[operator] ${this.key}: prompt build failed:`, err.message);
74
+ this._state = "idle";
75
+ return;
76
+ }
77
+
78
+ try {
79
+ this._agent = new AgentProcess({
80
+ key: this.key,
81
+ cwd: this.operatorDir,
82
+ extraEnv: { IMPL_SIGNAL_BASE: IMPL_BASE },
83
+ signalDir: this.operatorDir,
84
+ });
85
+
86
+ this._agent.spawnClaude(prompt, { model: "opus" });
87
+ } catch (err) {
88
+ console.error(`[operator] ${this.key}: spawn failed:`, err.message);
89
+ this._agent = null;
90
+ this._state = "idle";
91
+ return;
92
+ }
93
+
94
+ this._agent.on("exit", ({ exitCode }) => {
95
+ // Check for .agent-response
96
+ const responsePath = path.join(this.operatorDir, SIGNAL.AGENT_RESPONSE);
97
+ if (fs.existsSync(responsePath)) {
98
+ const response = fs.readFileSync(responsePath, "utf-8").trim();
99
+ this._appendHistory("operator", response);
100
+ } else if (exitCode !== 0) {
101
+ // Agent crashed — write fallback response
102
+ fs.writeFileSync(
103
+ responsePath,
104
+ "Something went wrong processing your message. Please try again."
105
+ );
106
+ this._appendHistory("operator", "(failed to respond)");
107
+ }
108
+
109
+ this._agent = null;
110
+ this._state = "idle";
111
+ this.emit("idle", { key: this.key });
112
+
113
+ // Drain on next tick to avoid spawning PTY inside PTY exit callback
114
+ setImmediate(() => this._drainPending());
115
+ });
116
+
117
+ this.emit("running", { key: this.key });
118
+ }
119
+
120
+ kill() {
121
+ if (this._agent) {
122
+ this._agent.kill();
123
+ this._agent = null;
124
+ }
125
+ this._state = "idle";
126
+ if (this._pollTimer) {
127
+ clearInterval(this._pollTimer);
128
+ this._pollTimer = null;
129
+ }
130
+ }
131
+
132
+ /** Safety net: periodically check for abandoned .user-message when idle */
133
+ _startPoll() {
134
+ this._pollTimer = setInterval(() => {
135
+ this._drainPending();
136
+ }, POLL_INTERVAL_MS);
137
+ }
138
+
139
+ _drainPending() {
140
+ if (this._state !== "idle") return;
141
+ const pendingMsg = path.join(this.operatorDir, SIGNAL.USER_MESSAGE);
142
+ try {
143
+ if (fs.existsSync(pendingMsg)) {
144
+ console.log(`[operator] ${this.key}: draining pending .user-message`);
145
+ this.handleUserMessage();
146
+ }
147
+ } catch (err) {
148
+ console.error(`[operator] ${this.key}: drain error:`, err.message);
149
+ this._state = "idle";
150
+ }
151
+ }
152
+
153
+ _appendHistory(role, content) {
154
+ const ts = new Date().toTimeString().slice(0, 8);
155
+ fs.appendFileSync(this.historyFile, `[${ts}] ${role}: ${content}\n`);
156
+
157
+ // Rotate at 200 lines
158
+ try {
159
+ const lines = fs.readFileSync(this.historyFile, "utf-8").split("\n");
160
+ if (lines.length > 200) {
161
+ fs.writeFileSync(this.historyFile, lines.slice(-100).join("\n"));
162
+ }
163
+ } catch { /* ignore */ }
164
+ }
165
+
166
+ _getHistory() {
167
+ try {
168
+ return fs.readFileSync(this.historyFile, "utf-8");
169
+ } catch {
170
+ return "(no prior conversation)";
171
+ }
172
+ }
173
+ }