@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/resume.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { readJob, reconcileJob, patchJob, jobPaths } from './jobs.js';
|
|
5
|
+
import { readSeq } from './seq.js';
|
|
6
|
+
import { snapshotPriorOutcome } from './continue.js';
|
|
7
|
+
|
|
8
|
+
const FAILURE_RESUME_STATES = new Set(['failed', 'stopped', 'crashed']);
|
|
9
|
+
const UNPAUSE_STATES = new Set(['paused', 'pausing']);
|
|
10
|
+
|
|
11
|
+
/** Plain Error whose `toString()` is just the message (no `Error:` prefix). */
|
|
12
|
+
function fail(message) {
|
|
13
|
+
const err = new Error(message);
|
|
14
|
+
err.name = '';
|
|
15
|
+
return err;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cursorLabel(record) {
|
|
19
|
+
const phase = record.phase ?? record.lastOutcome?.phase ?? '?';
|
|
20
|
+
const stage = record.stage ?? record.lastOutcome?.stage ?? '?';
|
|
21
|
+
return `${phase}/${stage}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure eligibility gate for `orch resume`. Reconciles first; never mutates
|
|
26
|
+
* beyond reconcile's dead-pid → crashed rewrite.
|
|
27
|
+
*
|
|
28
|
+
* Returns `{ mode, record }` where mode is:
|
|
29
|
+
* - `'unpause'` — live paused/pausing → existing requestResume path
|
|
30
|
+
* - `'noop'` — already running (successful no-op)
|
|
31
|
+
* - `'failure'` — terminal failure resume (recover → reentry)
|
|
32
|
+
*/
|
|
33
|
+
export function validateResume(cwd, slug, { ask, quick } = {}) {
|
|
34
|
+
const existing = readJob(cwd, slug);
|
|
35
|
+
if (!existing) throw fail(`unknown run ${slug}`);
|
|
36
|
+
|
|
37
|
+
if (ask) throw fail('orch resume does not support --ask; use the default orch command');
|
|
38
|
+
if (quick) throw fail('orch resume does not support --quick; use the default orch command');
|
|
39
|
+
|
|
40
|
+
const record = reconcileJob(cwd, slug, existing);
|
|
41
|
+
|
|
42
|
+
if (UNPAUSE_STATES.has(record.state)) {
|
|
43
|
+
return { mode: 'unpause', record };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (record.state === 'running') {
|
|
47
|
+
return { mode: 'noop', record };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (record.state === 'done') {
|
|
51
|
+
throw fail(
|
|
52
|
+
`${slug} is done; nothing to resume.\nuse: orch continue ${slug} "<new task>"`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!FAILURE_RESUME_STATES.has(record.state)) {
|
|
57
|
+
throw fail(`cannot resume ${slug} while state is ${record.state}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (record.role === 'coordinator') {
|
|
61
|
+
if (readSeq(cwd, slug)) {
|
|
62
|
+
throw fail(
|
|
63
|
+
`cannot resume coordinator ${slug}; resume each failed unit slug, then orch --seq-continue ${slug}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw fail(
|
|
67
|
+
`cannot resume coordinator ${slug}; resume each failed worker slug, then orch --integrate ${slug}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (record.role === 'integration') {
|
|
72
|
+
const parent = record.parent ?? '<parent-slug>';
|
|
73
|
+
throw fail(
|
|
74
|
+
`cannot resume integration ${slug}; use orch --integrate ${parent}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!record.worktree || !record.branch) {
|
|
79
|
+
throw fail(
|
|
80
|
+
`${slug} has no worktree; nothing to stage-resume (quick-fix / ask runs cannot be resumed)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(record.worktree)) {
|
|
85
|
+
throw fail(`worktree missing at ${record.worktree}; cannot resume ${slug}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { mode: 'failure', record };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reopen a terminal failure job for same-task resume. Restores
|
|
93
|
+
* phase/stage/round from `prior` (not research reset). Same slug/worktree.
|
|
94
|
+
*/
|
|
95
|
+
export function reopenForResume(cwd, slug, {
|
|
96
|
+
pid,
|
|
97
|
+
prior,
|
|
98
|
+
agent,
|
|
99
|
+
maxRounds,
|
|
100
|
+
startedAt,
|
|
101
|
+
} = {}) {
|
|
102
|
+
const existing = readJob(cwd, slug);
|
|
103
|
+
if (!existing) throw new Error(`reopenForResume: unknown job ${slug}`);
|
|
104
|
+
|
|
105
|
+
const started = startedAt ?? new Date().toISOString();
|
|
106
|
+
const resumeCount = (existing.resumeCount ?? 0) + 1;
|
|
107
|
+
const resumes = Array.isArray(existing.resumes) ? [...existing.resumes] : [];
|
|
108
|
+
resumes.push({
|
|
109
|
+
n: resumeCount,
|
|
110
|
+
startedAt: started,
|
|
111
|
+
prior: prior ?? null,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const phase = prior?.phase ?? existing.phase ?? null;
|
|
115
|
+
const stage = prior?.stage ?? existing.stage ?? null;
|
|
116
|
+
const round = prior?.round ?? existing.round ?? null;
|
|
117
|
+
|
|
118
|
+
return patchJob(cwd, slug, {
|
|
119
|
+
agent: agent ?? existing.agent,
|
|
120
|
+
maxRounds: maxRounds ?? existing.maxRounds,
|
|
121
|
+
pid,
|
|
122
|
+
startedAt: started,
|
|
123
|
+
state: 'running',
|
|
124
|
+
phase,
|
|
125
|
+
stage,
|
|
126
|
+
round,
|
|
127
|
+
finishedAt: null,
|
|
128
|
+
exitCode: null,
|
|
129
|
+
pauseRequested: false,
|
|
130
|
+
lastOutcome: null,
|
|
131
|
+
resumeCount,
|
|
132
|
+
resumes,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readOptional(filePath) {
|
|
137
|
+
try {
|
|
138
|
+
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf8');
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function shortGitSummary(worktreePath) {
|
|
146
|
+
if (!worktreePath || !fs.existsSync(worktreePath)) return '(worktree missing)';
|
|
147
|
+
try {
|
|
148
|
+
const status = execFileSync('git', ['status', '--short'], {
|
|
149
|
+
cwd: worktreePath,
|
|
150
|
+
encoding: 'utf8',
|
|
151
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
152
|
+
}).trim();
|
|
153
|
+
if (!status) return 'clean working tree';
|
|
154
|
+
const lines = status.split('\n');
|
|
155
|
+
const head = lines.slice(0, 8).join('; ');
|
|
156
|
+
const more = lines.length > 8 ? ` (+${lines.length - 8} more)` : '';
|
|
157
|
+
return `${lines.length} change(s): ${head}${more}`;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return `(git status unavailable: ${err.message?.split('\n')[0] ?? 'error'})`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function oneLineOrientation(prior, { hasTask, hasResearch, gitSummary }) {
|
|
164
|
+
const state = prior?.state ?? 'unknown';
|
|
165
|
+
const stage = prior?.stage ?? prior?.phase ?? 'unknown';
|
|
166
|
+
const round = prior?.round != null ? ` round ${prior.round}` : '';
|
|
167
|
+
const errHint = prior?.error
|
|
168
|
+
? prior.error.includes('failure.log')
|
|
169
|
+
? ' (see failure.log)'
|
|
170
|
+
: ` (${String(prior.error).slice(0, 60)})`
|
|
171
|
+
: '';
|
|
172
|
+
const artifacts = [
|
|
173
|
+
hasTask ? 'task.md present' : null,
|
|
174
|
+
hasResearch ? 'research.md present' : null,
|
|
175
|
+
gitSummary?.startsWith('clean') ? 'worktree intact' : 'worktree has changes',
|
|
176
|
+
].filter(Boolean).join(', ');
|
|
177
|
+
return `${state} at ${stage}${round}${errHint}; ${artifacts}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Deterministic recover: read cursor + failure.log + artifacts + git summary,
|
|
182
|
+
* write recover.md, return brief + one-liner for UI / stage injection.
|
|
183
|
+
*/
|
|
184
|
+
export function runRecover(cwd, slug, { prior, worktreePath } = {}) {
|
|
185
|
+
const paths = jobPaths(cwd, slug);
|
|
186
|
+
const artifactDir = paths.dir;
|
|
187
|
+
const failureLogPath = paths.failureLogPath;
|
|
188
|
+
const researchPath = path.join(artifactDir, 'research.md');
|
|
189
|
+
const taskPath = path.join(artifactDir, 'task.md');
|
|
190
|
+
const statusPath = path.join(artifactDir, 'status.md');
|
|
191
|
+
const recoverPath = path.join(artifactDir, 'recover.md');
|
|
192
|
+
|
|
193
|
+
const failureLog = readOptional(failureLogPath);
|
|
194
|
+
const research = readOptional(researchPath);
|
|
195
|
+
const task = readOptional(taskPath);
|
|
196
|
+
const status = readOptional(statusPath);
|
|
197
|
+
const gitSummary = shortGitSummary(worktreePath ?? readJob(cwd, slug)?.worktree);
|
|
198
|
+
|
|
199
|
+
const p = prior ?? snapshotPriorOutcome(cwd, slug, readJob(cwd, slug) ?? {});
|
|
200
|
+
const oneLiner = oneLineOrientation(p, {
|
|
201
|
+
hasTask: Boolean(task),
|
|
202
|
+
hasResearch: Boolean(research),
|
|
203
|
+
gitSummary,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const lines = [
|
|
207
|
+
'# Recover',
|
|
208
|
+
'',
|
|
209
|
+
`- Slug: \`${slug}\``,
|
|
210
|
+
`- Prior state: ${p.state ?? '(none)'}`,
|
|
211
|
+
`- Prior phase: ${p.phase ?? '(none)'}`,
|
|
212
|
+
`- Prior stage: ${p.stage ?? '(none)'}`,
|
|
213
|
+
`- Prior round: ${p.round ?? 'null'}`,
|
|
214
|
+
`- Prior task: ${p.task ?? '(none)'}`,
|
|
215
|
+
`- Prior summary: ${p.summary || '(none)'}`,
|
|
216
|
+
`- Prior error: ${p.error ?? '(none)'}`,
|
|
217
|
+
`- failure.log: ${failureLog ? failureLogPath : '(absent)'}`,
|
|
218
|
+
`- research.md: ${research ? 'present' : 'absent'}`,
|
|
219
|
+
`- task.md: ${task ? 'present' : 'absent'}`,
|
|
220
|
+
`- status.md: ${status ? 'present' : 'absent'}`,
|
|
221
|
+
`- Worktree git: ${gitSummary}`,
|
|
222
|
+
'',
|
|
223
|
+
'## Orientation',
|
|
224
|
+
'',
|
|
225
|
+
oneLiner,
|
|
226
|
+
'',
|
|
227
|
+
'## Guidance',
|
|
228
|
+
'',
|
|
229
|
+
'- Re-enter the unfinished stage; do not restart research/plan when task.md / research.md already exist.',
|
|
230
|
+
'- No destructive git (no reset/clean/force checkout).',
|
|
231
|
+
'- Retry the recorded stage; overwrite its outputs safely.',
|
|
232
|
+
'',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
if (failureLog) {
|
|
236
|
+
const excerpt = failureLog.length > 4000
|
|
237
|
+
? `${failureLog.slice(-4000)}\n…(truncated)…`
|
|
238
|
+
: failureLog;
|
|
239
|
+
lines.push('## failure.log (excerpt)', '', '```', excerpt.trimEnd(), '```', '');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fs.mkdirSync(artifactDir, { recursive: true });
|
|
243
|
+
fs.writeFileSync(recoverPath, `${lines.join('\n')}\n`, 'utf8');
|
|
244
|
+
|
|
245
|
+
const brief = [
|
|
246
|
+
'[Recover brief]',
|
|
247
|
+
`- Orientation: ${oneLiner}`,
|
|
248
|
+
`- Reentry: ${p.phase ?? '?'}/${p.stage ?? '?'} round ${p.round ?? 'null'}`,
|
|
249
|
+
`- failure.log: ${failureLog ? failureLogPath : '(absent)'}`,
|
|
250
|
+
`- recover.md: ${recoverPath}`,
|
|
251
|
+
`- Worktree git: ${gitSummary}`,
|
|
252
|
+
'- Do not re-run research/plan unless this stage is research or plan.',
|
|
253
|
+
'[/Recover brief]',
|
|
254
|
+
].join('\n');
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
oneLiner,
|
|
258
|
+
brief,
|
|
259
|
+
recoverPath,
|
|
260
|
+
failureLogPath: failureLog ? failureLogPath : null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { snapshotPriorOutcome, cursorLabel, FAILURE_RESUME_STATES };
|
package/lib/seq.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { isPidAlive } from './jobs.js';
|
|
5
|
+
|
|
6
|
+
const SLUG_SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const FANOUT_FIELDS = ['dependsOn', 'owns', 'scaffold', 'area'];
|
|
8
|
+
|
|
9
|
+
/** Absolute paths for a seq run's on-disk state under `<cwd>/.orch/<parentSlug>/`. */
|
|
10
|
+
function seqPaths(cwd, parentSlug) {
|
|
11
|
+
const dir = path.join(path.resolve(cwd), '.orch', parentSlug);
|
|
12
|
+
return {
|
|
13
|
+
dir,
|
|
14
|
+
seqJsonPath: path.join(dir, 'seq.json'),
|
|
15
|
+
lockPath: path.join(dir, '.seq.lock'),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atomicWriteJson(dir, filePath, data) {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
const tmpPath = path.join(
|
|
22
|
+
dir,
|
|
23
|
+
`.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
24
|
+
);
|
|
25
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
26
|
+
fs.renameSync(tmpPath, filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleepSync(ms) {
|
|
30
|
+
const view = new Int32Array(new SharedArrayBuffer(4));
|
|
31
|
+
Atomics.wait(view, 0, 0, ms);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Acquires `.seq.lock` via exclusive create, busy-waiting on contention.
|
|
36
|
+
* A lock whose owner pid is no longer alive is treated as stale and removed.
|
|
37
|
+
*/
|
|
38
|
+
function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
43
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid }));
|
|
44
|
+
fs.closeSync(fd);
|
|
45
|
+
return;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code !== 'EEXIST') throw err;
|
|
48
|
+
|
|
49
|
+
let ownerPid = null;
|
|
50
|
+
try {
|
|
51
|
+
ownerPid = JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid;
|
|
52
|
+
} catch {
|
|
53
|
+
// Lock file mid-write or briefly unreadable; treat as contention.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (ownerPid != null && !isPidAlive(ownerPid)) {
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(lockPath);
|
|
59
|
+
} catch {
|
|
60
|
+
// Another process may have removed it first; retry.
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Date.now() - start > timeoutMs) {
|
|
66
|
+
throw new Error(`patchUnit/patchTip: timed out waiting for lock ${lockPath}`);
|
|
67
|
+
}
|
|
68
|
+
sleepSync(retryMs);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function withSeqLock(cwd, parentSlug, fn) {
|
|
74
|
+
const { dir, seqJsonPath, lockPath } = seqPaths(cwd, parentSlug);
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
acquireLock(lockPath);
|
|
77
|
+
try {
|
|
78
|
+
return fn({ dir, seqJsonPath });
|
|
79
|
+
} finally {
|
|
80
|
+
try {
|
|
81
|
+
fs.unlinkSync(lockPath);
|
|
82
|
+
} catch {
|
|
83
|
+
// Already gone; nothing to clean up.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reads and parses `seq.json`; `null` if missing; throws on invalid JSON. */
|
|
89
|
+
export function readSeq(cwd, parentSlug) {
|
|
90
|
+
const { seqJsonPath } = seqPaths(cwd, parentSlug);
|
|
91
|
+
let content;
|
|
92
|
+
try {
|
|
93
|
+
content = fs.readFileSync(seqJsonPath, 'utf8');
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code === 'ENOENT') return null;
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
return JSON.parse(content);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Atomically (write-temp + rename) writes the full seq document. */
|
|
102
|
+
export function writeSeq(cwd, parentSlug, data) {
|
|
103
|
+
const { dir, seqJsonPath } = seqPaths(cwd, parentSlug);
|
|
104
|
+
atomicWriteJson(dir, seqJsonPath, data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Locks, re-reads the latest document, shallow-merges `patchFnOrObject` (an
|
|
109
|
+
* object, or a `(currentUnit) => partialPatch` function) onto the matching
|
|
110
|
+
* entry in `units[]`, atomically writes the whole document back, unlocks,
|
|
111
|
+
* and returns the updated full document.
|
|
112
|
+
*/
|
|
113
|
+
export function patchUnit(cwd, parentSlug, unitId, patchFnOrObject) {
|
|
114
|
+
return withSeqLock(cwd, parentSlug, ({ dir, seqJsonPath }) => {
|
|
115
|
+
const current = readSeq(cwd, parentSlug);
|
|
116
|
+
if (!current) throw new Error(`unknown parent ${parentSlug} (no seq.json)`);
|
|
117
|
+
const units = current.units.map((unit) => {
|
|
118
|
+
if (unit.id !== unitId) return unit;
|
|
119
|
+
const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(unit) : patchFnOrObject;
|
|
120
|
+
return { ...unit, ...patch };
|
|
121
|
+
});
|
|
122
|
+
const updated = { ...current, units };
|
|
123
|
+
atomicWriteJson(dir, seqJsonPath, updated);
|
|
124
|
+
return updated;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Locks and sets top-level `tip`. */
|
|
129
|
+
export function patchTip(cwd, parentSlug, tipSha) {
|
|
130
|
+
return withSeqLock(cwd, parentSlug, ({ dir, seqJsonPath }) => {
|
|
131
|
+
const current = readSeq(cwd, parentSlug);
|
|
132
|
+
if (!current) throw new Error(`unknown parent ${parentSlug} (no seq.json)`);
|
|
133
|
+
const updated = { ...current, tip: tipSha };
|
|
134
|
+
atomicWriteJson(dir, seqJsonPath, updated);
|
|
135
|
+
return updated;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Locks and pushes onto `adjustments[]` (creating the array if absent). */
|
|
140
|
+
export function appendAdjustment(cwd, parentSlug, entry) {
|
|
141
|
+
return withSeqLock(cwd, parentSlug, ({ dir, seqJsonPath }) => {
|
|
142
|
+
const current = readSeq(cwd, parentSlug);
|
|
143
|
+
if (!current) throw new Error(`unknown parent ${parentSlug} (no seq.json)`);
|
|
144
|
+
const adjustments = [...(current.adjustments ?? []), entry];
|
|
145
|
+
const updated = { ...current, adjustments };
|
|
146
|
+
atomicWriteJson(dir, seqJsonPath, updated);
|
|
147
|
+
return updated;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Returns a violation list for a seq decomposition (empty array = valid). */
|
|
152
|
+
export function validateSeqDecomposition(decomposition, { maxUnits } = {}) {
|
|
153
|
+
const violations = [];
|
|
154
|
+
const units = decomposition?.units ?? [];
|
|
155
|
+
|
|
156
|
+
if (units.length < 2) {
|
|
157
|
+
violations.push('fewer than two units; not decomposable');
|
|
158
|
+
}
|
|
159
|
+
if (typeof maxUnits === 'number' && units.length > maxUnits) {
|
|
160
|
+
violations.push(`too many units: ${units.length} exceeds maxUnits ${maxUnits}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const unit of units) {
|
|
165
|
+
const id = unit?.id;
|
|
166
|
+
if (typeof id !== 'string' || !id.trim()) {
|
|
167
|
+
violations.push('unit has empty id');
|
|
168
|
+
} else if (!SLUG_SAFE_ID.test(id)) {
|
|
169
|
+
violations.push(`unit id ${id} is not slug-safe`);
|
|
170
|
+
} else if (seen.has(id)) {
|
|
171
|
+
violations.push(`duplicate unit id ${id}`);
|
|
172
|
+
} else {
|
|
173
|
+
seen.add(id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof unit?.title !== 'string' || !unit.title.trim()) {
|
|
177
|
+
violations.push(`unit ${id ?? '?'} has empty title`);
|
|
178
|
+
}
|
|
179
|
+
if (typeof unit?.subtask !== 'string' || !unit.subtask.trim()) {
|
|
180
|
+
violations.push(`unit ${id ?? '?'} has empty subtask`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const field of FANOUT_FIELDS) {
|
|
184
|
+
if (Object.hasOwn(unit, field)) {
|
|
185
|
+
violations.push(`unit ${id ?? '?'} has fan-out field ${field}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return violations;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Thin per-unit prompt: id/title, original task fence, subtask; no backlog dump. */
|
|
194
|
+
export function buildUnitEnvelope({ id, title, subtask, originalTask }) {
|
|
195
|
+
return [
|
|
196
|
+
`You are unit ${id} (${title}) of a sequential orch run for:`,
|
|
197
|
+
originalTask,
|
|
198
|
+
'',
|
|
199
|
+
'Your unit subtask:',
|
|
200
|
+
subtask,
|
|
201
|
+
'',
|
|
202
|
+
'Work only on this unit. Do not implement later backlog items. Prior units',
|
|
203
|
+
'may already be merged into the branch you are based on — inspect the tree',
|
|
204
|
+
'and build on it.',
|
|
205
|
+
].join('\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function nextPendingIds(units, limit = 2) {
|
|
209
|
+
return units.filter((u) => u.state === 'pending').slice(0, limit).map((u) => u.id);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Returns a violation list for an adjust result (empty array = valid). */
|
|
213
|
+
export function validateAdjustResult(result, { units, maxUnits } = {}) {
|
|
214
|
+
const violations = [];
|
|
215
|
+
const rewrites = Array.isArray(result?.rewrites) ? result.rewrites : null;
|
|
216
|
+
const drops = Array.isArray(result?.drops) ? result.drops : null;
|
|
217
|
+
|
|
218
|
+
if (!rewrites || !drops) {
|
|
219
|
+
violations.push('adjust result must include rewrites[] and drops[]');
|
|
220
|
+
return violations;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const byId = new Map((units || []).map((u) => [u.id, u]));
|
|
224
|
+
const nextPending = nextPendingIds(units || [], 2);
|
|
225
|
+
|
|
226
|
+
if (rewrites.length > 2) {
|
|
227
|
+
violations.push('cannot rewrite more than the next two pending units');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Rewrites must be a contiguous prefix of the next pending units (no skip-ahead).
|
|
231
|
+
for (let i = 0; i < rewrites.length; i += 1) {
|
|
232
|
+
const id = rewrites[i]?.id;
|
|
233
|
+
if (typeof id !== 'string' || !byId.has(id)) {
|
|
234
|
+
violations.push(`cannot invent new id ${id}`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const unit = byId.get(id);
|
|
238
|
+
if (unit.state !== 'pending') {
|
|
239
|
+
violations.push(`cannot rewrite ${unit.state} unit ${id}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (nextPending[i] !== id) {
|
|
243
|
+
violations.push(`cannot rewrite non-next pending unit ${id}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const id of drops) {
|
|
248
|
+
if (typeof id !== 'string' || !byId.has(id)) {
|
|
249
|
+
violations.push(`cannot invent new id ${id}`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const unit = byId.get(id);
|
|
253
|
+
if (unit.state !== 'pending') {
|
|
254
|
+
violations.push(`cannot drop ${unit.state} unit ${id}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof maxUnits === 'number' && (units || []).length > maxUnits) {
|
|
259
|
+
violations.push(`total units ${(units || []).length} exceeds maxUnits ${maxUnits}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return violations;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Applies rewrites/drops to a cloned seq document; does not mutate input. */
|
|
266
|
+
export function applyAdjustResult(seqDoc, result) {
|
|
267
|
+
const units = seqDoc.units.map((unit) => {
|
|
268
|
+
const rewrite = (result.rewrites || []).find((r) => r.id === unit.id);
|
|
269
|
+
let next = unit;
|
|
270
|
+
if (rewrite) {
|
|
271
|
+
next = { ...next };
|
|
272
|
+
if (rewrite.title != null) next.title = rewrite.title;
|
|
273
|
+
if (rewrite.subtask != null) next.subtask = rewrite.subtask;
|
|
274
|
+
}
|
|
275
|
+
if ((result.drops || []).includes(unit.id)) {
|
|
276
|
+
next = { ...next, state: 'skipped' };
|
|
277
|
+
}
|
|
278
|
+
return next;
|
|
279
|
+
});
|
|
280
|
+
return { ...seqDoc, units };
|
|
281
|
+
}
|
package/lib/stage-summary.js
CHANGED
|
@@ -38,6 +38,30 @@ function splitSummaryIntoBullets(summary) {
|
|
|
38
38
|
.filter(Boolean);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Safety-net summary for the printed stage box when the agent omitted
|
|
43
|
+
* `<<<SUMMARY>>>`. Never invents a delimiter into forwarded content — callers
|
|
44
|
+
* pass only the resolved string to `printStageSummary`.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} label
|
|
47
|
+
* @param {string} summary
|
|
48
|
+
* @param {unknown} content
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveStageSummary(label, summary, content) {
|
|
52
|
+
if (typeof summary === 'string' && summary.trim()) return summary;
|
|
53
|
+
|
|
54
|
+
if (typeof content !== 'string') return '';
|
|
55
|
+
const trimmed = content.trim();
|
|
56
|
+
if (!trimmed) return '';
|
|
57
|
+
|
|
58
|
+
const [first] = splitSummaryIntoBullets(trimmed);
|
|
59
|
+
if (!first) return `${label} completed`;
|
|
60
|
+
|
|
61
|
+
if (first.length <= 160) return first;
|
|
62
|
+
return `${first.slice(0, 160).trimEnd()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
const FILES_NOTES_HEADER_RE = /^\s*files\s*:?\s*$/i;
|
|
42
66
|
|
|
43
67
|
/**
|
package/lib/tool-status.js
CHANGED
|
@@ -18,6 +18,17 @@ export function basename(p) {
|
|
|
18
18
|
return parts[parts.length - 1] || str;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Shared path alias for tool args: `path` | `file_path` | `filePath`.
|
|
23
|
+
* @param {Record<string, unknown>|null|undefined} args
|
|
24
|
+
* @returns {string|null}
|
|
25
|
+
*/
|
|
26
|
+
export function toolPath(args) {
|
|
27
|
+
if (!args || typeof args !== 'object') return null;
|
|
28
|
+
const p = args.path ?? args.file_path ?? args.filePath;
|
|
29
|
+
return typeof p === 'string' && p ? p : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/** Pick the first 1-2 short scalar args to preview for unknown tools, e.g. "path=foo". */
|
|
22
33
|
export function formatArgPreview(args = {}) {
|
|
23
34
|
const parts = [];
|
|
@@ -161,14 +172,45 @@ export function normalizeAgnToolEvent(event) {
|
|
|
161
172
|
};
|
|
162
173
|
}
|
|
163
174
|
|
|
175
|
+
const OPENCODE_TOOL_NAME_TO_FORMATTER_KEY = {
|
|
176
|
+
bash: 'shell',
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Normalize an OpenCode stream event into a `NormalizedToolEvent`, or `null`
|
|
181
|
+
* if the event isn't a well-formed completed `tool_use` part.
|
|
182
|
+
* OpenCode `--format json` only emits completed tool parts.
|
|
183
|
+
* @param {object} event
|
|
184
|
+
* @returns {NormalizedToolEvent|null}
|
|
185
|
+
*/
|
|
186
|
+
export function normalizeOpencodeToolEvent(event) {
|
|
187
|
+
if (event?.type !== 'tool_use') return null;
|
|
188
|
+
const part = event.part;
|
|
189
|
+
if (typeof part?.tool !== 'string' || part.tool === '') return null;
|
|
190
|
+
if (typeof part?.callID !== 'string' || part.callID === '') return null;
|
|
191
|
+
if (part?.state?.status !== 'completed') return null;
|
|
192
|
+
|
|
193
|
+
const canonicalName = OPENCODE_TOOL_NAME_TO_FORMATTER_KEY[part.tool] ?? part.tool;
|
|
194
|
+
const rawTitle = part.state?.title ?? part.title;
|
|
195
|
+
const title = typeof rawTitle === 'string' && rawTitle.trim() ? rawTitle.trim() : undefined;
|
|
196
|
+
return {
|
|
197
|
+
name: canonicalName,
|
|
198
|
+
args: part.state?.input ?? {},
|
|
199
|
+
phase: 'completed',
|
|
200
|
+
callId: part.callID,
|
|
201
|
+
...(title ? { title } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
164
205
|
/**
|
|
165
206
|
* Format a single normalized tool event as a human-readable status line.
|
|
166
|
-
* @param {{ name: string, args?: Record<string, unknown
|
|
207
|
+
* @param {{ name: string, args?: Record<string, unknown>, title?: string }} tool
|
|
167
208
|
* @param {{ maxLen?: number }} [options]
|
|
168
209
|
*/
|
|
169
|
-
export function formatToolStatus({ name, args = {} }, { maxLen = 60 } = {}) {
|
|
210
|
+
export function formatToolStatus({ name, args = {}, title }, { maxLen = 60 } = {}) {
|
|
170
211
|
const n = String(name ?? 'tool');
|
|
171
212
|
const key = n.toLowerCase();
|
|
213
|
+
const file = toolPath(args) ?? 'file';
|
|
172
214
|
|
|
173
215
|
switch (key) {
|
|
174
216
|
case 'grep':
|
|
@@ -176,13 +218,13 @@ export function formatToolStatus({ name, args = {} }, { maxLen = 60 } = {}) {
|
|
|
176
218
|
case 'glob':
|
|
177
219
|
return `Finding: ${truncate(args.glob_pattern ?? args.pattern ?? 'files', maxLen)}…`;
|
|
178
220
|
case 'read':
|
|
179
|
-
return `Reading ${truncate(basename(
|
|
221
|
+
return `Reading ${truncate(basename(file), maxLen)}…`;
|
|
180
222
|
case 'write':
|
|
181
|
-
return `Writing ${truncate(basename(
|
|
223
|
+
return `Writing ${truncate(basename(file), maxLen)}…`;
|
|
182
224
|
case 'edit':
|
|
183
|
-
return `Editing ${truncate(basename(
|
|
225
|
+
return `Editing ${truncate(basename(file), maxLen)}…`;
|
|
184
226
|
case 'delete':
|
|
185
|
-
return `Deleting ${truncate(basename(
|
|
227
|
+
return `Deleting ${truncate(basename(file), maxLen)}…`;
|
|
186
228
|
case 'shell':
|
|
187
229
|
case 'bash':
|
|
188
230
|
return `Running: ${truncate(args.command ?? '', maxLen)}…`;
|
|
@@ -192,7 +234,12 @@ export function formatToolStatus({ name, args = {} }, { maxLen = 60 } = {}) {
|
|
|
192
234
|
return `Searching web: ${truncate(args.search_term ?? args.query ?? '', maxLen)}…`;
|
|
193
235
|
case 'task':
|
|
194
236
|
return `Running subagent: ${truncate(args.description ?? args.prompt ?? 'task', maxLen)}…`;
|
|
237
|
+
case 'apply_patch':
|
|
238
|
+
return 'Applying patch…';
|
|
195
239
|
default: {
|
|
240
|
+
if (typeof title === 'string' && title.trim()) {
|
|
241
|
+
return truncate(title.trim(), maxLen);
|
|
242
|
+
}
|
|
196
243
|
const preview = formatArgPreview(args);
|
|
197
244
|
return preview ? `Running ${n}(${preview})…` : `Running ${n}…`;
|
|
198
245
|
}
|