@welluable/orch 1.3.0 → 1.4.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/README.md +94 -18
- package/agents/adjust.js +66 -0
- package/agents/ask.js +3 -5
- package/agents/boundaries.js +3 -4
- package/agents/code-writer.js +3 -5
- package/agents/decomposer.js +5 -5
- package/agents/index.js +2 -0
- package/agents/integrator.js +3 -4
- package/agents/planner.js +3 -5
- package/agents/quick-fix.js +3 -5
- package/agents/research.js +3 -5
- package/agents/seq-decomposer.js +58 -0
- package/agents/summary-footer.js +23 -0
- package/agents/test-critic.js +5 -6
- package/agents/test-runner.js +5 -6
- package/agents/test-writer.js +3 -5
- package/agents/triage.js +5 -6
- package/lib/agent-agn.js +4 -2
- package/lib/agent-cursor.js +4 -2
- package/lib/agent-opencode.js +177 -0
- package/lib/agent.js +150 -12
- package/lib/config.js +92 -17
- package/lib/continue.js +25 -7
- package/lib/failure-log.js +72 -0
- package/lib/file-tracker.js +68 -12
- package/lib/jobs.js +131 -6
- package/lib/notify.js +71 -0
- package/lib/resume.js +264 -0
- package/lib/seq.js +281 -0
- package/lib/stage-summary.js +24 -0
- package/lib/tool-status.js +53 -6
- package/main.js +2571 -412
- package/package.json +1 -1
package/lib/agent-cursor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Agent } from './agent.js';
|
|
1
|
+
import { Agent, appendVerbose } from './agent.js';
|
|
2
2
|
import { normalizeCursorToolEvent } from './tool-status.js';
|
|
3
3
|
|
|
4
4
|
export class AgentCursor extends Agent {
|
|
@@ -26,8 +26,10 @@ export class AgentCursor extends Agent {
|
|
|
26
26
|
case 'thinking': {
|
|
27
27
|
switch (event.subtype) {
|
|
28
28
|
case 'delta': {
|
|
29
|
+
const text = event.text ?? '';
|
|
30
|
+
appendVerbose(text);
|
|
29
31
|
if (verbose) {
|
|
30
|
-
process.stderr.write(
|
|
32
|
+
process.stderr.write(text);
|
|
31
33
|
} else if (this.activeTools.size === 0) {
|
|
32
34
|
this.setStatus('thinking…');
|
|
33
35
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Agent, modelPrintState, appendVerbose } from './agent.js';
|
|
2
|
+
import { normalizeOpencodeToolEvent } from './tool-status.js';
|
|
3
|
+
|
|
4
|
+
const OPENCODE_DENY_PERMISSION = '{"edit":"deny","bash":"deny"}';
|
|
5
|
+
|
|
6
|
+
export class AgentOpencode extends Agent {
|
|
7
|
+
#textBuffer = '';
|
|
8
|
+
#sawStepStart = false;
|
|
9
|
+
#pendingStop = false;
|
|
10
|
+
#settled = false;
|
|
11
|
+
|
|
12
|
+
getSpawnConfig(promptToSend) {
|
|
13
|
+
if (this.readOnly) {
|
|
14
|
+
return {
|
|
15
|
+
command: 'opencode',
|
|
16
|
+
args: ['run', '--format', 'json', '--auto', '--thinking', '--agent', 'plan', promptToSend],
|
|
17
|
+
options: {
|
|
18
|
+
cwd: this.cwd,
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
env: { ...process.env, OPENCODE_PERMISSION: OPENCODE_DENY_PERMISSION },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
command: 'opencode',
|
|
27
|
+
args: ['run', '--format', 'json', '--auto', '--thinking', promptToSend],
|
|
28
|
+
options: {
|
|
29
|
+
cwd: this.cwd,
|
|
30
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
+
env: process.env,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#printModelOnce(modelId) {
|
|
37
|
+
if (modelPrintState.printed || typeof modelId !== 'string' || !modelId) return;
|
|
38
|
+
modelPrintState.printed = true;
|
|
39
|
+
const wasSpinning = this.spinner?.isSpinning;
|
|
40
|
+
if (wasSpinning) this.spinner.stop?.();
|
|
41
|
+
console.log(`model: ${modelId}`);
|
|
42
|
+
if (wasSpinning) this.spinner.start?.();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#printUsageLine(part) {
|
|
46
|
+
const tokens = part?.tokens;
|
|
47
|
+
if (!tokens || typeof tokens !== 'object') return;
|
|
48
|
+
const input = tokens.input ?? tokens.in;
|
|
49
|
+
const output = tokens.output ?? tokens.out;
|
|
50
|
+
if (typeof input !== 'number' && typeof output !== 'number') return;
|
|
51
|
+
|
|
52
|
+
let line = ` tokens: in=${input ?? 0} out=${output ?? 0}`;
|
|
53
|
+
const cost = part.cost;
|
|
54
|
+
if (typeof cost === 'number' && cost > 0) {
|
|
55
|
+
line += ` · cost=$${cost}`;
|
|
56
|
+
}
|
|
57
|
+
process.stderr.write(`${line}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#settleOk(finish, { verbose = false, part = null } = {}) {
|
|
61
|
+
if (this.#settled) return;
|
|
62
|
+
this.#settled = true;
|
|
63
|
+
this.#pendingStop = false;
|
|
64
|
+
this.settleResult(
|
|
65
|
+
{
|
|
66
|
+
is_error: false,
|
|
67
|
+
result: this.#textBuffer,
|
|
68
|
+
duration_ms: undefined,
|
|
69
|
+
},
|
|
70
|
+
finish,
|
|
71
|
+
);
|
|
72
|
+
if (verbose && part) this.#printUsageLine(part);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#recordApplyPatchFiles(toolEvent) {
|
|
76
|
+
if (toolEvent.name !== 'apply_patch' || !this.fileTracker) return;
|
|
77
|
+
const entries = this.fileTracker.recordApplyPatch(toolEvent.args?.patchText);
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (!entry.isNew) continue;
|
|
80
|
+
const wasSpinning = this.spinner?.isSpinning;
|
|
81
|
+
if (wasSpinning) this.spinner.stop();
|
|
82
|
+
console.log(` ${entry.marker} ${entry.path}`);
|
|
83
|
+
if (wasSpinning) this.spinner.start();
|
|
84
|
+
this.onFileChange?.(entry);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleStreamEvent(event, { verbose, finish }) {
|
|
89
|
+
switch (event.type) {
|
|
90
|
+
case 'message.updated': {
|
|
91
|
+
this.#printModelOnce(event.properties?.info?.modelID);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'step_start': {
|
|
95
|
+
if (!this.#sawStepStart) {
|
|
96
|
+
this.#sawStepStart = true;
|
|
97
|
+
this.setStatus('connected');
|
|
98
|
+
} else if (this.activeTools.size === 0) {
|
|
99
|
+
this.setStatus('thinking…');
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case 'tool_use': {
|
|
104
|
+
const toolEvent = normalizeOpencodeToolEvent(event);
|
|
105
|
+
if (!toolEvent) break;
|
|
106
|
+
this.onToolEvent({ ...toolEvent, phase: 'started' });
|
|
107
|
+
this.onToolEvent(toolEvent);
|
|
108
|
+
this.#recordApplyPatchFiles(toolEvent);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case 'reasoning': {
|
|
112
|
+
const text = event.part?.text ?? '';
|
|
113
|
+
appendVerbose(text);
|
|
114
|
+
if (verbose) {
|
|
115
|
+
process.stderr.write(text);
|
|
116
|
+
} else if (this.activeTools.size === 0) {
|
|
117
|
+
this.setStatus('thinking…');
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'text': {
|
|
122
|
+
const text = event.part?.text ?? '';
|
|
123
|
+
this.#textBuffer += text;
|
|
124
|
+
appendVerbose(text);
|
|
125
|
+
if (verbose) {
|
|
126
|
+
process.stderr.write(text);
|
|
127
|
+
} else if (this.activeTools.size === 0) {
|
|
128
|
+
this.setStatus('composing response…');
|
|
129
|
+
}
|
|
130
|
+
if (this.#pendingStop && this.#textBuffer !== '') {
|
|
131
|
+
this.#settleOk(finish, { verbose });
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'step_finish': {
|
|
136
|
+
const reason = event.part?.reason;
|
|
137
|
+
if (reason === 'tool-calls') break;
|
|
138
|
+
if (reason === 'stop' || reason == null) {
|
|
139
|
+
if (this.#textBuffer !== '') {
|
|
140
|
+
this.#settleOk(finish, { verbose, part: event.part });
|
|
141
|
+
} else {
|
|
142
|
+
this.#pendingStop = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'error': {
|
|
148
|
+
if (this.#settled) break;
|
|
149
|
+
this.#settled = true;
|
|
150
|
+
this.#pendingStop = false;
|
|
151
|
+
const message =
|
|
152
|
+
event.error?.data?.message ||
|
|
153
|
+
event.error?.name ||
|
|
154
|
+
'OpenCode error';
|
|
155
|
+
this.settleResult(
|
|
156
|
+
{
|
|
157
|
+
is_error: true,
|
|
158
|
+
result: message,
|
|
159
|
+
},
|
|
160
|
+
finish,
|
|
161
|
+
);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
handleProcessClose(code, finish) {
|
|
170
|
+
if (this.#settled) return;
|
|
171
|
+
if (code === 0 && this.#textBuffer !== '') {
|
|
172
|
+
this.#settleOk(finish);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
super.handleProcessClose(code, finish);
|
|
176
|
+
}
|
|
177
|
+
}
|
package/lib/agent.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
-
import { formatActiveTools } from './tool-status.js';
|
|
5
|
-
import { patchJob, buildLastOutcome } from './jobs.js';
|
|
4
|
+
import { formatActiveTools, toolPath } from './tool-status.js';
|
|
5
|
+
import { patchJob, buildLastOutcome, jobPaths, readJob } from './jobs.js';
|
|
6
|
+
import {
|
|
7
|
+
appendFailureLog,
|
|
8
|
+
failureLogPointer,
|
|
9
|
+
formatFailureSection,
|
|
10
|
+
} from './failure-log.js';
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Every child process spawned by an agent, tracked so we can reap them all
|
|
@@ -43,6 +48,116 @@ export function setJobSlug(slug) {
|
|
|
43
48
|
activeJobSlug = slug;
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/** Active job slug: env (detached child) wins over in-process foreground slug. */
|
|
52
|
+
export function getActiveJobSlug() {
|
|
53
|
+
return process.env.ORCH_JOB_SLUG ?? activeJobSlug;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Current stage capture + prior-stage ring for failure.log (independent of -v). */
|
|
57
|
+
let currentCapture = null;
|
|
58
|
+
/** @type {{ phase: *, stage: *, round: *, verbose: string }[]} */
|
|
59
|
+
let priorCaptures = [];
|
|
60
|
+
const PRIOR_RING_MAX = 8;
|
|
61
|
+
|
|
62
|
+
/** Clear failure-log buffers (also invoked from `resetShutdownState`). */
|
|
63
|
+
export function resetFailureLogState() {
|
|
64
|
+
currentCapture = null;
|
|
65
|
+
priorCaptures = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start capturing verbose for a stage. Rotates a non-empty current buffer into
|
|
70
|
+
* the prior-stage ring so recover can see more than the final frame.
|
|
71
|
+
*/
|
|
72
|
+
export function beginStageCapture({ phase = null, stage = null, round = null } = {}) {
|
|
73
|
+
if (currentCapture && currentCapture.verbose) {
|
|
74
|
+
priorCaptures.push({
|
|
75
|
+
phase: currentCapture.phase,
|
|
76
|
+
stage: currentCapture.stage,
|
|
77
|
+
round: currentCapture.round,
|
|
78
|
+
verbose: currentCapture.verbose,
|
|
79
|
+
});
|
|
80
|
+
if (priorCaptures.length > PRIOR_RING_MAX) {
|
|
81
|
+
priorCaptures.splice(0, priorCaptures.length - PRIOR_RING_MAX);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
currentCapture = { phase, stage, round, verbose: '' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Append a verbose chunk when a job slug is active. Never gated on `-v`
|
|
89
|
+
* (stderr streaming stays verbose-gated in the backends).
|
|
90
|
+
*/
|
|
91
|
+
export function appendVerbose(chunk) {
|
|
92
|
+
if (!getActiveJobSlug()) return;
|
|
93
|
+
if (chunk == null || chunk === '') return;
|
|
94
|
+
if (!currentCapture) {
|
|
95
|
+
currentCapture = { phase: null, stage: null, round: null, verbose: '' };
|
|
96
|
+
}
|
|
97
|
+
currentCapture.verbose += String(chunk);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Append one `=== orch failure ===` section to `.orch/<slug>/failure.log`,
|
|
102
|
+
* patch `run.json.failureLogPath`, and return a pointer string. No-op when
|
|
103
|
+
* `slug` is falsy.
|
|
104
|
+
*/
|
|
105
|
+
export function flushFailureLog({
|
|
106
|
+
cwd,
|
|
107
|
+
slug,
|
|
108
|
+
state,
|
|
109
|
+
exitCode = null,
|
|
110
|
+
finishedAt,
|
|
111
|
+
task = null,
|
|
112
|
+
error = null,
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (!slug) return null;
|
|
115
|
+
|
|
116
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
117
|
+
let phase = currentCapture?.phase ?? null;
|
|
118
|
+
let stage = currentCapture?.stage ?? null;
|
|
119
|
+
let round = currentCapture?.round ?? null;
|
|
120
|
+
let resolvedTask = task;
|
|
121
|
+
|
|
122
|
+
if (phase == null || stage == null || round == null || resolvedTask == null) {
|
|
123
|
+
try {
|
|
124
|
+
const record = readJob(resolvedCwd, slug);
|
|
125
|
+
if (record) {
|
|
126
|
+
if (phase == null) phase = record.phase ?? null;
|
|
127
|
+
if (stage == null) stage = record.stage ?? null;
|
|
128
|
+
if (round == null) round = record.round ?? null;
|
|
129
|
+
if (resolvedTask == null) resolvedTask = record.task ?? null;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Best-effort cursor fill from run.json.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { failureLogPath } = jobPaths(resolvedCwd, slug);
|
|
137
|
+
const section = formatFailureSection({
|
|
138
|
+
slug,
|
|
139
|
+
state,
|
|
140
|
+
phase,
|
|
141
|
+
stage,
|
|
142
|
+
round,
|
|
143
|
+
exitCode,
|
|
144
|
+
finishedAt,
|
|
145
|
+
task: resolvedTask,
|
|
146
|
+
error,
|
|
147
|
+
stageVerbose: currentCapture?.verbose ?? '',
|
|
148
|
+
priorStages: priorCaptures,
|
|
149
|
+
});
|
|
150
|
+
appendFailureLog(failureLogPath, section);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
patchJob(resolvedCwd, slug, { failureLogPath });
|
|
154
|
+
} catch {
|
|
155
|
+
// Best-effort: file is already on disk even if the patch fails.
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return failureLogPointer(slug);
|
|
159
|
+
}
|
|
160
|
+
|
|
46
161
|
/** Conventional shell status: 128 + signal number. */
|
|
47
162
|
export function exitCodeForSignal(signal) {
|
|
48
163
|
if (signal === 'SIGINT') return 130;
|
|
@@ -69,6 +184,14 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
|
|
|
69
184
|
try {
|
|
70
185
|
const exitCode = exitCodeForSignal(signal);
|
|
71
186
|
const finishedAt = new Date().toISOString();
|
|
187
|
+
const pointer = flushFailureLog({
|
|
188
|
+
cwd: jobCwd,
|
|
189
|
+
slug: jobSlug,
|
|
190
|
+
state: 'stopped',
|
|
191
|
+
exitCode,
|
|
192
|
+
finishedAt,
|
|
193
|
+
error: signal,
|
|
194
|
+
});
|
|
72
195
|
patchJob(jobCwd, jobSlug, (current) => ({
|
|
73
196
|
state: 'stopped',
|
|
74
197
|
exitCode,
|
|
@@ -82,7 +205,7 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
|
|
|
82
205
|
finishedAt,
|
|
83
206
|
task: current.task,
|
|
84
207
|
summary: '',
|
|
85
|
-
error:
|
|
208
|
+
error: pointer,
|
|
86
209
|
}),
|
|
87
210
|
}));
|
|
88
211
|
} catch {
|
|
@@ -125,6 +248,7 @@ export function resetShutdownState() {
|
|
|
125
248
|
shuttingDown = false;
|
|
126
249
|
liveChildren.clear();
|
|
127
250
|
activeJobSlug = null;
|
|
251
|
+
resetFailureLogState();
|
|
128
252
|
}
|
|
129
253
|
|
|
130
254
|
/**
|
|
@@ -209,10 +333,14 @@ export class Agent {
|
|
|
209
333
|
this.refreshSpinnerText();
|
|
210
334
|
}
|
|
211
335
|
|
|
212
|
-
/** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} toolEvent */
|
|
213
|
-
onToolEvent({ name, args, phase, callId }) {
|
|
336
|
+
/** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string, title?: string }} toolEvent */
|
|
337
|
+
onToolEvent({ name, args, phase, callId, title }) {
|
|
214
338
|
if (phase === 'started') {
|
|
215
|
-
this.activeTools.set(callId, {
|
|
339
|
+
this.activeTools.set(callId, {
|
|
340
|
+
name,
|
|
341
|
+
args,
|
|
342
|
+
...(typeof title === 'string' && title ? { title } : {}),
|
|
343
|
+
});
|
|
216
344
|
this.fileTracker?.record({ name, args, phase, callId });
|
|
217
345
|
} else if (phase === 'completed') {
|
|
218
346
|
// Recall before delete — Claude/agn completions often have empty args.
|
|
@@ -220,7 +348,7 @@ export class Agent {
|
|
|
220
348
|
this.activeTools.delete(callId);
|
|
221
349
|
|
|
222
350
|
if (this.fileTracker) {
|
|
223
|
-
const hasPath = Boolean(args
|
|
351
|
+
const hasPath = Boolean(toolPath(args));
|
|
224
352
|
const entry = this.fileTracker.record({
|
|
225
353
|
name: name || prior?.name || '',
|
|
226
354
|
args: hasPath ? args : (prior?.args ?? args ?? {}),
|
|
@@ -279,6 +407,20 @@ export class Agent {
|
|
|
279
407
|
throw new Error('handleStreamEvent must be implemented by subclass');
|
|
280
408
|
}
|
|
281
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Called when the child exits before a settling stream event.
|
|
412
|
+
* Subclasses may override (e.g. defensive success on exit 0 + buffered text).
|
|
413
|
+
* @param {number|null} code
|
|
414
|
+
* @param {(err: Error|null, value?: object) => void} finish
|
|
415
|
+
*/
|
|
416
|
+
handleProcessClose(code, finish) {
|
|
417
|
+
this.stopElapsedTimer();
|
|
418
|
+
if (this.spinner?.isSpinning) {
|
|
419
|
+
this.spinner.fail(`[${this.name}] exited ${code}`);
|
|
420
|
+
}
|
|
421
|
+
finish(new Error(`[${this.name}] exited ${code} before result`));
|
|
422
|
+
}
|
|
423
|
+
|
|
282
424
|
settleResult(event, finish) {
|
|
283
425
|
this.stopElapsedTimer();
|
|
284
426
|
this.activeTools.clear();
|
|
@@ -376,11 +518,7 @@ export class Agent {
|
|
|
376
518
|
liveChildren.delete(child);
|
|
377
519
|
this.process = null;
|
|
378
520
|
if (!settled) {
|
|
379
|
-
this.
|
|
380
|
-
if (this.spinner?.isSpinning) {
|
|
381
|
-
this.spinner.fail(`[${this.name}] exited ${code}`);
|
|
382
|
-
}
|
|
383
|
-
finish(new Error(`[${this.name}] exited ${code} before result`));
|
|
521
|
+
this.handleProcessClose(code, finish);
|
|
384
522
|
}
|
|
385
523
|
});
|
|
386
524
|
});
|
package/lib/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
const VALID_AGENTS = new Set(['cursor', 'claude', 'agn']);
|
|
5
|
+
const VALID_AGENTS = new Set(['cursor', 'claude', 'agn', 'opencode']);
|
|
6
6
|
|
|
7
7
|
/** Absolute path to the global orch config file. */
|
|
8
8
|
export function globalConfigPath({ homedir = os.homedir() } = {}) {
|
|
@@ -16,8 +16,8 @@ export function localConfigPath(cwd) {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Read a config file. Missing file → `{}`. Bad JSON, unreadable file, or
|
|
19
|
-
* invalid `agent` → throws with a message suitable for `Error: …`
|
|
20
|
-
* Unknown keys are ignored; `agent` is case-sensitive.
|
|
19
|
+
* invalid `agent` / `notify` → throws with a message suitable for `Error: …`
|
|
20
|
+
* on stderr. Unknown keys are ignored; `agent` is case-sensitive.
|
|
21
21
|
*/
|
|
22
22
|
export function loadConfig(configPath, displayPath = configPath) {
|
|
23
23
|
if (!fs.existsSync(configPath)) return {};
|
|
@@ -40,17 +40,27 @@ export function loadConfig(configPath, displayPath = configPath) {
|
|
|
40
40
|
throw new Error(`could not parse ${displayPath}: expected a JSON object`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const out = {};
|
|
44
|
+
|
|
45
|
+
if (Object.prototype.hasOwnProperty.call(data, 'agent') && data.agent !== undefined) {
|
|
46
|
+
if (!VALID_AGENTS.has(data.agent)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`invalid agent in ${displayPath}: ${JSON.stringify(data.agent)} (expected "cursor", "claude", "agn", or "opencode")`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
out.agent = data.agent;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(data, 'notify') && data.notify !== undefined) {
|
|
55
|
+
if (typeof data.notify !== 'boolean') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`invalid notify in ${displayPath}: ${JSON.stringify(data.notify)} (expected true or false)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
out.notify = data.notify;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
return
|
|
63
|
+
return out;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -70,23 +80,74 @@ export function resolveAgent({ cliAgent, cwd, homedir = os.homedir() } = {}) {
|
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/**
|
|
73
|
-
*
|
|
83
|
+
* Effective notify for a run: CLI > local > global > `true`.
|
|
84
|
+
* `cliNotify` is `true` | `false` | `undefined` (flag omitted).
|
|
85
|
+
*/
|
|
86
|
+
export function resolveNotify({ cliNotify, cwd, homedir = os.homedir() } = {}) {
|
|
87
|
+
if (cliNotify === true || cliNotify === false) return cliNotify;
|
|
88
|
+
|
|
89
|
+
const local = loadConfig(localConfigPath(cwd), '.orch/config');
|
|
90
|
+
if (typeof local.notify === 'boolean') return local.notify;
|
|
91
|
+
|
|
92
|
+
const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
|
|
93
|
+
if (typeof global.notify === 'boolean') return global.notify;
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create parent dirs if needed, merge `agent` / `notify` into the existing
|
|
100
|
+
* config object (preserve the other key), overwrite with pretty-printed JSON,
|
|
74
101
|
* return the path written.
|
|
75
102
|
*/
|
|
76
|
-
export function writeConfig(configPath, { agent }) {
|
|
77
|
-
if (!VALID_AGENTS.has(agent)) {
|
|
103
|
+
export function writeConfig(configPath, { agent, notify } = {}) {
|
|
104
|
+
if (agent !== undefined && !VALID_AGENTS.has(agent)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", "agn", or "opencode")`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (notify !== undefined && typeof notify !== 'boolean') {
|
|
78
110
|
throw new Error(
|
|
79
|
-
`invalid
|
|
111
|
+
`invalid notify: ${JSON.stringify(notify)} (expected true or false)`,
|
|
80
112
|
);
|
|
81
113
|
}
|
|
114
|
+
|
|
115
|
+
let existing = {};
|
|
116
|
+
if (fs.existsSync(configPath)) {
|
|
117
|
+
try {
|
|
118
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
119
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
120
|
+
existing = raw;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Corrupt file: start fresh; caller already validated inputs.
|
|
124
|
+
existing = {};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const next = { ...existing };
|
|
129
|
+
if (agent !== undefined) next.agent = agent;
|
|
130
|
+
if (notify !== undefined) next.notify = notify;
|
|
131
|
+
|
|
132
|
+
// Persist only known keys (drop unknown junk from a prior merge? Spec says
|
|
133
|
+
// unknown keys are ignored on load but does not require stripping on write.
|
|
134
|
+
// Keep agent/notify; preserve other keys already present.)
|
|
135
|
+
const out = {};
|
|
136
|
+
if (next.agent !== undefined) out.agent = next.agent;
|
|
137
|
+
if (next.notify !== undefined) out.notify = next.notify;
|
|
138
|
+
for (const [k, v] of Object.entries(next)) {
|
|
139
|
+
if (k === 'agent' || k === 'notify') continue;
|
|
140
|
+
out[k] = v;
|
|
141
|
+
}
|
|
142
|
+
|
|
82
143
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
83
|
-
fs.writeFileSync(configPath, `${JSON.stringify(
|
|
144
|
+
fs.writeFileSync(configPath, `${JSON.stringify(out, null, 2)}\n`);
|
|
84
145
|
return configPath;
|
|
85
146
|
}
|
|
86
147
|
|
|
87
148
|
/**
|
|
88
|
-
* Print effective agent and which file(s) contributed (stdout). Throws
|
|
89
|
-
* invalid existing files — same fail-fast contract as a run.
|
|
149
|
+
* Print effective agent/notify and which file(s) contributed (stdout). Throws
|
|
150
|
+
* on invalid existing files — same fail-fast contract as a run.
|
|
90
151
|
*/
|
|
91
152
|
export function printConfig({
|
|
92
153
|
cwd,
|
|
@@ -108,8 +169,22 @@ export function printConfig({
|
|
|
108
169
|
source = `global (${globalPath})`;
|
|
109
170
|
}
|
|
110
171
|
|
|
172
|
+
let notify = true;
|
|
173
|
+
let notifySource = 'default (builtin)';
|
|
174
|
+
if (typeof local.notify === 'boolean') {
|
|
175
|
+
notify = local.notify;
|
|
176
|
+
notifySource = `local (${localPath})`;
|
|
177
|
+
} else if (typeof global.notify === 'boolean') {
|
|
178
|
+
notify = global.notify;
|
|
179
|
+
notifySource = `global (${globalPath})`;
|
|
180
|
+
}
|
|
181
|
+
|
|
111
182
|
log(`agent=${agent}`);
|
|
112
183
|
log(`source=${source}`);
|
|
113
184
|
log(`global=${global.agent ?? 'unset'} (${globalPath})`);
|
|
114
185
|
log(`local=${local.agent ?? 'unset'} (${localPath})`);
|
|
186
|
+
log(`notify=${notify}`);
|
|
187
|
+
log(`notifySource=${notifySource}`);
|
|
188
|
+
log(`notifyGlobal=${typeof global.notify === 'boolean' ? global.notify : 'unset'} (${globalPath})`);
|
|
189
|
+
log(`notifyLocal=${typeof local.notify === 'boolean' ? local.notify : 'unset'} (${localPath})`);
|
|
115
190
|
}
|
package/lib/continue.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readJob, reconcileJob, jobPaths } from './jobs.js';
|
|
4
|
+
import { readSeq } from './seq.js';
|
|
4
5
|
|
|
5
|
-
const CONTINUE_ELIGIBLE = new Set(['done'
|
|
6
|
+
const CONTINUE_ELIGIBLE = new Set(['done']);
|
|
7
|
+
const FAILURE_TERMINALS = new Set(['failed', 'stopped', 'crashed']);
|
|
6
8
|
|
|
7
9
|
/** Plain Error whose `toString()` is just the message (no `Error:` prefix), so
|
|
8
10
|
* `assert.throws(fn, /^exact message$/)` contracts match Node's RegExp check. */
|
|
@@ -16,6 +18,9 @@ function fail(message) {
|
|
|
16
18
|
* Pure eligibility gate for `orch continue`. Reconciles first; never mutates
|
|
17
19
|
* beyond reconcile's dead-pid → crashed rewrite. Throws plain Error messages
|
|
18
20
|
* (CLI wraps with `Error: ${err.message}`).
|
|
21
|
+
*
|
|
22
|
+
* Continue is follow-up work on primarily `done` jobs. Terminal failures
|
|
23
|
+
* refuse by default and point at `orch resume` (see `.spec/resume.md`).
|
|
19
24
|
*/
|
|
20
25
|
export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
|
|
21
26
|
const existing = readJob(cwd, slug);
|
|
@@ -30,13 +35,12 @@ export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
|
|
|
30
35
|
|
|
31
36
|
const record = reconcileJob(cwd, slug, existing);
|
|
32
37
|
|
|
33
|
-
if (!CONTINUE_ELIGIBLE.has(record.state)) {
|
|
34
|
-
throw fail(
|
|
35
|
-
`cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
38
|
if (record.role === 'coordinator') {
|
|
39
|
+
if (readSeq(cwd, slug)) {
|
|
40
|
+
throw fail(
|
|
41
|
+
`cannot continue coordinator ${slug}; continue each failed unit slug, then orch --seq-continue ${slug} or orch resume ${slug}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
40
44
|
throw fail(
|
|
41
45
|
`cannot continue coordinator ${slug}; continue each failed worker slug, then orch --integrate ${slug}`,
|
|
42
46
|
);
|
|
@@ -49,6 +53,20 @@ export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
|
|
|
49
53
|
);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
if (FAILURE_TERMINALS.has(record.state)) {
|
|
57
|
+
const phase = record.phase ?? record.lastOutcome?.phase ?? '?';
|
|
58
|
+
const stage = record.stage ?? record.lastOutcome?.stage ?? '?';
|
|
59
|
+
throw fail(
|
|
60
|
+
`${slug} is ${record.state} at ${phase}/${stage};\nuse: orch resume ${slug}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!CONTINUE_ELIGIBLE.has(record.state)) {
|
|
65
|
+
throw fail(
|
|
66
|
+
`cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
if (!record.worktree || !record.branch) {
|
|
53
71
|
throw fail(`${slug} has no worktree; continue only applies to complex runs`);
|
|
54
72
|
}
|