opencode-harness-kit 0.6.3 → 0.7.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.
@@ -1,24 +1,36 @@
1
1
  #!/usr/bin/env node
2
- // `oc-harness run` -- drive the space-plan -> build -> review -> finish loop
3
- // without a human at the keyboard.
2
+ // `oc-harness run` -- drive the plan's tasks through build and verify without a
3
+ // human at the keyboard.
4
4
  //
5
- // The four skills already know how to do their own job. What was missing was
6
- // the thing that decides *which* one runs next and *when to stop*. That is
7
- // here, and it is deliberately small: it reads `.space/tasks.md`, hands one
8
- // task at a time to `opencode run --agent <role>`, records the real verify
9
- // output, and stops.
5
+ // The skills already know how to do their own job. What was missing was the
6
+ // thing that decides *which* task runs next and *when to stop*. That is here,
7
+ // and it is deliberately small: it reads `.space/tasks.md`, hands one task at a
8
+ // time to `opencode run --agent <role>`, records the real verify output, and
9
+ // stops. Review and finish remain explicit human steps: this loop will not
10
+ // claim a task is done because an agent said so, and it will not publish.
10
11
  //
11
12
  // What it will not do, ever:
12
13
  // - push, open a PR, or cut a release. Publishing is a human decision, and an
13
14
  // unattended loop that can do it is an unattended loop that will.
14
15
  // - retry a task it already blocked, or start a task with no verify command.
15
16
  // A task it cannot check is a task it cannot claim is done.
17
+ // - run two writers in one project. `.space/run.lock` makes the second run
18
+ // refuse rather than race the first to the task file.
16
19
  // - loop forever. Every stop condition is explicit and every one of them is
17
20
  // reported, so a run that ends is never a mystery.
18
21
 
19
22
  import { spawnSync } from "node:child_process"
20
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
21
- import { join } from "node:path"
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ renameSync,
29
+ unlinkSync,
30
+ realpathSync,
31
+ statSync,
32
+ } from "node:fs"
33
+ import { join, relative, resolve, sep } from "node:path"
22
34
 
23
35
  import {
24
36
  parseTasks,
@@ -27,15 +39,114 @@ import {
27
39
  taskPrompt,
28
40
  summarize,
29
41
  isBlocked,
42
+ isDone,
43
+ isVacuousVerify,
30
44
  } from "../tasks.js"
31
45
 
32
46
  const TASKS_PATH = join(".space", "tasks.md")
47
+ const LOCK_PATH = join(".space", "run.lock")
48
+
49
+ /**
50
+ * Whether a process is still there.
51
+ *
52
+ * `process.kill(pid, 0)` sends no signal; it only asks the kernel whether the
53
+ * pid exists. EPERM means it exists and belongs to someone else, which is
54
+ * still a live holder -- the answer is yes in both cases.
55
+ */
56
+ /** @param {unknown} err @returns {string | undefined} */
57
+ function errorCode(err) {
58
+ return err && typeof err === "object" && "code" in err
59
+ ? /** @type {{ code?: string }} */ (err).code
60
+ : undefined
61
+ }
62
+
63
+ /** @param {unknown} err @returns {string} */
64
+ function errorText(err) {
65
+ return err instanceof Error ? err.message : String(err)
66
+ }
33
67
 
34
- const bold = (s) => `\x1b[1m${s}\x1b[0m`
35
- const stamp = (s) => `\x1b[2m${s}\x1b[0m`
36
- const green = (s) => `\x1b[32m${s}\x1b[0m`
37
- const yellow = (s) => `\x1b[33m${s}\x1b[0m`
38
- const red = (s) => `\x1b[31m${s}\x1b[0m`
68
+ function pidAlive(pid) {
69
+ if (!Number.isInteger(pid) || pid <= 0) return false
70
+ try {
71
+ process.kill(pid, 0)
72
+ return true
73
+ } catch (err) {
74
+ return errorCode(err) === "EPERM"
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Take `.space/run.lock`, or report who holds it.
80
+ *
81
+ * Two `oc-harness run` processes in one project would both read the task file,
82
+ * both run the same task, and both write their result back -- the second write
83
+ * winning and the first task's work being reported twice. The lock is a file
84
+ * created with O_EXCL, so the kernel decides the race rather than the order in
85
+ * which the two processes happened to read the directory.
86
+ *
87
+ * A lock whose pid is gone is stale and is cleared. A lock that cannot be
88
+ * parsed is treated the same way: a crashed run must not need a manual `rm`
89
+ * before the next one can start.
90
+ *
91
+ * A live pid is never cleared automatically, because the lock may belong to a
92
+ * run that is simply slow. `force` is the explicit override for the case where
93
+ * the pid was reused by an unrelated process and waiting would be forever.
94
+ */
95
+ export function acquireLock(cwd, { pid = process.pid, now = Date.now(), force = false } = {}) {
96
+ const path = join(cwd, LOCK_PATH)
97
+ for (let attempt = 0; attempt < 2; attempt++) {
98
+ try {
99
+ writeFileSync(path, JSON.stringify({ pid, at: new Date(now).toISOString() }), {
100
+ flag: "wx",
101
+ })
102
+ return {
103
+ path,
104
+ release() {
105
+ try {
106
+ unlinkSync(path)
107
+ } catch {
108
+ // Already gone is the state we wanted.
109
+ }
110
+ },
111
+ }
112
+ } catch (err) {
113
+ if (errorCode(err) !== "EEXIST") {
114
+ return { error: `cannot create ${LOCK_PATH}: ${errorText(err)}` }
115
+ }
116
+ let holder = null
117
+ try {
118
+ holder = JSON.parse(readFileSync(path, "utf8"))
119
+ } catch {
120
+ // A crashed run can leave half a line. Treat it as stale.
121
+ }
122
+ if (holder && pidAlive(holder.pid)) {
123
+ // `force` breaks a live lock once. If another run takes it between the
124
+ // unlink and the next create, that run wins and this one reports busy;
125
+ // forcing again would let two runs in.
126
+ if (!force || attempt > 0) return { busy: holder }
127
+ }
128
+ try {
129
+ unlinkSync(path)
130
+ } catch (err) {
131
+ return { error: `cannot clear stale ${LOCK_PATH}: ${errorText(err)}` }
132
+ }
133
+ }
134
+ }
135
+ return { error: `could not take ${LOCK_PATH}` }
136
+ }
137
+
138
+
139
+ // Colour is for a terminal. A CI job or a redirected log gets plain text:
140
+ // escape codes in a file are noise a reader pays for on every line, and
141
+ // `NO_COLOR` is the conventional way to ask for none.
142
+ const COLOUR = !("NO_COLOR" in process.env) && process.stdout.isTTY && process.env.TERM !== "dumb"
143
+ const paint = (code, s) => (COLOUR ? `\x1b[${code}m${s}\x1b[0m` : String(s))
144
+
145
+ const bold = (s) => paint("1", s)
146
+ const stamp = (s) => paint("2", s)
147
+ const green = (s) => paint("32", s)
148
+ const yellow = (s) => paint("33", s)
149
+ const red = (s) => paint("31", s)
39
150
 
40
151
  /**
41
152
  * Run one command to completion and report what happened.
@@ -75,8 +186,326 @@ export function tail(text, n = 12) {
75
186
  }
76
187
 
77
188
  /**
78
- * Ask `opencode` to do the task. Returns the transcript so a human can read what
79
- * the agent actually did rather than being told about it.
189
+ * The working tree, keyed by path, with the two-letter status and the file's
190
+ * mtime so an edit to a file that was ALREADY dirty is still visible.
191
+ *
192
+ * Returns null when `cwd` is not inside a git work tree. A task's `files:`
193
+ * scope cannot be checked there, and guessing from a directory listing would
194
+ * be worse than saying nothing.
195
+ */
196
+ export function gitChanges(cwd) {
197
+ const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
198
+ encoding: "utf8",
199
+ })
200
+ if (top.status !== 0) return null
201
+ const root = top.stdout.trim()
202
+ const res = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z"], {
203
+ encoding: "utf8",
204
+ maxBuffer: 16 * 1024 * 1024,
205
+ })
206
+ if (res.status !== 0) return null
207
+
208
+ const files = new Map()
209
+ const entries = res.stdout.split("\0")
210
+ for (let i = 0; i < entries.length; i++) {
211
+ const entry = entries[i]
212
+ if (!entry) continue
213
+ const code = entry.slice(0, 2)
214
+ // Renames and copies carry the old path in the next NUL entry. The new
215
+ // path is the one the task is judged on.
216
+ if (code.includes("R") || code.includes("C")) i++
217
+ const path = entry.slice(3)
218
+ if (!path) continue
219
+ let mtimeMs = 0
220
+ try {
221
+ mtimeMs = statSync(join(root, path)).mtimeMs
222
+ } catch {
223
+ // A deleted path has no mtime; its status is the whole signal.
224
+ }
225
+ files.set(path, { code, mtimeMs })
226
+ }
227
+ return { root, files }
228
+ }
229
+
230
+ /**
231
+ * The path-shaped tokens in a task's `files:` line.
232
+ *
233
+ * `files:` is written for a human, so a template placeholder like `<exact
234
+ * paths to change>` must not be read as a filename. A token counts only when
235
+ * it looks like a path: it names a directory (`src/`) or carries a file
236
+ * extension (`run.mjs`). Prose without either is ignored, which turns the
237
+ * scope check off rather than inventing a scope from words.
238
+ */
239
+ export function namedPaths(files) {
240
+ return String(files ?? "")
241
+ .split(/[,;\n]/)
242
+ .flatMap((part) => part.trim().split(/\s+/))
243
+ .map((part) => part.replace(/^`+|`+$/g, "").trim())
244
+ .filter((part) => part && (part.includes("/") || /\.[A-Za-z0-9]{1,8}$/.test(part)))
245
+ }
246
+
247
+ /**
248
+ * Files the agent touched that the task did not name.
249
+ *
250
+ * The status BEFORE the agent ran is the baseline: a repository that was
251
+ * already dirty must not blame the agent for changes it did not make. A file
252
+ * that was already dirty and changed again is caught by the mtime.
253
+ */
254
+ export function outOfScope(task, cwd, before) {
255
+ const named = namedPaths(task.files)
256
+ if (!named.length || !before) return null
257
+ const after = gitChanges(cwd)
258
+ if (!after) return null
259
+
260
+ const moved = [...after.files]
261
+ .filter(([path, now]) => {
262
+ const was = before.files.get(path)
263
+ return !was || was.code !== now.code || was.mtimeMs !== now.mtimeMs
264
+ })
265
+ .map(([path]) => path)
266
+ // `.space/` is the loop's own bookkeeping: the lock, the task file it
267
+ // writes back, the review verdicts, and the plugin's active/telemetry
268
+ // files. The plugin writes telemetry on every agent call, which changes
269
+ // the directory's mtime, so counting it would block every task that
270
+ // touches no code at all.
271
+ .filter((path) => path !== ".space" && !path.startsWith(".space/"))
272
+ if (!moved.length) return null
273
+
274
+ // Both sides go through the same root, and both are resolved the same way.
275
+ // `git rev-parse --show-toplevel` answers with a real path, while the caller
276
+ // may hand over a symlinked one: on macOS /var is /private/var, so a take
277
+ // under /var/folders compared `src/a.js` against
278
+ // ../../../var/folders/.../src/a.js and called every edit out of scope.
279
+ const base = realpathSync(cwd)
280
+ const root = realpathSync(after.root)
281
+ const allowed = named.map((path) => relative(root, resolve(base, path)).split(sep).join("/"))
282
+ const outside = moved.filter(
283
+ (path) => !allowed.some((a) => path === a || path.startsWith(`${a}/`)),
284
+ )
285
+ return outside.length ? { outside, allowed, moved } : null
286
+ }
287
+
288
+ /**
289
+ * The reviewer's final words, from `opencode run --format json`.
290
+ *
291
+ * `--format json` emits one JSON event per line. Only the LAST text part is
292
+ * the answer: a session that says "let me look" before it looks has an earlier
293
+ * text part too, and taking the whole transcript would make the verdict a
294
+ * needle in a haystack. The answer is the last thing the agent said.
295
+ */
296
+ export function agentText(stdout) {
297
+ let last = ""
298
+ for (const line of String(stdout ?? "").split("\n")) {
299
+ const text = line.trim()
300
+ if (!text.startsWith("{")) continue
301
+ try {
302
+ const event = JSON.parse(text)
303
+ if (event?.type === "text" && typeof event.part?.text === "string") {
304
+ last = event.part.text
305
+ }
306
+ } catch {
307
+ // Not every line of stdout is ours. A log line on stdout is noise, not
308
+ // a verdict, and the caller fails closed when no text part was seen.
309
+ }
310
+ }
311
+ return last
312
+ }
313
+
314
+ /** The only schema `--review` accepts. Bump the version to change it. */
315
+ export const REVIEW_SCHEMA = "oc-harness/review@1"
316
+
317
+ /**
318
+ * Validate a reviewer's verdict.
319
+ *
320
+ * Deliberately strict: prose is not a verdict, a verdict with no blockers when
321
+ * it says fail is not a verdict, and a pass that carries blockers contradicts
322
+ * itself. A malformed report is a failed review, never a pass by default.
323
+ */
324
+ export function parseVerdict(text) {
325
+ let raw = String(text ?? "").trim()
326
+ if (!raw) return { ok: false, error: "reviewer returned no verdict" }
327
+
328
+ // A fenced block is a container, not prose. Anything around it is prose,
329
+ // and accepting "here is my verdict: {...}" would be parsing prose by
330
+ // another name.
331
+ const fence = raw.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i)
332
+ if (fence) raw = fence[1].trim()
333
+
334
+ let value
335
+ try {
336
+ value = JSON.parse(raw)
337
+ } catch {
338
+ return { ok: false, error: "reviewer did not return a JSON verdict" }
339
+ }
340
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
341
+ return { ok: false, error: "review verdict is not an object" }
342
+ }
343
+ if (value.schema !== REVIEW_SCHEMA) {
344
+ return { ok: false, error: `review verdict schema is not ${REVIEW_SCHEMA}` }
345
+ }
346
+ if (value.verdict !== "pass" && value.verdict !== "fail") {
347
+ return { ok: false, error: "review verdict must be pass or fail" }
348
+ }
349
+ if (!Array.isArray(value.blockers)) {
350
+ return { ok: false, error: "review verdict has no blockers array" }
351
+ }
352
+
353
+ for (const [i, blocker] of value.blockers.entries()) {
354
+ if (!blocker || typeof blocker !== "object" || Array.isArray(blocker)) {
355
+ return { ok: false, error: `blocker ${i} is not an object` }
356
+ }
357
+ if (typeof blocker.file !== "string" || !blocker.file) {
358
+ return { ok: false, error: `blocker ${i} has no file` }
359
+ }
360
+ if (!Number.isInteger(blocker.line) || blocker.line < 0) {
361
+ return { ok: false, error: `blocker ${i} has no line number` }
362
+ }
363
+ if (typeof blocker.issue !== "string" || !blocker.issue) {
364
+ return { ok: false, error: `blocker ${i} has no issue` }
365
+ }
366
+ if (typeof blocker.consequence !== "string" || !blocker.consequence) {
367
+ return { ok: false, error: `blocker ${i} has no consequence` }
368
+ }
369
+ }
370
+
371
+ if (value.verdict === "pass" && value.blockers.length > 0) {
372
+ return { ok: false, error: "a pass cannot carry blockers" }
373
+ }
374
+ if (value.verdict === "fail" && value.blockers.length === 0) {
375
+ return { ok: false, error: "a fail needs at least one blocker" }
376
+ }
377
+ if (value.notes !== undefined && typeof value.notes !== "string") {
378
+ return { ok: false, error: "review notes must be a string" }
379
+ }
380
+
381
+ return {
382
+ ok: true,
383
+ verdict: value.verdict,
384
+ blockers: value.blockers,
385
+ notes: value.notes ?? "",
386
+ }
387
+ }
388
+
389
+ /** Human-readable blockers, for the task file and the terminal. */
390
+ export function formatBlockers(blockers) {
391
+ return blockers
392
+ .map((b) => ` - ${b.file}:${b.line} ${b.issue}\n because: ${b.consequence}`)
393
+ .join("\n")
394
+ }
395
+
396
+ /**
397
+ * What the reviewer reads: the diff, and the untracked files it must open
398
+ * itself. A diff alone cannot show a new file, and a reviewer that is not told
399
+ * that is a reviewer that passes work it never saw.
400
+ */
401
+ export function reviewDiff(cwd, { maxBytes = 200_000 } = {}) {
402
+ const run = (args) => {
403
+ const res = spawnSync("git", ["-C", cwd, ...args], {
404
+ encoding: "utf8",
405
+ maxBuffer: 64 * 1024 * 1024,
406
+ })
407
+ return res.status === 0 ? res.stdout : ""
408
+ }
409
+
410
+ const tracked = run(["diff", "--no-color", "--no-ext-diff", "--", "."])
411
+ const staged = run(["diff", "--cached", "--no-color", "--no-ext-diff", "--", "."])
412
+ const changes = gitChanges(cwd)
413
+ const untracked = changes
414
+ ? [...changes.files].filter(([, value]) => value.code === "??").map(([path]) => path)
415
+ : []
416
+
417
+ let diff = [tracked, staged].filter((part) => part.trim()).join("\n")
418
+ const truncated = Buffer.byteLength(diff) > maxBytes
419
+ if (truncated) diff = `${diff.slice(0, maxBytes)}\n[diff truncated at ${maxBytes} bytes]`
420
+
421
+ return { diff, untracked, truncated }
422
+ }
423
+
424
+ /** The review request. It asks for one JSON object and nothing else. */
425
+ export function buildReviewPrompt(task, { diff, untracked }, verifyOutput) {
426
+ return [
427
+ `You are the reviewer. Review exactly one task and stop. You never edit, fix or stage anything.`,
428
+ ``,
429
+ `The task, as it was written:`,
430
+ taskPrompt(task),
431
+ ``,
432
+ `The verify command already ran in the orchestrator and passed: ${task.verify}`,
433
+ `Its real output was:`,
434
+ verifyOutput || "(no output)",
435
+ ``,
436
+ `Review the change against the task. Pass 1: does it do exactly what was asked, with no extra changes? Pass 2: defects that will bite next, each with a concrete consequence.`,
437
+ ``,
438
+ `The diff (tracked and staged changes):`,
439
+ diff || "(no tracked diff)",
440
+ ``,
441
+ untracked.length
442
+ ? `These files are untracked and are NOT in the diff. Read them before judging:\n${untracked.map((p) => ` - ${p}`).join("\n")}`
443
+ : `There are no untracked files.`,
444
+ ``,
445
+ `Return ONLY one JSON object, with no prose before or after it, matching this schema exactly:`,
446
+ `{`,
447
+ ` "schema": "${REVIEW_SCHEMA}",`,
448
+ ` "verdict": "pass" | "fail",`,
449
+ ` "blockers": [`,
450
+ ` { "file": "path/from/repo", "line": 1, "issue": "what is wrong", "consequence": "what breaks because of it" }`,
451
+ ` ],`,
452
+ ` "notes": "optional short note"`,
453
+ `}`,
454
+ ``,
455
+ `Rules for the verdict:`,
456
+ ` - pass means blockers is empty. fail means at least one blocker.`,
457
+ ` - Every blocker needs a real file, a line number and a consequence. A preference is not a blocker; drop it or put it in notes.`,
458
+ ` - If you cannot review the change, return fail with a blocker that says why.`,
459
+ ` - Do not wrap the JSON in a sentence. Do not use a code fence. Output the object and nothing else.`,
460
+ ].join("\n")
461
+ }
462
+
463
+ /**
464
+ * Where the validated verdict is kept. A review that leaves no file is a
465
+ * review nobody can audit, and the next run cannot tell it ever happened.
466
+ */
467
+ export function verdictPath(cwd, task, round) {
468
+ const slug = String(task.title)
469
+ .toLowerCase()
470
+ .replace(/[^a-z0-9]+/g, "-")
471
+ .replace(/^-+|-+$/g, "")
472
+ .slice(0, 60)
473
+ return join(cwd, ".space", "review", `${String(round).padStart(2, "0")}-${slug || "task"}.json`)
474
+ }
475
+
476
+ /** Record a verdict atomically, next to the plan it belongs to. */
477
+ export function writeVerdict(cwd, task, round, verdict, verify) {
478
+ const path = verdictPath(cwd, task, round)
479
+ const record = {
480
+ schema: REVIEW_SCHEMA,
481
+ at: new Date().toISOString(),
482
+ task: task.title,
483
+ round,
484
+ reviewer: "reviewer",
485
+ verify: { command: task.verify, exit: verify.status, seconds: verify.seconds },
486
+ verdict: verdict.verdict,
487
+ blockers: verdict.blockers,
488
+ notes: verdict.notes,
489
+ }
490
+ const tmp = `${path}.tmp`
491
+ try {
492
+ mkdirSync(join(cwd, ".space", "review"), { recursive: true })
493
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`)
494
+ renameSync(tmp, path)
495
+ return { path, record }
496
+ } catch (err) {
497
+ try {
498
+ unlinkSync(tmp)
499
+ } catch {
500
+ // The temporary file may never have existed.
501
+ }
502
+ return { error: errorText(err) }
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Ask `opencode` to do the task. Returns the transcript so a human can read
508
+ * what the agent actually did rather than being told about it.
80
509
  */
81
510
  export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
82
511
  // `--dir` is not optional, and neither is PWD.
@@ -97,12 +526,46 @@ export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
97
526
  env: { ...process.env, PWD: cwd },
98
527
  },
99
528
  )
529
+ const stdout = res.stdout ?? ""
100
530
  return {
101
531
  ok: res.status === 0,
102
532
  status: res.status,
103
- output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd(),
533
+ // stdout is kept apart from stderr because `--format json` is a protocol.
534
+ // A progress line on stderr is not a verdict, and merging the two streams
535
+ // would make it look like one.
536
+ stdout,
537
+ stderr: res.stderr ?? "",
538
+ output: `${stdout}${res.stderr ?? ""}`.trimEnd(),
104
539
  error: res.error ? String(res.error.message) : "",
540
+ steps: countSteps(stdout),
541
+ }
542
+ }
543
+
544
+ /**
545
+ * How many model turns the transcript holds.
546
+ *
547
+ * `opencode run --format json` writes one JSON object per line; `step_finish`
548
+ * marks the end of a turn. The count is what tells a failed verify apart from
549
+ * a lane that spent its whole allowance and was forced to answer in text.
550
+ *
551
+ * A line that is not JSON is skipped rather than fatal: a wrapper that prints
552
+ * a banner on stdout must not make the count throw.
553
+ *
554
+ * @param {string} stdout
555
+ * @returns {number}
556
+ */
557
+ export function countSteps(stdout) {
558
+ let steps = 0
559
+ for (const line of String(stdout ?? "").split("\n")) {
560
+ const text = line.trim()
561
+ if (!text) continue
562
+ try {
563
+ if (JSON.parse(text)?.type === "step_finish") steps++
564
+ } catch {
565
+ // Not an event line.
566
+ }
105
567
  }
568
+ return steps
106
569
  }
107
570
 
108
571
  /** The agent's own instructions for one task, plus what the loop expects back. */
@@ -123,6 +586,43 @@ export function buildPrompt(task) {
123
586
  ].join("\n")
124
587
  }
125
588
 
589
+ /**
590
+ * Find the task we were working on in a freshly parsed file.
591
+ *
592
+ * A title alone is not an identity: two tasks can share one, and the ordinal
593
+ * among same-titled tasks shifts when a duplicate before ours is removed or
594
+ * renamed. Matching title + change + files is stable across edits to status
595
+ * and output, which are the only fields the loop writes.
596
+ *
597
+ * When more than one task still fits, there is no honest answer. Returning
598
+ * `null` stops the run; guessing would mark the wrong task done.
599
+ *
600
+ * @param {import("../tasks.js").TaskFile} parsed
601
+ * @param {import("../tasks.js").Task} task
602
+ * @returns {import("../tasks.js").Task | null}
603
+ */
604
+ export function findTask(parsed, task) {
605
+ const candidates = parsed.tasks.filter((t) => t.title === task.title)
606
+ if (candidates.length === 0) return null
607
+
608
+ // Two same-titled tasks are told apart by what they say they change. The
609
+ // match is checked even when only one candidate is left: if that one has
610
+ // different content, it is a different task and the one we were running is
611
+ // gone.
612
+ const narrowed = candidates.filter(
613
+ (t) => t.change === task.change && t.files === task.files,
614
+ )
615
+ if (narrowed.length === 1) return narrowed[0]
616
+
617
+ // A bare title with no change or files is the only case where nothing else
618
+ // identifies the task, so a sole candidate is the best available answer.
619
+ if (candidates.length === 1 && !task.change && !task.files) {
620
+ const [only] = candidates
621
+ return !only.change && !only.files ? only : null
622
+ }
623
+ return null
624
+ }
625
+
126
626
  /**
127
627
  * Run the loop.
128
628
  *
@@ -140,7 +640,13 @@ export function runLoop({
140
640
  role = "build",
141
641
  maxRounds = 20,
142
642
  budgetMinutes = 120,
643
+ allowExtraFiles = false,
644
+ forceUnlock = false,
645
+ /** The role's configured step cap, or 0 when it is unknown. */
646
+ stepCap = 0,
647
+ review = false,
143
648
  runAgent = invokeAgent,
649
+ runReviewer = invokeAgent,
144
650
  runVerify = sh,
145
651
  } = {}) {
146
652
  const started = Date.now()
@@ -157,6 +663,21 @@ export function runLoop({
157
663
  let text = readFileSync(join(cwd, TASKS_PATH), "utf8")
158
664
  let rounds = 0
159
665
 
666
+ // One writer per task file. The lock is taken before the first read of the
667
+ // file and released on EVERY exit, including a throw from a custom runner.
668
+ const lock = acquireLock(cwd, { force: forceUnlock })
669
+ if (lock.busy) {
670
+ return stop(log, {
671
+ reason: `another run is already in progress in this project (pid ${lock.busy.pid}). Wait for it, or remove ${LOCK_PATH} if it is gone.`,
672
+ code: 2,
673
+ })
674
+ }
675
+ if (lock.error) {
676
+ return stop(log, { reason: lock.error, code: 2 })
677
+ }
678
+
679
+ try {
680
+
160
681
  /**
161
682
  * The task file, read fresh, and the task we were working on inside it.
162
683
  *
@@ -174,16 +695,78 @@ export function runLoop({
174
695
  try {
175
696
  fresh = readFileSync(join(cwd, TASKS_PATH), "utf8")
176
697
  } catch {
177
- return { text, task }
698
+ // The file is gone. Writing the copy we read at the start would recreate
699
+ // it from stale state, including any task the agent or a human removed.
700
+ return null
178
701
  }
179
702
  const parsed = parseTasks(fresh)
180
- const same = parsed.tasks.filter((t) => t.title === task.title)[task.ordinal ?? 0]
181
- if (!same) return { text, task }
703
+ const same = findTask(parsed, task)
704
+ if (!same) return null
182
705
  return { text: fresh, task: same }
183
706
  }
184
707
 
708
+ /**
709
+ * Apply an outcome to the CURRENT file and write it back atomically.
710
+ *
711
+ * Returns false when the file changed underneath us: the caller must stop
712
+ * rather than write a stale list. The write goes through a temporary file so
713
+ * a reader never sees half a task file, and so a crash cannot leave a
714
+ * truncated plan behind.
715
+ */
716
+ const writeBack = (task, outcome) => {
717
+ const fresh = reload(task)
718
+ if (!fresh) return false
719
+ text = markTask(fresh.text, fresh.task, outcome)
720
+ const target = join(cwd, TASKS_PATH)
721
+ const tmp = `${target}.tmp`
722
+ try {
723
+ writeFileSync(tmp, text)
724
+ renameSync(tmp, target)
725
+ } catch {
726
+ try {
727
+ unlinkSync(tmp)
728
+ } catch {
729
+ // There is nothing to clean up if the temporary file was never made.
730
+ }
731
+ return false
732
+ }
733
+ return true
734
+ }
735
+
736
+ const changedUnderneath = (task) =>
737
+ stop(log, {
738
+ reason: `the task file changed or disappeared while "${task.title}" was in flight. Not writing a stale task list -- check ${TASKS_PATH} and run again.`,
739
+ code: 2,
740
+ rounds,
741
+ })
742
+
185
743
  for (;;) {
186
744
  const parsed = parseTasks(text)
745
+
746
+ // An empty plan is not a finished plan. It used to fall through to
747
+ // `nextPending()`, find nothing, and print "every task is done (0/0)" --
748
+ // a green result for a file that holds no tasks at all.
749
+ if (parsed.tasks.length === 0) {
750
+ return stop(log, {
751
+ reason: `no tasks found in ${TASKS_PATH}. Run space-plan first -- this loop needs a plan to walk.`,
752
+ code: 2,
753
+ rounds,
754
+ })
755
+ }
756
+
757
+ // Work is done in file order. `nextPending()` skips blocked tasks, so
758
+ // checking it alone ran every task AFTER a blocker and only reported the
759
+ // blocker at the end. Find the first task that is not done and stop if it
760
+ // is blocked; pending work before it still runs.
761
+ const firstOpen = parsed.tasks.find((t) => !isDone(t))
762
+ if (firstOpen && isBlocked(firstOpen)) {
763
+ return stop(log, {
764
+ reason: `task is blocked and needs a person: ${firstOpen.title}`,
765
+ code: 2,
766
+ rounds,
767
+ })
768
+ }
769
+
187
770
  const task = nextPending(parsed)
188
771
 
189
772
  if (!task) {
@@ -199,17 +782,12 @@ export function runLoop({
199
782
  })
200
783
  }
201
784
 
202
- if (isBlocked(task)) {
203
- return stop(log, { reason: `task is blocked and needs a person: ${task.title}`, code: 2, rounds })
204
- }
205
-
206
785
  if (!task.verify) {
207
- const fresh = reload(task)
208
- text = markTask(fresh.text, fresh.task, {
786
+ const wrote = writeBack(task, {
209
787
  status: "blocked",
210
788
  verifyOutput: "blocked by the loop: this task has no verify command, so nothing could prove it finished.",
211
789
  })
212
- writeFileSync(join(cwd, TASKS_PATH), text)
790
+ if (!wrote) return changedUnderneath(task)
213
791
  return stop(log, {
214
792
  reason: `"${task.title}" has no verify command. It cannot be checked, so it cannot be run unattended. Add one, or do it yourself.`,
215
793
  code: 2,
@@ -217,6 +795,22 @@ export function runLoop({
217
795
  })
218
796
  }
219
797
 
798
+ // `verify: true` and `verify: echo ok` exit 0 whether the change landed or
799
+ // not. A command that cannot fail is not a check, and running it would
800
+ // mark the task done on no evidence at all.
801
+ if (isVacuousVerify(task.verify)) {
802
+ const wrote = writeBack(task, {
803
+ status: "blocked",
804
+ verifyOutput: `blocked by the loop: "${task.verify}" cannot fail, so it proves nothing.`,
805
+ })
806
+ if (!wrote) return changedUnderneath(task)
807
+ return stop(log, {
808
+ reason: `"${task.title}" has a verify command that cannot fail ("${task.verify}"). Replace it with a command that fails when the change is wrong.`,
809
+ code: 2,
810
+ rounds,
811
+ })
812
+ }
813
+
220
814
  if (rounds >= maxRounds) {
221
815
  return stop(log, { reason: `stopped at the round cap (${maxRounds}). Raise it with --max-rounds, or run fewer tasks.`, code: 2, rounds })
222
816
  }
@@ -234,25 +828,90 @@ export function runLoop({
234
828
  console.log(`\n ${bold(`[${rounds}]`)} ${task.title}`)
235
829
  if (task.files) console.log(` ${stamp(task.files)}`)
236
830
 
831
+ // What the tree looked like before the agent touched it. The scope check
832
+ // compares against this, so a repository that was already dirty does not
833
+ // blame the agent for someone else's changes.
834
+ const beforeAgent = allowExtraFiles ? null : gitChanges(cwd)
835
+
836
+ const agentTimeoutMs = remaining()
237
837
  const agent = runAgent(role, buildPrompt(task), {
238
838
  cwd,
239
- timeoutMs: remaining(),
839
+ timeoutMs: agentTimeoutMs,
240
840
  })
241
841
  if (!agent.ok) {
242
842
  const why = agent.error || agent.output || `exit ${agent.status}`
243
- const fresh = reload(task)
244
- text = markTask(fresh.text, fresh.task, {
843
+ // A route that never answers is a timeout, not a failed edit: it hangs
844
+ // until the ceiling kills it. The ceiling is the budget, so at the
845
+ // default that is two hours spent on one task with nothing to show for
846
+ // it, and the stop message is the only place that says why.
847
+ const timedOut = /ETIMEDOUT|timed out/i.test(String(agent.error ?? ""))
848
+ const limit =
849
+ agentTimeoutMs >= 60_000
850
+ ? `${Math.round(agentTimeoutMs / 60_000)} min`
851
+ : `${Math.round(agentTimeoutMs / 1000)}s`
852
+ const capNote = timedOut
853
+ ? `The agent did not answer within the ${limit} the budget allowed. ` +
854
+ `That is what a spent or unreachable model route looks like: check it with \`opencode run --print-logs -m <model> hi\`, ` +
855
+ `and lower --budget if you would rather fail sooner.`
856
+ : ""
857
+ const wrote = writeBack(task, {
245
858
  status: "blocked",
246
- verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}`,
859
+ verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}${capNote ? `\n\n${capNote}` : ""}`,
247
860
  })
248
- writeFileSync(join(cwd, TASKS_PATH), text)
861
+ if (!wrote) return changedUnderneath(task)
249
862
  return stop(log, {
250
- reason: `the ${role} agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
863
+ reason: timedOut
864
+ ? `the ${role} agent timed out on "${task.title}". ${capNote}`
865
+ : `the ${role} agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
866
+ code: 3,
867
+ rounds,
868
+ })
869
+ }
870
+
871
+ // Exit 0 with nothing at all on stdout is not an agent that chose to do
872
+ // nothing: it is the shape of a route that never answered. A spent free
873
+ // route makes `opencode run` print the provider's reason on stderr (only
874
+ // with --print-logs) and exit 0, so without this the loop runs the verify
875
+ // against an untouched tree and blocks the task for a change the model
876
+ // never attempted. `stdout` is checked rather than `steps` because a
877
+ // custom runner in a test has no event stream to count.
878
+ if (agent.ok && typeof agent.stdout === "string" && agent.stdout.trim() === "") {
879
+ const why = agent.stderr || agent.error || "(nothing on stderr either)"
880
+ const wrote = writeBack(task, {
881
+ status: "blocked",
882
+ verifyOutput: `blocked by the loop: the ${role} agent exited 0 without producing a word.\n${tail(why)}`,
883
+ })
884
+ if (!wrote) return changedUnderneath(task)
885
+ return stop(log, {
886
+ reason:
887
+ `the ${role} agent answered nothing on "${task.title}". The model route is probably spent or unreachable; ` +
888
+ `run \`opencode run --print-logs -m <model> hi\` to see the provider's reason. Nothing was verified, so no task was scored.`,
251
889
  code: 3,
252
890
  rounds,
253
891
  })
254
892
  }
255
893
 
894
+ // A task names the files it may touch. An agent that edits outside that
895
+ // list has gone beyond the plan, and an unattended loop that lets that
896
+ // pass is an unattended loop that can rewrite the repository one task at
897
+ // a time. `--allow-extra-files` is the deliberate escape hatch.
898
+ if (!allowExtraFiles) {
899
+ const scope = outOfScope(task, cwd, beforeAgent)
900
+ if (scope) {
901
+ const listed = scope.outside.map((p) => ` - ${p}`).join("\n")
902
+ const wrote = writeBack(task, {
903
+ status: "blocked",
904
+ verifyOutput: `blocked by the loop: the agent changed files the task did not name.\n${listed}`,
905
+ })
906
+ if (!wrote) return changedUnderneath(task)
907
+ return stop(log, {
908
+ reason: `"${task.title}" changed files outside its scope:\n${listed}\nAdd them to the task's files: line, or rerun with --allow-extra-files.`,
909
+ code: 2,
910
+ rounds,
911
+ })
912
+ }
913
+ }
914
+
256
915
  // The agent's claim is not the evidence. Re-run the verify here, because a
257
916
  // loop that trusts the agent's own word about whether it passed is a loop
258
917
  // that reports success it never checked.
@@ -261,27 +920,106 @@ export function runLoop({
261
920
  log.push({ round: rounds, task: task.title, ok: check.ok, seconds: check.seconds })
262
921
 
263
922
  if (!check.ok) {
264
- const fresh = reload(task)
265
- text = markTask(fresh.text, fresh.task, {
923
+ // A lane at its step cap is forced into a text-only answer. opencode
924
+ // exits 0, so without this the run reports a plain verify failure and
925
+ // the actual cause - a cap nobody looked at - stays in the log.
926
+ const capped = stepCap > 0 && (agent.steps ?? 0) >= stepCap
927
+ const capNote = capped
928
+ ? `the agent used its whole ${stepCap}-step allowance before the check ran. Raise agent.${role}.steps, or split the task.`
929
+ : ""
930
+ const wrote = writeBack(task, {
266
931
  status: "blocked",
267
- verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}`,
932
+ verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}${capNote ? `\n\n${capNote}` : ""}`,
268
933
  })
269
- writeFileSync(join(cwd, TASKS_PATH), text)
934
+ if (!wrote) return changedUnderneath(task)
270
935
  return stop(log, {
271
- reason: `verify failed on "${task.title}". Marked blocked -- the real output is in ${TASKS_PATH}.`,
936
+ reason:
937
+ `verify failed on "${task.title}". Marked blocked -- the real output is in ${TASKS_PATH}.` +
938
+ (capNote ? `\n ${capNote}` : ""),
272
939
  code: 4,
273
940
  rounds,
274
941
  })
275
942
  }
276
943
 
277
- const fresh = reload(task)
278
- text = markTask(fresh.text, fresh.task, {
944
+ let reviewNote = ""
945
+ if (review) {
946
+ // A separate lane reviews, and it is asked for one JSON object. The
947
+ // verdict is validated here rather than trusted, and the file it is
948
+ // written to is the record a human can audit afterwards. The reviewer
949
+ // cannot edit, so the orchestrator is the one that persists the verdict.
950
+ console.log(` ${stamp("review: reviewer")}`)
951
+ const diff = reviewDiff(cwd)
952
+ const reviewed = runReviewer("reviewer", buildReviewPrompt(task, diff, check.output), {
953
+ cwd,
954
+ timeoutMs: remaining(),
955
+ })
956
+
957
+ if (!reviewed.ok) {
958
+ const why = reviewed.error || reviewed.output || `exit ${reviewed.status}`
959
+ const wrote = writeBack(task, {
960
+ status: "blocked",
961
+ verifyOutput: `review failed: the reviewer agent failed.\n${tail(why)}`,
962
+ })
963
+ if (!wrote) return changedUnderneath(task)
964
+ return stop(log, {
965
+ reason: `the reviewer agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
966
+ code: 5,
967
+ rounds,
968
+ })
969
+ }
970
+
971
+ const verdict = parseVerdict(agentText(reviewed.stdout))
972
+ if (!verdict.ok) {
973
+ const said = tail(agentText(reviewed.stdout) || reviewed.stdout || reviewed.output)
974
+ const wrote = writeBack(task, {
975
+ status: "blocked",
976
+ verifyOutput: `review failed: ${verdict.error}\n${said}`,
977
+ })
978
+ if (!wrote) return changedUnderneath(task)
979
+ return stop(log, {
980
+ reason: `the reviewer did not return a schema'd verdict on "${task.title}": ${verdict.error}`,
981
+ code: 5,
982
+ rounds,
983
+ })
984
+ }
985
+
986
+ const saved = writeVerdict(cwd, task, rounds, verdict, check)
987
+ if (saved.error) {
988
+ return stop(log, {
989
+ reason: `the review verdict could not be written: ${saved.error}`,
990
+ code: 5,
991
+ rounds,
992
+ })
993
+ }
994
+
995
+ if (verdict.verdict === "fail") {
996
+ const blockers = formatBlockers(verdict.blockers)
997
+ const wrote = writeBack(task, {
998
+ status: "blocked",
999
+ verifyOutput: `review failed (${relative(cwd, saved.path)})\n${blockers}`,
1000
+ })
1001
+ if (!wrote) return changedUnderneath(task)
1002
+ return stop(log, {
1003
+ reason: `review failed on "${task.title}":\n${blockers}\nThe verdict is in ${relative(cwd, saved.path)}.`,
1004
+ code: 5,
1005
+ rounds,
1006
+ })
1007
+ }
1008
+
1009
+ reviewNote = `\nreview: pass (${relative(cwd, saved.path)})`
1010
+ console.log(` ${green("reviewed")} ${stamp(relative(cwd, saved.path))}`)
1011
+ }
1012
+
1013
+ const wrote = writeBack(task, {
279
1014
  status: "done",
280
- verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}`,
1015
+ verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}${reviewNote}`,
281
1016
  })
282
- writeFileSync(join(cwd, TASKS_PATH), text)
1017
+ if (!wrote) return changedUnderneath(task)
283
1018
  console.log(` ${green("done")} ${stamp(`${check.seconds}s`)}`)
284
1019
  }
1020
+ } finally {
1021
+ lock.release()
1022
+ }
285
1023
  }
286
1024
 
287
1025
  /**