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.
- package/bin/iriai-build.js +78 -0
- package/bridge-v3.js +98 -0
- package/cli/bootstrap.js +83 -0
- package/cli/commands/implementation.js +64 -0
- package/cli/commands/index.js +46 -0
- package/cli/commands/launch.js +153 -0
- package/cli/commands/plan.js +117 -0
- package/cli/commands/setup.js +80 -0
- package/cli/commands/slack.js +97 -0
- package/cli/commands/transfer.js +111 -0
- package/cli/config.js +92 -0
- package/cli/display.js +121 -0
- package/cli/terminal-input.js +666 -0
- package/cli/wait.js +82 -0
- package/index.js +1488 -0
- package/lib/agent-process.js +170 -0
- package/lib/bridge-state.js +126 -0
- package/lib/constants.js +137 -0
- package/lib/health-monitor.js +113 -0
- package/lib/prompt-builder.js +565 -0
- package/lib/signal-watcher.js +215 -0
- package/lib/slack-helpers.js +224 -0
- package/lib/state-machines/feature-lead.js +408 -0
- package/lib/state-machines/operator-agent.js +173 -0
- package/lib/state-machines/planning-role.js +161 -0
- package/lib/state-machines/role-agent.js +186 -0
- package/lib/state-machines/team-orchestrator.js +160 -0
- package/package.json +31 -0
- package/v3/.handover-html-evidence.md +35 -0
- package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
- package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
- package/v3/adapters/desktop-adapter.js +78 -0
- package/v3/adapters/interface.js +146 -0
- package/v3/adapters/slack-adapter.js +608 -0
- package/v3/adapters/slack-helpers.js +179 -0
- package/v3/adapters/terminal-adapter.js +249 -0
- package/v3/agent-supervisor.js +320 -0
- package/v3/artifact-portal.js +1184 -0
- package/v3/bridge.db +0 -0
- package/v3/constants.js +170 -0
- package/v3/db.js +76 -0
- package/v3/file-io.js +216 -0
- package/v3/helpers.js +174 -0
- package/v3/operator.js +364 -0
- package/v3/orchestrator.js +2886 -0
- package/v3/plan-compiler.js +440 -0
- package/v3/prompt-builder.js +849 -0
- package/v3/queries.js +461 -0
- package/v3/recovery.js +508 -0
- package/v3/review-sessions.js +360 -0
- package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
- package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
- package/v3/roles/architect/CLAUDE.md +809 -0
- package/v3/roles/backend-implementer/CLAUDE.md +97 -0
- package/v3/roles/code-reviewer/CLAUDE.md +89 -0
- package/v3/roles/database-implementer/CLAUDE.md +97 -0
- package/v3/roles/deployer/CLAUDE.md +42 -0
- package/v3/roles/designer/CLAUDE.md +386 -0
- package/v3/roles/documentation/CLAUDE.md +40 -0
- package/v3/roles/feature-lead/CLAUDE.md +233 -0
- package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
- package/v3/roles/implementer/CLAUDE.md +97 -0
- package/v3/roles/integration-tester/CLAUDE.md +174 -0
- package/v3/roles/observability-engineer/CLAUDE.md +40 -0
- package/v3/roles/operator/CLAUDE.md +322 -0
- package/v3/roles/orchestrator/CLAUDE.md +288 -0
- package/v3/roles/package-implementer/CLAUDE.md +47 -0
- package/v3/roles/performance-analyst/CLAUDE.md +49 -0
- package/v3/roles/plan-compiler/CLAUDE.md +163 -0
- package/v3/roles/planning-lead/CLAUDE.md +41 -0
- package/v3/roles/pm/CLAUDE.md +806 -0
- package/v3/roles/regression-tester/CLAUDE.md +135 -0
- package/v3/roles/release-manager/CLAUDE.md +43 -0
- package/v3/roles/security-auditor/CLAUDE.md +90 -0
- package/v3/roles/smoke-tester/CLAUDE.md +97 -0
- package/v3/roles/test-author/CLAUDE.md +42 -0
- package/v3/roles/verifier/CLAUDE.md +90 -0
- package/v3/schema.sql +134 -0
- package/v3/slack-adapter.js +510 -0
- 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
|
+
}
|