hive-lite 0.1.9 → 0.2.1

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.
@@ -0,0 +1,1156 @@
1
+ const cp = require('child_process');
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { createId } = require('./id');
7
+ const { readIntentTarget, validateIntentTarget } = require('./intent');
8
+ const { ensureDir, exists, readJson, readText, writeJson, writeText } = require('./fsx');
9
+ const { runGitRaw } = require('./git');
10
+
11
+ const STEP_RESULT_SCHEMA_VERSION = 'hive.operator.step_result.v1';
12
+ const RUN_SCHEMA_VERSION = 'hive.operator.run.v1';
13
+
14
+ const SKILLS = {
15
+ start: 'hive-lite-start-prompt',
16
+ finish: 'hive-lite-finish',
17
+ };
18
+
19
+ const STATUS_ENUM = new Set([
20
+ 'prompt_ready',
21
+ 'split_required',
22
+ 'map_update_required',
23
+ 'needs_human_action',
24
+ 'accept_ready',
25
+ 'risk_acceptance_required',
26
+ 'blocked',
27
+ 'failed',
28
+ 'accepted',
29
+ 'done',
30
+ ]);
31
+
32
+ const RECOMMENDED_ACTION_ENUM = new Set([
33
+ 'copy_coding_prompt',
34
+ 'start_ready_phase',
35
+ 'manual_review_required',
36
+ 'accept',
37
+ 'accept_risk',
38
+ 'accept_with_risk',
39
+ 'review_reason_required',
40
+ 'revise_required',
41
+ 'map_update_required',
42
+ 'stop',
43
+ 'retry',
44
+ 'none',
45
+ ]);
46
+
47
+ const ARTIFACT_FILENAMES = {
48
+ coding_agent_prompt_path: 'coding-agent-prompt.md',
49
+ review_packet_path: 'review-packet.md',
50
+ handoff_prompt_path: 'handoff-prompt.md',
51
+ start_intent_path: 'start-intent.md',
52
+ map_update_required_path: 'map-update-required.md',
53
+ };
54
+
55
+ const DEFAULT_AGENT_TIMEOUT_MS = 10 * 60 * 1000;
56
+
57
+ function rel(root, file) {
58
+ return path.relative(root, file).replace(/\\/g, '/');
59
+ }
60
+
61
+ function operatorRoot(root) {
62
+ return path.join(root, '.hive', 'state', 'operator');
63
+ }
64
+
65
+ function runsRoot(root) {
66
+ return path.join(operatorRoot(root), 'runs');
67
+ }
68
+
69
+ function currentRoot(root) {
70
+ return path.join(operatorRoot(root), 'current');
71
+ }
72
+
73
+ function resolveRepoPath(root, value) {
74
+ if (!value || typeof value !== 'string') return null;
75
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(root, value);
76
+ }
77
+
78
+ function assertOperatorPath(root, value, label) {
79
+ const resolved = resolveRepoPath(root, value);
80
+ if (!resolved) throw new Error(`${label} is required`);
81
+ const relative = path.relative(operatorRoot(root), resolved).replace(/\\/g, '/');
82
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
83
+ throw new Error(`${label} must be inside .hive/state/operator/: ${value}`);
84
+ }
85
+ return resolved;
86
+ }
87
+
88
+ function sha256(file) {
89
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
90
+ }
91
+
92
+ function splitCommand(commandText) {
93
+ const text = String(commandText || '').trim();
94
+ if (!text) return [];
95
+ const parts = [];
96
+ const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
97
+ let match;
98
+ while ((match = pattern.exec(text))) {
99
+ const value = match[1] !== undefined ? match[1] : match[2] !== undefined ? match[2] : match[3];
100
+ parts.push(value.replace(/\\(["'\\])/g, '$1'));
101
+ }
102
+ return parts;
103
+ }
104
+
105
+ function parsePositiveInteger(value, label) {
106
+ if (value === undefined || value === null || value === '') return null;
107
+ const parsed = Number(value);
108
+ if (!Number.isInteger(parsed) || parsed < 0) {
109
+ throw new Error(`${label} must be a non-negative integer number of milliseconds.`);
110
+ }
111
+ return parsed;
112
+ }
113
+
114
+ function firstConfigured(values) {
115
+ return values.find((value) => value !== undefined && value !== null && value !== '');
116
+ }
117
+
118
+ function resolveAgentConfig(options = {}, env = process.env) {
119
+ const commandText = options.operatorAgentCommand
120
+ || options.agentCommand
121
+ || env.HIVE_LITE_OPERATOR_AGENT_COMMAND
122
+ || env.HIVE_OPERATOR_AGENT_COMMAND
123
+ || '';
124
+ if (!commandText) {
125
+ throw new Error('No operator agent command configured. Set HIVE_LITE_OPERATOR_AGENT_COMMAND or pass --operator-agent-command "<command>".');
126
+ }
127
+ const argv = splitCommand(commandText);
128
+ if (argv.length === 0) {
129
+ throw new Error('No operator agent command configured. Set HIVE_LITE_OPERATOR_AGENT_COMMAND or pass --operator-agent-command "<command>".');
130
+ }
131
+ const mode = options.operatorAgentMode
132
+ || options.agentMode
133
+ || env.HIVE_LITE_OPERATOR_AGENT_MODE
134
+ || env.HIVE_OPERATOR_AGENT_MODE
135
+ || 'stdin';
136
+ if (!['stdin', 'prompt_file'].includes(mode)) {
137
+ throw new Error(`Unsupported operator agent mode: ${mode}. Use stdin or prompt_file.`);
138
+ }
139
+ const commandName = path.basename(argv[0]);
140
+ if (mode === 'stdin' && commandName === 'codex' && !argv.slice(1).some((arg) => arg === 'exec' || arg === 'e')) {
141
+ throw new Error('Codex interactive CLI requires a terminal when stdin is piped. Use HIVE_LITE_OPERATOR_AGENT_COMMAND="codex -a never -s workspace-write exec" with HIVE_LITE_OPERATOR_AGENT_MODE="stdin", or pass --operator-agent-command "codex -a never -s workspace-write exec".');
142
+ }
143
+ const timeoutMs = parsePositiveInteger(
144
+ firstConfigured([
145
+ options.operatorAgentTimeoutMs,
146
+ options.agentTimeoutMs,
147
+ env.HIVE_LITE_OPERATOR_AGENT_TIMEOUT_MS,
148
+ env.HIVE_OPERATOR_AGENT_TIMEOUT_MS,
149
+ ]),
150
+ 'operator agent timeout',
151
+ ) ?? DEFAULT_AGENT_TIMEOUT_MS;
152
+ return {
153
+ commandText,
154
+ command: argv[0],
155
+ args: argv.slice(1),
156
+ mode,
157
+ timeoutMs,
158
+ };
159
+ }
160
+
161
+ function createRun(root, input) {
162
+ const runId = createId('oprun');
163
+ const runDir = path.join(runsRoot(root), runId);
164
+ const currentDir = currentRoot(root);
165
+ ensureDir(runDir);
166
+ ensureDir(path.join(runDir, 'input'));
167
+ ensureDir(path.join(runDir, 'steps'));
168
+ ensureDir(currentDir);
169
+ const run = {
170
+ schema_version: RUN_SCHEMA_VERSION,
171
+ id: runId,
172
+ status: 'running',
173
+ created_at: new Date().toISOString(),
174
+ input,
175
+ paths: {
176
+ run_dir: rel(root, runDir),
177
+ current_dir: rel(root, currentDir),
178
+ },
179
+ step_counts: {},
180
+ context: {},
181
+ };
182
+ writeJson(path.join(runDir, 'run.json'), run);
183
+ writeJson(path.join(currentDir, 'run.json'), run);
184
+ return {
185
+ ...run,
186
+ root,
187
+ runDir,
188
+ currentDir,
189
+ };
190
+ }
191
+
192
+ function saveRun(run) {
193
+ const data = {
194
+ schema_version: run.schema_version,
195
+ id: run.id,
196
+ status: run.status,
197
+ created_at: run.created_at,
198
+ updated_at: new Date().toISOString(),
199
+ input: run.input,
200
+ paths: run.paths,
201
+ step_counts: run.step_counts,
202
+ context: run.context,
203
+ };
204
+ writeJson(path.join(run.runDir, 'run.json'), data);
205
+ writeJson(path.join(run.currentDir, 'run.json'), data);
206
+ }
207
+
208
+ function nextStepName(run, base) {
209
+ const count = (run.step_counts[base] || 0) + 1;
210
+ run.step_counts[base] = count;
211
+ saveRun(run);
212
+ return count === 1 ? base : `${base}-${count}`;
213
+ }
214
+
215
+ function stepPaths(run, stepName) {
216
+ const stepDir = path.join(run.runDir, 'steps', stepName);
217
+ return {
218
+ stepDir,
219
+ artifactsDir: path.join(stepDir, 'artifacts'),
220
+ invocationPath: path.join(stepDir, 'invocation.md'),
221
+ stdoutPath: path.join(stepDir, 'stdout.log'),
222
+ stderrPath: path.join(stepDir, 'stderr.log'),
223
+ resultPath: path.join(stepDir, 'step-result.json'),
224
+ };
225
+ }
226
+
227
+ function writeRequirementInput(run, requirement) {
228
+ const file = path.join(run.runDir, 'input', 'requirement.md');
229
+ const current = path.join(run.currentDir, 'requirement.md');
230
+ const value = `${String(requirement).trim()}\n`;
231
+ writeText(file, value);
232
+ writeText(current, value);
233
+ return {
234
+ requirement_path: rel(run.root, file),
235
+ current_requirement_path: rel(run.root, current),
236
+ };
237
+ }
238
+
239
+ function absoluteFromDisplayPath(root, displayPath) {
240
+ if (!displayPath) return null;
241
+ if (path.isAbsolute(displayPath)) return displayPath;
242
+ return path.resolve(root, displayPath);
243
+ }
244
+
245
+ function copyIntentInput(run, cwd, target) {
246
+ const validation = validateIntentTarget(run.root, cwd, target);
247
+ if (!validation.valid) {
248
+ const errors = validation.errors.map((item) => `${item.code}: ${item.message}`).join('; ');
249
+ throw new Error(`Start Intent Handoff is invalid: ${errors || 'validation failed'}`);
250
+ }
251
+ const shown = readIntentTarget(run.root, cwd, target);
252
+ const inputMarkdown = path.join(run.runDir, 'input', 'start-intent.md');
253
+ const inputJson = path.join(run.runDir, 'input', 'start-intent.json');
254
+ const currentMarkdown = path.join(run.currentDir, 'start-intent.md');
255
+ const currentJson = path.join(run.currentDir, 'start-intent.json');
256
+ writeText(inputMarkdown, shown.markdown.endsWith('\n') ? shown.markdown : `${shown.markdown}\n`);
257
+ writeText(currentMarkdown, shown.markdown.endsWith('\n') ? shown.markdown : `${shown.markdown}\n`);
258
+
259
+ const sourceJson = validation.paths.json ? absoluteFromDisplayPath(run.root, validation.paths.json) : null;
260
+ const jsonValue = sourceJson && exists(sourceJson)
261
+ ? readJson(sourceJson)
262
+ : shown;
263
+ writeJson(inputJson, jsonValue);
264
+ writeJson(currentJson, jsonValue);
265
+
266
+ return {
267
+ validation,
268
+ shown,
269
+ copied: {
270
+ input_markdown: rel(run.root, inputMarkdown),
271
+ input_json: rel(run.root, inputJson),
272
+ current_markdown: rel(run.root, currentMarkdown),
273
+ current_json: rel(run.root, currentJson),
274
+ },
275
+ };
276
+ }
277
+
278
+ function printIntentShow(io, validation, shown) {
279
+ io.stdout.write(`Start Intent Handoff: ${validation.intent_id || '(unknown)'}\n`);
280
+ io.stdout.write(`Status: ${validation.valid ? 'valid' : 'invalid'}\n`);
281
+ io.stdout.write(`Path: ${validation.paths.markdown}\n\n`);
282
+ io.stdout.write('Find Intent:\n');
283
+ io.stdout.write(`${shown.findIntent || '(none)'}\n\n`);
284
+ }
285
+
286
+ function baseInvocation(run, paths, skill, body) {
287
+ return [
288
+ '# Hive Lite Operator Step Invocation',
289
+ '',
290
+ 'Role and Objective:',
291
+ `- Run only the selected Hive Lite operator skill for this step: $${skill}.`,
292
+ '- Produce the required structured step result so the operator runner can continue.',
293
+ '- Do not automate the coding agent or treat natural-language transcript text as protocol.',
294
+ '',
295
+ 'Inputs:',
296
+ `Target repo root: ${run.root}`,
297
+ `Operator run: ${run.id}`,
298
+ `Step: ${path.basename(paths.stepDir)}`,
299
+ `Selected skill: $${skill}`,
300
+ '',
301
+ 'Invoke this operator skill in a fresh session:',
302
+ '',
303
+ '```text',
304
+ body.trimEnd(),
305
+ '```',
306
+ '',
307
+ 'Runner contract:',
308
+ '- This is an operator-agent session for Hive Lite skills only.',
309
+ '- Do not launch, supervise, or automate any coding agent.',
310
+ '- Do not run the discovery skill inside this operator run.',
311
+ '- Do not wait for human input inside the agent session.',
312
+ '- Write the structured step result, then exit cleanly.',
313
+ '- Do not rely on natural-language stdout/stderr as workflow protocol.',
314
+ '',
315
+ 'Completion contract:',
316
+ '- Write every referenced artifact under .hive/state/operator/.',
317
+ '- Write `step-result.json` to the exact Step result path below.',
318
+ '- If the step cannot produce a prompt/accept/manual-action result, write a map-update, blocked, or failed result with the concrete next action.',
319
+ '- Stop after writing the result; the runner handles user interaction.',
320
+ '',
321
+ `Step result path: ${rel(run.root, paths.resultPath)}`,
322
+ `Artifacts directory: ${rel(run.root, paths.artifactsDir)}`,
323
+ `Current artifacts directory: ${rel(run.root, run.currentDir)}`,
324
+ `Step-result schema_version: ${STEP_RESULT_SCHEMA_VERSION}`,
325
+ '',
326
+ 'Allowed result statuses include:',
327
+ '- prompt_ready',
328
+ '- split_required',
329
+ '- map_update_required',
330
+ '- needs_human_action',
331
+ '- accept_ready',
332
+ '- risk_acceptance_required',
333
+ '- blocked',
334
+ '- failed',
335
+ '- accepted',
336
+ '- done',
337
+ '',
338
+ 'Required result shapes:',
339
+ '- For `map_update_required`, write a human-readable artifact first, then write `step-result.json` like:',
340
+ '```json',
341
+ JSON.stringify({
342
+ schema_version: STEP_RESULT_SCHEMA_VERSION,
343
+ skill,
344
+ status: 'map_update_required',
345
+ recommended_action: 'map_update_required',
346
+ artifacts: {
347
+ map_update_required_path: rel(run.root, path.join(paths.artifactsDir, 'map-update-required.md')),
348
+ },
349
+ map_update: {
350
+ changed_files: ['.hive/map/areas.yaml'],
351
+ reason: 'Project Map changed and must be committed or stashed before continuing.',
352
+ },
353
+ }, null, 2),
354
+ '```',
355
+ '- For `blocked`, write `recommended_action: revise_required` and include `blockers` with concrete next steps.',
356
+ '- For `failed`, use only unexpected tool/runtime failures that prevent a normal structured result.',
357
+ '',
358
+ 'Artifact paths in step-result.json must be under .hive/state/operator/.',
359
+ '',
360
+ ].join('\n');
361
+ }
362
+
363
+ function startInvocationBody(input) {
364
+ if (input.type === 'intent') {
365
+ return [
366
+ '$hive-lite-start-prompt',
367
+ `Use Start Intent Handoff: ${input.intentPath}`,
368
+ '',
369
+ `Original intent target: ${input.originalTarget}`,
370
+ ].join('\n');
371
+ }
372
+ if (input.type === 'handoff') {
373
+ return [
374
+ '$hive-lite-start-prompt',
375
+ input.handoffText,
376
+ ].filter(Boolean).join('\n');
377
+ }
378
+ return [
379
+ '$hive-lite-start-prompt',
380
+ input.requirement,
381
+ ].join('\n');
382
+ }
383
+
384
+ function finishInvocationBody(run, action = {}) {
385
+ const lines = ['$hive-lite-finish'];
386
+ if (run.context.ctx_id) lines.push(`Context: ${run.context.ctx_id}`);
387
+ if (run.context.chg_id) lines.push(`Change: ${run.context.chg_id}`);
388
+ if (action.kind) {
389
+ lines.push('');
390
+ lines.push(`Runner human action: ${action.kind}`);
391
+ if (action.note) lines.push(`Human note: ${action.note}`);
392
+ if (action.reason) lines.push(`Human reason: ${action.reason}`);
393
+ if (action.chg_id) lines.push(`Change: ${action.chg_id}`);
394
+ } else {
395
+ lines.push('');
396
+ lines.push('The human has confirmed the coding agent is done editing.');
397
+ }
398
+ return lines.join('\n');
399
+ }
400
+
401
+ function createInvocation(run, paths, skill, body) {
402
+ ensureDir(paths.artifactsDir);
403
+ const value = baseInvocation(run, paths, skill, body);
404
+ writeText(paths.invocationPath, value);
405
+ return value;
406
+ }
407
+
408
+ function gitOutput(root, args) {
409
+ try {
410
+ return runGitRaw(args, { cwd: root }).trim();
411
+ } catch {
412
+ return '';
413
+ }
414
+ }
415
+
416
+ function gitRawOutput(root, args) {
417
+ try {
418
+ return runGitRaw(args, { cwd: root });
419
+ } catch {
420
+ return '';
421
+ }
422
+ }
423
+
424
+ function statusLines(root) {
425
+ const output = gitRawOutput(root, ['status', '--porcelain', '-uall']);
426
+ return output ? output.split(/\r?\n/).filter(Boolean).sort() : [];
427
+ }
428
+
429
+ function isAllowedSkillWrite(skill, file) {
430
+ const clean = String(file || '').replace(/^"|"$/g, '');
431
+ if (clean.startsWith('.hive/state/operator/')) return true;
432
+ if (skill === SKILLS.start) {
433
+ return clean.startsWith('.hive/context/')
434
+ || clean.startsWith('.hive/map/');
435
+ }
436
+ if (skill === SKILLS.finish) {
437
+ return clean.startsWith('.hive/changes/')
438
+ || clean.startsWith('.hive/deltas/')
439
+ || clean.startsWith('.hive/patches/');
440
+ }
441
+ return false;
442
+ }
443
+
444
+ function normalizeStatusPath(file) {
445
+ return String(file || '')
446
+ .replace(/^"|"$/g, '')
447
+ .replace(/\\"/g, '"')
448
+ .replace(/\\\\/g, '\\');
449
+ }
450
+
451
+ function statusLinePaths(line) {
452
+ let file = String(line || '').slice(3).trim();
453
+ if (file.includes(' -> ')) {
454
+ return file.split(' -> ').map((item) => normalizeStatusPath(item.trim())).filter(Boolean);
455
+ }
456
+ return [normalizeStatusPath(file)];
457
+ }
458
+
459
+ function fileState(root, file, status = 'clean') {
460
+ const absolute = path.join(root, file);
461
+ try {
462
+ const stat = fs.lstatSync(absolute);
463
+ if (stat.isSymbolicLink()) {
464
+ return {
465
+ status,
466
+ exists: true,
467
+ kind: 'symlink',
468
+ hash: crypto.createHash('sha256').update(fs.readlinkSync(absolute)).digest('hex'),
469
+ };
470
+ }
471
+ if (stat.isFile()) {
472
+ return {
473
+ status,
474
+ exists: true,
475
+ kind: 'file',
476
+ hash: sha256(absolute),
477
+ };
478
+ }
479
+ return {
480
+ status,
481
+ exists: true,
482
+ kind: stat.isDirectory() ? 'directory' : 'other',
483
+ hash: null,
484
+ };
485
+ } catch {
486
+ return {
487
+ status,
488
+ exists: false,
489
+ kind: 'missing',
490
+ hash: null,
491
+ };
492
+ }
493
+ }
494
+
495
+ function statusSnapshot(root) {
496
+ const files = {};
497
+ for (const line of statusLines(root)) {
498
+ const status = line.slice(0, 2);
499
+ for (const file of statusLinePaths(line)) {
500
+ files[file] = fileState(root, file, status);
501
+ }
502
+ }
503
+ return {
504
+ head: gitOutput(root, ['rev-parse', 'HEAD']) || null,
505
+ files,
506
+ };
507
+ }
508
+
509
+ function stateSignature(state) {
510
+ return JSON.stringify([
511
+ state.status,
512
+ state.exists,
513
+ state.kind,
514
+ state.hash,
515
+ ]);
516
+ }
517
+
518
+ function changedHeadPaths(root, beforeHead, afterHead) {
519
+ if (!beforeHead || !afterHead || beforeHead === afterHead) return [];
520
+ const output = gitOutput(root, ['diff', '--name-only', beforeHead, afterHead, '--']);
521
+ return output ? output.split(/\r?\n/).filter(Boolean) : [];
522
+ }
523
+
524
+ function validateSkillAuthority(root, skill, before, after) {
525
+ const violations = new Set();
526
+ const paths = new Set([
527
+ ...Object.keys(before.files || {}),
528
+ ...Object.keys(after.files || {}),
529
+ ]);
530
+ for (const file of paths) {
531
+ if (isAllowedSkillWrite(skill, file)) continue;
532
+ const beforeState = before.files[file] || fileState(root, file, 'clean');
533
+ const afterState = after.files[file] || fileState(root, file, 'clean');
534
+ if (stateSignature(beforeState) !== stateSignature(afterState)) violations.add(file);
535
+ }
536
+ for (const file of changedHeadPaths(root, before.head, after.head)) {
537
+ if (!isAllowedSkillWrite(skill, file)) violations.add(file);
538
+ }
539
+ if (violations.size > 0) {
540
+ throw new Error(`Operator skill modified files outside its authority: ${[...violations].sort().join(', ')}`);
541
+ }
542
+ }
543
+
544
+ function runSubprocess(command, args, options) {
545
+ return new Promise((resolve) => {
546
+ const child = cp.spawn(command, args, {
547
+ cwd: options.cwd,
548
+ stdio: ['pipe', 'pipe', 'pipe'],
549
+ env: {
550
+ ...process.env,
551
+ HIVE_LITE_OPERATOR_RUN_ID: options.runId,
552
+ HIVE_LITE_OPERATOR_STEP_RESULT: options.resultPath,
553
+ HIVE_LITE_OPERATOR_ARTIFACTS_DIR: options.artifactsDir,
554
+ },
555
+ });
556
+ const stdout = fs.createWriteStream(options.stdoutPath);
557
+ const stderr = fs.createWriteStream(options.stderrPath);
558
+ let spawnError = null;
559
+ let timedOut = false;
560
+ let killTimer = null;
561
+ const timeoutMs = Number.isInteger(options.timeoutMs) ? options.timeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
562
+ const timeoutTimer = timeoutMs > 0
563
+ ? setTimeout(() => {
564
+ timedOut = true;
565
+ child.kill('SIGTERM');
566
+ killTimer = setTimeout(() => {
567
+ child.kill('SIGKILL');
568
+ }, Math.min(2000, timeoutMs));
569
+ if (killTimer.unref) killTimer.unref();
570
+ }, timeoutMs)
571
+ : null;
572
+ if (timeoutTimer && timeoutTimer.unref) timeoutTimer.unref();
573
+ child.on('error', (error) => {
574
+ spawnError = error;
575
+ });
576
+ child.stdout.pipe(stdout);
577
+ child.stderr.pipe(stderr);
578
+ if (options.stdin !== null && options.stdin !== undefined) {
579
+ child.stdin.end(options.stdin);
580
+ } else {
581
+ child.stdin.end();
582
+ }
583
+ child.on('close', (code, signal) => {
584
+ if (timeoutTimer) clearTimeout(timeoutTimer);
585
+ if (killTimer) clearTimeout(killTimer);
586
+ stdout.end();
587
+ stderr.end();
588
+ resolve({ code, signal, error: spawnError, timedOut, timeoutMs });
589
+ });
590
+ });
591
+ }
592
+
593
+ async function runAgentStep(run, baseStep, skill, input, agentConfig, io) {
594
+ const stepName = nextStepName(run, baseStep);
595
+ const paths = stepPaths(run, stepName);
596
+ ensureDir(paths.stepDir);
597
+ ensureDir(paths.artifactsDir);
598
+
599
+ const body = skill === SKILLS.start ? startInvocationBody(input) : finishInvocationBody(run, input);
600
+ const invocation = createInvocation(run, paths, skill, body);
601
+ io.stdout.write(`Running ${skill}...\n`);
602
+
603
+ const args = [...agentConfig.args];
604
+ let stdin = null;
605
+ if (agentConfig.mode === 'stdin') {
606
+ stdin = invocation;
607
+ } else {
608
+ const promptTokenIndex = args.findIndex((arg) => arg.includes('{prompt_file}'));
609
+ if (promptTokenIndex === -1) args.push(paths.invocationPath);
610
+ else args[promptTokenIndex] = args[promptTokenIndex].replace(/\{prompt_file\}/g, paths.invocationPath);
611
+ }
612
+
613
+ io.stdout.write('[OK] agent session started\n');
614
+ io.stdout.write(`[..] waiting for structured result (logs: ${rel(run.root, paths.stdoutPath)}, ${rel(run.root, paths.stderrPath)})\n`);
615
+ const before = statusSnapshot(run.root);
616
+ const proc = await runSubprocess(agentConfig.command, args, {
617
+ cwd: run.root,
618
+ stdin,
619
+ stdoutPath: paths.stdoutPath,
620
+ stderrPath: paths.stderrPath,
621
+ resultPath: paths.resultPath,
622
+ artifactsDir: paths.artifactsDir,
623
+ runId: run.id,
624
+ timeoutMs: agentConfig.timeoutMs,
625
+ });
626
+
627
+ const after = statusSnapshot(run.root);
628
+ validateSkillAuthority(run.root, skill, before, after);
629
+
630
+ if (proc.error) {
631
+ throw new Error(`Operator agent failed to start: ${proc.error.message}`);
632
+ }
633
+ if (proc.timedOut) {
634
+ throw new Error(`Operator agent timed out after ${proc.timeoutMs}ms before completing the step. Transcript logs: ${rel(run.root, paths.stdoutPath)}, ${rel(run.root, paths.stderrPath)}. Expected result: ${rel(run.root, paths.resultPath)}`);
635
+ }
636
+ if (proc.code !== 0) {
637
+ throw new Error(`Operator agent exited with code ${proc.code}. Transcript logs: ${rel(run.root, paths.stdoutPath)}, ${rel(run.root, paths.stderrPath)}`);
638
+ }
639
+
640
+ return validateAndLoadStepResult(run, paths, skill);
641
+ }
642
+
643
+ function collectPathFields(value, fields = []) {
644
+ if (!value || typeof value !== 'object') return fields;
645
+ if (Array.isArray(value)) {
646
+ for (const item of value) collectPathFields(item, fields);
647
+ return fields;
648
+ }
649
+ for (const [key, child] of Object.entries(value)) {
650
+ if (key === 'artifact_hashes' || key === 'artifactHashes') continue;
651
+ if (key.endsWith('_path') && typeof child === 'string') fields.push({ key, value: child });
652
+ else collectPathFields(child, fields);
653
+ }
654
+ return fields;
655
+ }
656
+
657
+ function validateArtifactHashes(root, result, pathFields) {
658
+ const hashes = result.artifact_hashes || result.artifactHashes || {};
659
+ for (const field of pathFields) {
660
+ const expected = hashes[field.key] || hashes[field.value];
661
+ if (!expected) continue;
662
+ const file = resolveRepoPath(root, field.value);
663
+ if (!exists(file)) throw new Error(`artifact for hash check does not exist: ${field.value}`);
664
+ if (sha256(file) !== expected) throw new Error(`artifact hash mismatch: ${field.value}`);
665
+ }
666
+ }
667
+
668
+ function validateAndLoadStepResult(run, paths, expectedSkill) {
669
+ if (!exists(paths.resultPath)) {
670
+ throw new Error(`Operator step failed: missing step-result.json at ${rel(run.root, paths.resultPath)}. Transcript logs: ${rel(run.root, paths.stdoutPath)}, ${rel(run.root, paths.stderrPath)}`);
671
+ }
672
+ let result;
673
+ try {
674
+ result = readJson(paths.resultPath);
675
+ } catch (error) {
676
+ throw new Error(`Operator step failed: invalid step-result.json at ${rel(run.root, paths.resultPath)}: ${error.message}`);
677
+ }
678
+ if (result.schema_version !== STEP_RESULT_SCHEMA_VERSION) {
679
+ throw new Error(`Operator step failed: unsupported step-result schema_version ${result.schema_version || '(missing)'}`);
680
+ }
681
+ if (result.skill !== expectedSkill) {
682
+ throw new Error(`Operator step failed: expected skill ${expectedSkill}, got ${result.skill || '(missing)'}`);
683
+ }
684
+ if (!STATUS_ENUM.has(result.status)) {
685
+ throw new Error(`Operator step failed: invalid status ${result.status || '(missing)'}`);
686
+ }
687
+ if (!RECOMMENDED_ACTION_ENUM.has(result.recommended_action || 'none')) {
688
+ throw new Error(`Operator step failed: invalid recommended_action ${result.recommended_action || '(missing)'}`);
689
+ }
690
+ validateRecommendedAction(result);
691
+
692
+ const pathFields = collectPathFields(result);
693
+ for (const field of pathFields) {
694
+ assertOperatorPath(run.root, field.value, field.key);
695
+ if (!exists(resolveRepoPath(run.root, field.value))) {
696
+ throw new Error(`Operator step failed: artifact path does not exist: ${field.value}`);
697
+ }
698
+ }
699
+ validateArtifactHashes(run.root, result, pathFields);
700
+ validateRequiredArtifacts(result);
701
+ syncCurrentArtifacts(run.root, result, paths.resultPath);
702
+
703
+ if (result.ctx_id) run.context.ctx_id = result.ctx_id;
704
+ if (result.chg_id) run.context.chg_id = result.chg_id;
705
+ if (result.split && result.split.id) run.context.split_id = result.split.id;
706
+ saveRun(run);
707
+ return result;
708
+ }
709
+
710
+ function validateRecommendedAction(result) {
711
+ const allowedByStatus = {
712
+ prompt_ready: new Set(['copy_coding_prompt']),
713
+ split_required: new Set(['start_ready_phase']),
714
+ map_update_required: new Set(['map_update_required']),
715
+ needs_human_action: new Set(['manual_review_required', 'review_reason_required']),
716
+ accept_ready: new Set(['accept']),
717
+ risk_acceptance_required: new Set(['accept_risk', 'accept_with_risk']),
718
+ blocked: new Set(['revise_required']),
719
+ };
720
+ const allowed = allowedByStatus[result.status];
721
+ if (!allowed) return;
722
+ if (!result.recommended_action) {
723
+ throw new Error(`Operator step failed: ${result.status} requires recommended_action`);
724
+ }
725
+ if (!allowed.has(result.recommended_action)) {
726
+ throw new Error(`Operator step failed: ${result.status} cannot use recommended_action ${result.recommended_action}`);
727
+ }
728
+ }
729
+
730
+ function validateRequiredArtifacts(result) {
731
+ if (result.status === 'prompt_ready') {
732
+ const promptPath = result.artifacts && result.artifacts.coding_agent_prompt_path;
733
+ if (!promptPath) throw new Error('Operator step failed: prompt_ready requires artifacts.coding_agent_prompt_path');
734
+ }
735
+ if (result.status === 'split_required' && result.recommended_action === 'start_ready_phase') {
736
+ const phase = result.split && result.split.ready_phase;
737
+ if (!phase || !phase.handoff_prompt_path) {
738
+ throw new Error('Operator step failed: split_required requires split.ready_phase.handoff_prompt_path');
739
+ }
740
+ }
741
+ if (result.status === 'map_update_required') {
742
+ const mapUpdatePath = result.artifacts && result.artifacts.map_update_required_path;
743
+ if (!mapUpdatePath) throw new Error('Operator step failed: map_update_required requires artifacts.map_update_required_path');
744
+ }
745
+ }
746
+
747
+ function syncCurrentArtifacts(root, result, resultPath) {
748
+ ensureDir(currentRoot(root));
749
+ for (const [key, filename] of Object.entries(ARTIFACT_FILENAMES)) {
750
+ const artifactPath = result.artifacts && result.artifacts[key];
751
+ if (!artifactPath) continue;
752
+ const source = resolveRepoPath(root, artifactPath);
753
+ const target = path.join(currentRoot(root), filename);
754
+ if (path.resolve(source) !== path.resolve(target)) {
755
+ fs.copyFileSync(source, target);
756
+ }
757
+ }
758
+ fs.copyFileSync(resultPath, path.join(currentRoot(root), 'step-result.json'));
759
+ }
760
+
761
+ class ActionInput {
762
+ constructor(input, output) {
763
+ this.output = output;
764
+ this.nonInteractive = !input.isTTY;
765
+ this.closed = false;
766
+ if (this.nonInteractive) {
767
+ let text = '';
768
+ try {
769
+ text = fs.readFileSync(0, 'utf8');
770
+ } catch {
771
+ text = '';
772
+ }
773
+ this.lines = text.length ? text.split(/\r?\n/) : [];
774
+ } else {
775
+ this.rl = readline.createInterface({ input, output });
776
+ }
777
+ }
778
+
779
+ async ask(prompt, fallback = 'q') {
780
+ if (this.nonInteractive) {
781
+ const value = this.lines.length > 0 ? this.lines.shift() : fallback;
782
+ if (prompt) this.output.write(prompt);
783
+ return value;
784
+ }
785
+ return new Promise((resolve) => {
786
+ this.rl.question(prompt || '', (answer) => resolve(answer));
787
+ });
788
+ }
789
+
790
+ close() {
791
+ if (this.rl && !this.closed) this.rl.close();
792
+ this.closed = true;
793
+ }
794
+ }
795
+
796
+ function artifactPath(root, result, key) {
797
+ const value = result.artifacts && result.artifacts[key];
798
+ return value ? resolveRepoPath(root, value) : null;
799
+ }
800
+
801
+ function displayArtifact(root, result, key) {
802
+ if (ARTIFACT_FILENAMES[key]) {
803
+ const stable = path.join(currentRoot(root), ARTIFACT_FILENAMES[key]);
804
+ if (exists(stable)) return rel(root, stable);
805
+ }
806
+ const value = result.artifacts && result.artifacts[key];
807
+ return value || rel(root, path.join(currentRoot(root), ARTIFACT_FILENAMES[key]));
808
+ }
809
+
810
+ function copyPromptToClipboard(text) {
811
+ const platform = process.platform;
812
+ const candidates = platform === 'darwin'
813
+ ? [['pbcopy', []]]
814
+ : platform === 'win32'
815
+ ? [['clip', []]]
816
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
817
+ for (const [command, args] of candidates) {
818
+ try {
819
+ const result = cp.spawnSync(command, args, {
820
+ input: text,
821
+ encoding: 'utf8',
822
+ stdio: ['pipe', 'ignore', 'ignore'],
823
+ });
824
+ if (result.status === 0) return true;
825
+ } catch {
826
+ // Try the next platform clipboard helper.
827
+ }
828
+ }
829
+ return false;
830
+ }
831
+
832
+ async function renderPromptReady(run, result, agentConfig, input, io) {
833
+ const promptFile = artifactPath(run.root, result, 'coding_agent_prompt_path');
834
+ const promptDisplay = displayArtifact(run.root, result, 'coding_agent_prompt_path');
835
+ io.stdout.write('\nCoding Prompt Ready\n\n');
836
+ io.stdout.write('Prompt\n');
837
+ io.stdout.write(` ${promptDisplay}\n\n`);
838
+ if (result.summary) {
839
+ io.stdout.write('Summary\n');
840
+ for (const line of String(result.summary).split(/\r?\n/)) io.stdout.write(` ${line}\n`);
841
+ io.stdout.write('\n');
842
+ }
843
+ for (;;) {
844
+ io.stdout.write('Actions\n');
845
+ io.stdout.write(' [Enter] Copy prompt\n');
846
+ io.stdout.write(' [o] Open full prompt\n');
847
+ io.stdout.write(' [q] Quit\n');
848
+ const choice = (await input.ask('> ', '')).trim().toLowerCase();
849
+ if (choice === 'q') return null;
850
+ if (choice === 'o') {
851
+ io.stdout.write('\nFull Prompt\n\n');
852
+ io.stdout.write(readText(promptFile));
853
+ if (!readText(promptFile).endsWith('\n')) io.stdout.write('\n');
854
+ io.stdout.write('\n');
855
+ continue;
856
+ }
857
+ const text = readText(promptFile);
858
+ const copied = copyPromptToClipboard(text);
859
+ writeText(path.join(run.currentDir, 'copied-coding-agent-prompt.md'), text);
860
+ io.stdout.write('\nPrompt copied.\n');
861
+ if (!copied) io.stdout.write(`Clipboard unavailable; prompt copy is saved at ${rel(run.root, path.join(run.currentDir, 'copied-coding-agent-prompt.md'))}.\n`);
862
+ io.stdout.write('\nSend it to your coding agent.\n');
863
+ io.stdout.write('Return here when coding is finished.\n\n');
864
+ io.stdout.write('Actions\n');
865
+ io.stdout.write(' [Enter] Coding is done\n');
866
+ io.stdout.write(' [q] Quit\n');
867
+ const done = (await input.ask('> ', '')).trim().toLowerCase();
868
+ if (done === 'q') return null;
869
+ return runAgentStep(run, 'finish', SKILLS.finish, {}, agentConfig, io);
870
+ }
871
+ }
872
+
873
+ async function renderSplitRequired(run, result, agentConfig, input, io) {
874
+ const ready = result.split && result.split.ready_phase;
875
+ io.stdout.write('\nRequirement Split\n\n');
876
+ if (ready) {
877
+ io.stdout.write('Ready\n');
878
+ io.stdout.write(` ${ready.id || 'phase'}${ready.title ? `: ${ready.title}` : ''}\n\n`);
879
+ }
880
+ const waiting = result.split && Array.isArray(result.split.waiting_phases) ? result.split.waiting_phases : [];
881
+ if (waiting.length > 0) {
882
+ io.stdout.write('Waiting\n');
883
+ for (const phase of waiting) io.stdout.write(` ${phase.id}${phase.title ? `: ${phase.title}` : ''}\n`);
884
+ io.stdout.write('\n');
885
+ }
886
+ io.stdout.write('Actions\n');
887
+ io.stdout.write(` [Enter] Start ${ready && ready.id ? ready.id : 'ready phase'}\n`);
888
+ io.stdout.write(' [q] Quit\n');
889
+ const choice = (await input.ask('> ', '')).trim().toLowerCase();
890
+ if (choice === 'q' || !ready || !ready.handoff_prompt_path) return null;
891
+ const handoffPath = resolveRepoPath(run.root, ready.handoff_prompt_path);
892
+ const handoffText = readText(handoffPath);
893
+ return runAgentStep(run, 'start', SKILLS.start, {
894
+ type: 'handoff',
895
+ handoffText,
896
+ }, agentConfig, io);
897
+ }
898
+
899
+ async function renderManualReview(run, result, agentConfig, input, io) {
900
+ io.stdout.write('\nManual Review Required\n\n');
901
+ io.stdout.write('Please verify the changed behavior manually.\n\n');
902
+ const checks = result.manual_review && Array.isArray(result.manual_review.checks)
903
+ ? result.manual_review.checks
904
+ : [];
905
+ if (checks.length > 0) {
906
+ io.stdout.write('Check\n');
907
+ for (const check of checks) io.stdout.write(` ${check}\n`);
908
+ io.stdout.write('\n');
909
+ }
910
+ io.stdout.write('Actions\n');
911
+ io.stdout.write(' [a] 已人工确认,通过\n');
912
+ io.stdout.write(' [d] 已人工确认,否决\n');
913
+ io.stdout.write(' [q] Quit\n');
914
+ const choice = (await input.ask('> ', 'q')).trim().toLowerCase();
915
+ if (choice === 'a') {
916
+ return runAgentStep(run, 'finish', SKILLS.finish, {
917
+ kind: 'manual_review_passed',
918
+ chg_id: result.chg_id,
919
+ note: 'Human selected 已人工确认,通过 in the operator runner.',
920
+ }, agentConfig, io);
921
+ }
922
+ if (choice === 'd') {
923
+ io.stdout.write('\nReview Failed\n\n');
924
+ io.stdout.write('Current change should not be accepted.\n');
925
+ io.stdout.write('Ask the coding agent to revise using the same prompt/context.\n\n');
926
+ io.stdout.write('Actions\n');
927
+ io.stdout.write(' [Enter] Coding is done\n');
928
+ io.stdout.write(' [q] Quit\n');
929
+ const done = (await input.ask('> ', '')).trim().toLowerCase();
930
+ if (done === 'q') return null;
931
+ return runAgentStep(run, 'finish', SKILLS.finish, {}, agentConfig, io);
932
+ }
933
+ return null;
934
+ }
935
+
936
+ async function renderAcceptReady(run, result, agentConfig, input, io) {
937
+ io.stdout.write('\nFinish Passed\n\n');
938
+ if (result.finish) {
939
+ if (result.finish.changed_file_count !== undefined) {
940
+ io.stdout.write('Changed\n');
941
+ io.stdout.write(` ${result.finish.changed_file_count} files\n\n`);
942
+ }
943
+ if (result.finish.evidence_verdict) {
944
+ io.stdout.write('Evidence\n');
945
+ io.stdout.write(` ${result.finish.evidence_verdict}\n\n`);
946
+ }
947
+ if (result.finish.risk_level) {
948
+ io.stdout.write('Risk\n');
949
+ io.stdout.write(` ${result.finish.risk_level}\n\n`);
950
+ }
951
+ }
952
+ io.stdout.write('Actions\n');
953
+ io.stdout.write(' [a] Accept\n');
954
+ io.stdout.write(' [q] Quit\n');
955
+ const choice = (await input.ask('> ', 'q')).trim().toLowerCase();
956
+ if (choice !== 'a') return null;
957
+ return runAgentStep(run, 'finish', SKILLS.finish, {
958
+ kind: 'accept',
959
+ chg_id: result.chg_id,
960
+ }, agentConfig, io);
961
+ }
962
+
963
+ async function renderRiskRequired(run, result, agentConfig, input, io) {
964
+ io.stdout.write('\nRisk Acceptance Required\n\n');
965
+ if (result.reason) {
966
+ io.stdout.write('Reason\n');
967
+ io.stdout.write(` ${result.reason}\n\n`);
968
+ }
969
+ io.stdout.write('Actions\n');
970
+ io.stdout.write(' [Enter] Enter risk reason\n');
971
+ io.stdout.write(' [q] Quit\n');
972
+ const choice = (await input.ask('> ', '')).trim().toLowerCase();
973
+ if (choice === 'q') return null;
974
+ let reason = '';
975
+ while (!reason) {
976
+ reason = (await input.ask('Risk reason: ', '')).trim();
977
+ if (!reason) io.stdout.write('Risk reason is required before accept-risk.\n');
978
+ if (!reason && input.nonInteractive) return null;
979
+ }
980
+ io.stdout.write('\nRisk reason recorded.\n\n');
981
+ io.stdout.write('Actions\n');
982
+ io.stdout.write(' [a] Accept with risk\n');
983
+ io.stdout.write(' [q] Quit\n');
984
+ const accept = (await input.ask('> ', 'q')).trim().toLowerCase();
985
+ if (accept !== 'a') return null;
986
+ return runAgentStep(run, 'finish', SKILLS.finish, {
987
+ kind: 'accept_risk',
988
+ chg_id: result.chg_id,
989
+ reason,
990
+ }, agentConfig, io);
991
+ }
992
+
993
+ async function renderReviewReasonRequired(run, result, agentConfig, input, io) {
994
+ io.stdout.write('\nReview Reason Required\n\n');
995
+ io.stdout.write('A human review reason is required before this change can be accepted.\n\n');
996
+ const reason = (await input.ask('Review reason: ', '')).trim();
997
+ if (!reason) return null;
998
+ return runAgentStep(run, 'finish', SKILLS.finish, {
999
+ kind: 'review_reason',
1000
+ chg_id: result.chg_id,
1001
+ reason,
1002
+ }, agentConfig, io);
1003
+ }
1004
+
1005
+ async function renderBlocked(run, result, agentConfig, input, io) {
1006
+ io.stdout.write('\nBlocked\n\n');
1007
+ const blockers = result.blockers || result.reasons || [];
1008
+ if (blockers.length > 0) {
1009
+ io.stdout.write('Reason\n');
1010
+ for (const blocker of blockers) io.stdout.write(` ${blocker}\n`);
1011
+ io.stdout.write('\n');
1012
+ }
1013
+ io.stdout.write('Current change cannot be accepted.\n\n');
1014
+ io.stdout.write('Next\n');
1015
+ io.stdout.write(' Ask the coding agent to revise the change, then return here.\n\n');
1016
+ io.stdout.write('Actions\n');
1017
+ io.stdout.write(' [Enter] Coding is done\n');
1018
+ io.stdout.write(' [q] Quit\n');
1019
+ const choice = (await input.ask('> ', 'q')).trim().toLowerCase();
1020
+ if (choice === 'q') return null;
1021
+ return runAgentStep(run, 'finish', SKILLS.finish, {}, agentConfig, io);
1022
+ }
1023
+
1024
+ async function renderMapUpdateRequired(run, result, input, io) {
1025
+ io.stdout.write('\nMap Update Required\n\n');
1026
+ const details = displayArtifact(run.root, result, 'map_update_required_path');
1027
+ io.stdout.write('Details\n');
1028
+ io.stdout.write(` ${details}\n\n`);
1029
+ const changed = result.map_update && Array.isArray(result.map_update.changed_files)
1030
+ ? result.map_update.changed_files
1031
+ : [];
1032
+ if (changed.length > 0) {
1033
+ io.stdout.write('Changed\n');
1034
+ for (const file of changed) io.stdout.write(` ${file}\n`);
1035
+ io.stdout.write('\n');
1036
+ }
1037
+ io.stdout.write('Reason\n');
1038
+ io.stdout.write(' Project Map was updated while preparing this requirement.\n');
1039
+ io.stdout.write(' Commit or stash the map update before generating the coding prompt.\n\n');
1040
+ for (;;) {
1041
+ io.stdout.write('Actions\n');
1042
+ io.stdout.write(' [o] Open details\n');
1043
+ io.stdout.write(' [q] Quit\n');
1044
+ const choice = (await input.ask('> ', 'q')).trim().toLowerCase();
1045
+ if (choice === 'o') {
1046
+ const file = artifactPath(run.root, result, 'map_update_required_path');
1047
+ const text = readText(file);
1048
+ io.stdout.write('\n');
1049
+ io.stdout.write(text);
1050
+ if (!text.endsWith('\n')) io.stdout.write('\n');
1051
+ io.stdout.write('\n');
1052
+ continue;
1053
+ }
1054
+ return null;
1055
+ }
1056
+ }
1057
+
1058
+ async function handleStepResult(run, result, agentConfig, input, io) {
1059
+ let current = result;
1060
+ while (current) {
1061
+ if (current.status === 'prompt_ready' && current.recommended_action === 'copy_coding_prompt') {
1062
+ current = await renderPromptReady(run, current, agentConfig, input, io);
1063
+ } else if (current.status === 'split_required' && current.recommended_action === 'start_ready_phase') {
1064
+ current = await renderSplitRequired(run, current, agentConfig, input, io);
1065
+ } else if (current.status === 'needs_human_action' && current.recommended_action === 'manual_review_required') {
1066
+ current = await renderManualReview(run, current, agentConfig, input, io);
1067
+ } else if (current.status === 'needs_human_action' && current.recommended_action === 'review_reason_required') {
1068
+ current = await renderReviewReasonRequired(run, current, agentConfig, input, io);
1069
+ } else if (current.status === 'accept_ready' && current.recommended_action === 'accept') {
1070
+ current = await renderAcceptReady(run, current, agentConfig, input, io);
1071
+ } else if (current.status === 'risk_acceptance_required') {
1072
+ current = await renderRiskRequired(run, current, agentConfig, input, io);
1073
+ } else if (current.status === 'blocked' || current.recommended_action === 'revise_required') {
1074
+ current = await renderBlocked(run, current, agentConfig, input, io);
1075
+ } else if (current.status === 'map_update_required') {
1076
+ current = await renderMapUpdateRequired(run, current, input, io);
1077
+ } else if (current.status === 'accepted' || current.status === 'done') {
1078
+ io.stdout.write('\nOperator step complete.\n');
1079
+ current = null;
1080
+ } else if (current.status === 'failed') {
1081
+ throw new Error('Operator skill reported failed status.');
1082
+ } else {
1083
+ io.stdout.write(`\nNo V1 action available for ${current.status}/${current.recommended_action || 'none'}.\n`);
1084
+ current = null;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ async function runOperator(cwd, options = {}, io = {}) {
1090
+ const root = options.root || cwd;
1091
+ const out = io.stdout || process.stdout;
1092
+ const err = io.stderr || process.stderr;
1093
+ const inputStream = io.stdin || process.stdin;
1094
+ const input = new ActionInput(inputStream, out);
1095
+ try {
1096
+ const hasIntent = !!options.intent;
1097
+ const requirement = String(options.requirement || '').trim();
1098
+ if (hasIntent) {
1099
+ const validation = validateIntentTarget(root, cwd, options.intent);
1100
+ if (!validation.valid) {
1101
+ const errors = validation.errors.map((item) => `${item.code}: ${item.message}`).join('; ');
1102
+ throw new Error(`Start Intent Handoff is invalid: ${errors || 'validation failed'}`);
1103
+ }
1104
+ const shown = readIntentTarget(root, cwd, options.intent);
1105
+ printIntentShow({ stdout: out, stderr: err }, validation, shown);
1106
+ } else if (!requirement) {
1107
+ throw new Error('operator run requires a clear requirement or --intent <intent_id_or_path>');
1108
+ }
1109
+
1110
+ const agentConfig = resolveAgentConfig(options, process.env);
1111
+ const run = createRun(root, hasIntent
1112
+ ? { type: 'start_intent', target: options.intent }
1113
+ : { type: 'clear_requirement' });
1114
+
1115
+ let startInput;
1116
+ if (hasIntent) {
1117
+ const copied = copyIntentInput(run, cwd, options.intent);
1118
+ run.input.intent_id = copied.validation.intent_id;
1119
+ run.input.paths = copied.copied;
1120
+ saveRun(run);
1121
+ startInput = {
1122
+ type: 'intent',
1123
+ intentPath: copied.copied.current_markdown,
1124
+ originalTarget: options.intent,
1125
+ };
1126
+ } else {
1127
+ const paths = writeRequirementInput(run, requirement);
1128
+ run.input.requirement_path = paths.requirement_path;
1129
+ run.input.current_requirement_path = paths.current_requirement_path;
1130
+ saveRun(run);
1131
+ startInput = {
1132
+ type: 'requirement',
1133
+ requirement,
1134
+ };
1135
+ }
1136
+
1137
+ const result = await runAgentStep(run, 'start', SKILLS.start, startInput, agentConfig, { stdout: out, stderr: err });
1138
+ await handleStepResult(run, result, agentConfig, input, { stdout: out, stderr: err });
1139
+ run.status = 'complete';
1140
+ saveRun(run);
1141
+ return run;
1142
+ } finally {
1143
+ input.close();
1144
+ }
1145
+ }
1146
+
1147
+ module.exports = {
1148
+ STEP_RESULT_SCHEMA_VERSION,
1149
+ copyIntentInput,
1150
+ createRun,
1151
+ resolveAgentConfig,
1152
+ runAgentStep,
1153
+ runOperator,
1154
+ splitCommand,
1155
+ validateAndLoadStepResult,
1156
+ };