@bybrawe/opencode-loop 0.6.8 → 0.6.9

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,7 +1,7 @@
1
1
  import test from "node:test"
2
2
  import assert from "node:assert/strict"
3
3
  import { spawn } from "node:child_process"
4
- import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
4
+ import { mkdtemp, mkdir, writeFile, readFile, realpath, rm } from "node:fs/promises"
5
5
  import os from "node:os"
6
6
  import path from "node:path"
7
7
  import { fileURLToPath } from "node:url"
@@ -47,9 +47,12 @@ process.stdin.resume();
47
47
  assert.equal(result.code, 0, `${mode}: ${result.stdout}\n${result.stderr}`)
48
48
  const calls = (await readFile(log, "utf8")).trim().split(/\r?\n/).map(JSON.parse)
49
49
  if (mode === "pwd") {
50
+ // macOS /var/folders is an alias of /private/var/folders; the V2 host
51
+ // compares project PWD with the physical cwd, not the original alias.
52
+ const canonical = process.platform === "win32" ? project : await realpath(project)
50
53
  for (const call of calls.filter(call => ["session", "run"].includes(call.args[0]))) {
51
- assert.equal(call.pwd, project, "native lookup and run must use the selected project, not the caller's PWD")
52
- assert.equal(call.cwd, project)
54
+ assert.equal(call.pwd, canonical, "native lookup and run must use the canonical selected project, not the caller's PWD")
55
+ assert.equal(call.cwd, canonical)
53
56
  }
54
57
  }
55
58
  if (mode === "stdin") assert.match(result.stdout, /INPUT=\r?\n/)
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict"
2
2
  import { spawnSync } from "node:child_process"
3
- import { readFileSync } from "node:fs"
3
+ import { readFileSync, realpathSync } from "node:fs"
4
4
  import path from "node:path"
5
5
  import { fileURLToPath, pathToFileURL } from "node:url"
6
6
 
@@ -11,7 +11,9 @@ assert.ok(process.argv.length <= 3, "expected at most one consumer directory")
11
11
  const loop = pkg.name === "@bybrawe/opencode-loop"
12
12
  const cwd = process.argv[2] ? path.resolve(process.argv[2]) : root
13
13
  const packageRoot = process.argv[2] ? path.join(cwd, "node_modules", ...pkg.name.split("/")) : root
14
- const url = (relative) => pathToFileURL(path.join(packageRoot, relative)).href
14
+ // Node resolves ESM file URLs to real paths on macOS, where /var aliases /private/var.
15
+ // Guard and delegated V1 entry identities must use the same canonical URLs as the loader.
16
+ const url = (relative) => pathToFileURL(realpathSync.native(path.join(packageRoot, relative))).href
15
17
  const legacyEntries = loop ? [url("src/v1.js"), url("src/source/v1.js")] : [url("dist/legacy-v1.js")]
16
18
  const blocked = loop
17
19
  ? [...legacyEntries, url("src/index.js"), url("src/source/legacy-v1.js")]
@@ -23,15 +25,23 @@ const serverEntries = loop ? [`${pkg.name}/server`, url("src/source/server.js")]
23
25
  function probe(mode) {
24
26
  const stub = "globalThis.__legacyModuleLoads = (globalThis.__legacyModuleLoads || 0) + 1; export default async (...args) => { if (args[0]?.failure) throw args[0].failure; return { args }; }"
25
27
  const loader = `
26
- const blocked = ${JSON.stringify(blocked)};
27
- const legacyEntries = ${JSON.stringify(legacyEntries)};
28
+ import { realpathSync } from "node:fs";
29
+ import { fileURLToPath } from "node:url";
30
+ const physical = (url) => {
31
+ if (!url.startsWith("file:")) return url;
32
+ const name = realpathSync.native(fileURLToPath(url));
33
+ return process.platform === "win32" ? name.toLowerCase() : name;
34
+ };
35
+ const blocked = new Set(${JSON.stringify(blocked)}.map(physical));
36
+ const legacyEntries = new Set(${JSON.stringify(legacyEntries)}.map(physical));
28
37
  export async function resolve(specifier, context, nextResolve) {
29
38
  if (specifier.startsWith("@opencode-ai/")) throw new Error("V2_LEGACY_IMPORT: " + specifier);
30
39
  const result = await nextResolve(specifier, context);
31
- if (${JSON.stringify(mode)} === "delegate" && legacyEntries.includes(result.url)) {
40
+ const actual = physical(result.url);
41
+ if (${JSON.stringify(mode)} === "delegate" && legacyEntries.has(actual)) {
32
42
  return { url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(stub)}`)}, shortCircuit: true };
33
43
  }
34
- if (blocked.includes(result.url)) throw new Error("V2_LEGACY_IMPORT: " + result.url);
44
+ if (blocked.has(actual)) throw new Error("V2_LEGACY_IMPORT: " + result.url);
35
45
  return result;
36
46
  }
37
47
  `
@@ -7,11 +7,11 @@ export function validateRelease(request, pkg, env) {
7
7
  assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
8
8
  assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
9
9
  assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
10
- assert.equal(request?.version, "0.6.8", "release version is not authorized")
10
+ assert.equal(request?.version, "0.6.9", "release version is not authorized")
11
11
  assert.equal(request?.name, "@bybrawe/opencode-loop")
12
12
  assert.equal(pkg.name, request.name)
13
13
  assert.equal(pkg.version, request.version)
14
- assert.equal(request.predecessor, "0.6.7")
14
+ assert.equal(request.predecessor, "0.6.8")
15
15
  assert.equal(request.goal?.version, "1.3.49")
16
16
  assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
17
17
  return request
@@ -47,6 +47,67 @@ async function test(name, run, extra) {
47
47
  finally { await f.close() }
48
48
  }
49
49
 
50
+ await test("explicit checklist stop prevents the next V2 model turn", async (f) => {
51
+ await writeFile(path.join(f.directory, "TASK.md"), "- [ ] implement\n- [x] setup\n")
52
+ const added = await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3")
53
+ assert.equal(added.accepted, true)
54
+ await f.wake()
55
+ assert.equal(f.prompts.length, 1)
56
+ await writeFile(path.join(f.directory, "TASK.md"), "- [x] implement\n- [x] setup\n")
57
+ await f.complete()
58
+ const job = (await f.state()).jobs[0]
59
+ assert.equal(f.prompts.length, 1, "no redundant model prompt after host checks TASK.md")
60
+ assert.equal(job.enabled, false)
61
+ assert.equal(job.paused, true)
62
+ assert.equal(job.pauseReason, "checklist-complete")
63
+ assert.deepEqual(job.checklistStatus, { status: "complete", total: 2, pending: 0 })
64
+ })
65
+
66
+ await test("explicit checklist refuses missing project file instead of spending tokens", async (f) => {
67
+ assert.equal((await f.add("0s --progress-file MISSING.md --until-checklist-complete implement")).accepted, true)
68
+ await f.wake()
69
+ const job = (await f.state()).jobs[0]
70
+ assert.equal(f.prompts.length, 0)
71
+ assert.equal(job.paused, true)
72
+ assert.equal(job.pauseReason, "checklist-unavailable")
73
+ assert.equal(job.lastChecklistError, "file-missing")
74
+ })
75
+
76
+ await test("an already-complete checklist still requires host verification when requested", async (f) => {
77
+ await writeFile(path.join(f.directory, "TASK.md"), "- [x] done\n")
78
+ await f.add('0s --progress-file TASK.md --until-checklist-complete --verify "npm test" check work --max-runs 2')
79
+ await f.wake()
80
+ assert.equal(f.prompts.length, 1, "unobserved verify must not pass by default")
81
+ await f.complete()
82
+ const job = (await f.state()).jobs[0]
83
+ assert.equal(f.hooks.length, 1)
84
+ assert.equal(f.hooks[0], "npm test")
85
+ assert.equal(f.prompts.length, 1, "observed verify pass stops before another turn")
86
+ assert.equal(job.enabled, false)
87
+ assert.equal(job.pauseReason, "checklist-complete")
88
+ assert.equal(job.lastVerifyCode, 0)
89
+ })
90
+
91
+ await test("progress-file without opt-in still follows existing finite or infinite semantics", async (f) => {
92
+ await writeFile(path.join(f.directory, "TASK.md"), "- [x] all done\n")
93
+ await f.add("0s --progress-file TASK.md task --max-runs 1")
94
+ await f.wake()
95
+ assert.equal(f.prompts.length, 1)
96
+ assert.equal((await f.state()).jobs[0].untilChecklistComplete, false)
97
+ })
98
+
99
+ await test("native checklist flag requires explicit progress file and status reveals stop controls", async (f) => {
100
+ const rejected = await f.add("0s --until-checklist-complete implement")
101
+ assert.equal(rejected.accepted, false)
102
+ assert.match(rejected.error, /--progress-file/)
103
+ await writeFile(path.join(f.directory, "TASK.md"), "- [ ] todo\n")
104
+ await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3")
105
+ const status = await f.control("loop-status")
106
+ assert.match(status.status.text, /checklist-stop:TASK\.md/)
107
+ assert.match(status.status.text, /\/loop-stop/)
108
+ assert.match(status.status.text, /\/loop-clear/)
109
+ })
110
+
50
111
  await test("one durable prompt under concurrent idle notifications", async (f) => {
51
112
  assert.equal((await f.add("0s keep working --max-runs 2")).accepted, true)
52
113
  await Promise.all(Array.from({ length: 12 }, () => f.wake()))
@@ -224,7 +224,8 @@ function commandNames(payload) {
224
224
  }
225
225
 
226
226
  async function main() {
227
- assert.equal(process.platform, "linux", "the real OpenCode 2 Loop canary is intentionally Ubuntu-only")
227
+ const supported = process.platform === "linux" || (process.platform === "darwin" && process.env.OPENCODE2_MACOS_NATIVE_CANARY === "1")
228
+ assert.ok(supported, "real OpenCode 2 canary supports Linux or explicitly opted-in macOS CI only")
228
229
 
229
230
  const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
230
231
  const home = path.join(workspace, ".home")
@@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url"
8
8
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
9
9
  const npmCLI = process.env.npm_execpath
10
10
  assert.ok(npmCLI, "run this through npm run package:smoke")
11
- function run(command, args, cwd) {
11
+ function run(command, args, cwd, timeout = 180_000) {
12
12
  const result = spawnSync(command, args, {
13
- cwd, encoding: "utf8", timeout: 180_000, maxBuffer: 8 * 1024 * 1024,
13
+ cwd, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024,
14
14
  windowsHide: true,
15
15
  env: { ...process.env, npm_config_fetch_retries: "1", npm_config_fetch_timeout: "90000" },
16
16
  })
@@ -30,22 +30,54 @@ try {
30
30
  await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
31
31
  const packedTarball = path.join(temp, packed[0].filename)
32
32
  const installArgs = ["install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund"]
33
+ const strictInstall = process.env.OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL === "1"
34
+ // A cache-only npm install should finish quickly. On Windows, a cache miss
35
+ // sometimes blocks inside npm for the entire 180-second subprocess deadline.
36
+ // Bound the best-effort offline attempt; strict opt-in keeps the full gate.
37
+ const offlineTimeout = process.platform === "win32" && !strictInstall ? 30_000 : 180_000
38
+ async function strictCleanOnlineInstall() {
39
+ // Busy Windows runners can stall fetching npm metadata even after npm ci.
40
+ // Retry once on subprocess ETIMEDOUT using a *new* empty consumer.
41
+ // This path never accepts the tar archive or CI-installed dependency tree
42
+ // as a substitute for a real npm install.
43
+ const maxAttempts = process.platform === "win32" ? 2 : 1
44
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
45
+ try {
46
+ const timeoutMs = process.platform === "win32" && attempt === 2 ? 270_000 : 180_000
47
+ run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer, timeoutMs)
48
+ console.log("Strict clean npm tarball consumer install PASS")
49
+ return
50
+ } catch (error) {
51
+ if (attempt >= maxAttempts || error?.cause?.code !== "ETIMEDOUT") throw error
52
+ console.warn("Strict Windows npm install timed out; retrying once from a fresh empty consumer, not a fallback")
53
+ await rm(consumer, { recursive: true, force: true, maxRetries: 8, retryDelay: 150 })
54
+ await mkdir(consumer, { recursive: true })
55
+ await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
56
+ }
57
+ }
58
+ }
33
59
  try {
34
60
  // npm ci populated this job's npm cache from the lockfile. A cache-only
35
61
  // install remains an exact, clean npm consumer when all metadata is present.
36
- run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer)
62
+ run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer, offlineTimeout)
37
63
  console.log("Clean tarball npm install from local cache PASS")
38
64
  } catch (error) {
39
- if (!/ENOTCACHED|cache mode is .only-if-cached./i.test(String(error))) throw error
40
- if (process.platform !== "win32" || process.env.OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL === "1") {
65
+ const cacheMissing = /ENOTCACHED|cache mode is .only-if-cached./i.test(String(error))
66
+ const windowsOfflineTimeout = process.platform === "win32" && !strictInstall && error?.cause?.code === "ETIMEDOUT"
67
+ if (!cacheMissing && !windowsOfflineTimeout) throw error
68
+ if (process.platform !== "win32" || strictInstall) {
41
69
  console.warn("Offline cache incomplete: performing strict bounded online npm consumer install")
42
- run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
70
+ if (strictInstall) await strictCleanOnlineInstall()
71
+ else run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
43
72
  } else {
44
73
  // GitHub Windows runners sporadically hang for minutes while npm fetches
45
74
  // transient dependency packuments. We still check the EXACT production
46
75
  // archive and isolated V2 imports on Windows, using the already-verified
47
76
  // npm-ci dependency tree. Linux continues to gate a real clean npm install.
48
77
  // This is intentionally NOT labelled a successful Windows npm install.
78
+ // An interrupted npm process may have left a partial consumer tree.
79
+ // Never combine that unverified tree with the deterministic archive test.
80
+ await rm(path.join(consumer, "node_modules"), { recursive: true, force: true, maxRetries: 8, retryDelay: 150 })
49
81
  const packageRoot = path.join(consumer, "node_modules", "@bybrawe", "opencode-loop")
50
82
  await mkdir(packageRoot, { recursive: true })
51
83
  run("tar", ["-xzf", packedTarball, "-C", packageRoot, "--strip-components=1"], temp)
@@ -56,7 +88,9 @@ try {
56
88
  path.join(scope, "plugin"),
57
89
  "junction",
58
90
  )
59
- console.warn("Windows npm cache lacks package metadata; testing packed archive with preinstalled dependency tree (not a clean npm install)")
91
+ console.warn(windowsOfflineTimeout
92
+ ? "Windows offline npm install exceeded 30s; checking the production archive with pinned CI dependencies (NOT a clean npm install)"
93
+ : "Windows npm cache lacks package metadata; checking the production archive with pinned CI dependencies (NOT a clean npm install)")
60
94
  }
61
95
  }
62
96
  console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
@@ -20,6 +20,7 @@ assert.match(workflow, /needs:\s*\[native-regressions, real-native-pair\]/)
20
20
  assert.match(workflow, /node scripts\/native-release\.mjs ci/)
21
21
  assert.match(workflow, /git diff --exit-code -- src\/index.js src\/server.js src\/native.js/)
22
22
  assert.match(workflow, /npm run package:smoke/)
23
+ assert.match(workflow, /OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL:\s*\x271\x27/, "Windows/Linux npm release gate must require a strict clean consumer install, never a tarball fallback")
23
24
  assert.match(workflow, /windows-latest/)
24
25
  assert.match(workflow, /Decide whether npm publish is needed/)
25
26
  assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
@@ -48,14 +49,14 @@ assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$att
48
49
  assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
49
50
 
50
51
  const sha = "a".repeat(40)
51
- const request = { name: "@bybrawe/opencode-loop", version: "0.6.8", predecessor: "0.6.7", goal: { version: "1.3.49", sha: "b".repeat(40) } }
52
+ const request = { name: "@bybrawe/opencode-loop", version: "0.6.9", predecessor: "0.6.8", goal: { version: "1.3.49", sha: "b".repeat(40) } }
52
53
  const pkg = { name: request.name, version: request.version }
53
54
  const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
54
55
  assert.strictEqual(validateRelease(request, pkg, env), request)
55
56
  for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
56
57
  assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
57
58
  assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
58
- assert.equal(releaseDecision(404, null, request, sha, "0.6.7"), true)
59
+ assert.equal(releaseDecision(404, null, request, sha, "0.6.8"), true)
59
60
  for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
60
61
  assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
61
62
  const manifest = { ...pkg, gitHead: sha }
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict"
2
2
  import path from "node:path"
3
+ import os from "node:os"
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises"
3
5
  import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js"
6
+ import { inspectTaskChecklist } from "../src/source/runtime/checklist-completion.js"
4
7
 
5
8
  assert.throws(() => createRunAdmissionRuntime({}), /untilReached/)
6
9
  assert.throws(() => createRunAdmissionRuntime({ untilReached: async () => false }), /scheduleDueWork/)
@@ -130,4 +133,48 @@ assert.equal(admitted.paused, undefined)
130
133
  assert.equal(admitted.runNowRequestedAt, clock)
131
134
  assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 0)
132
135
 
136
+ const project = await mkdtemp(path.join(os.tmpdir(), "loop-admission-checklist-"))
137
+ try {
138
+ await writeFile(path.join(project, "TASK.md"), "- [ ] code\n- [x] plan\n")
139
+ const checklistJob = { id: "checklist-job", enabled: true, progressFile: "TASK.md", untilChecklistComplete: true }
140
+ const checklistState = { jobs: [checklistJob] }
141
+ assert.equal((await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob)).admitted, true)
142
+ assert.deepEqual(checklistJob.checklistStatus, { status: "pending", total: 2, pending: 1 })
143
+
144
+ await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n")
145
+ assert.deepEqual(await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob),
146
+ { admitted: false, reason: "checklist_complete" })
147
+ assert.equal(checklistState.jobs.length, 0)
148
+ assert.equal(notifications.at(-1)[2], "checklist_complete")
149
+
150
+ const verifyJob = { id: "verify-checklist", enabled: true, progressFile: "TASK.md",
151
+ untilChecklistComplete: true, verifyCommand: "npm test" }
152
+ const verifyState = { jobs: [verifyJob] }
153
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
154
+ "unchecked host verification cannot imply completion")
155
+ verifyJob.lastVerifyCode = 1
156
+ verifyJob.lastVerifyAt = clock
157
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
158
+ "failed host verification cannot imply completion")
159
+ verifyJob.lastVerifyCode = 0
160
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
161
+ "successful verify without recorded checklist fingerprint is not authoritative")
162
+ verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest
163
+ await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n- [x] later task\n")
164
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
165
+ "verification for older checklist bytes cannot certify newly changed work")
166
+ verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest
167
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).reason, "checklist_complete")
168
+
169
+ const missingJob = { id: "missing-checklist", enabled: true, progressFile: "MISSING.md", untilChecklistComplete: true }
170
+ const missingState = { jobs: [missingJob] }
171
+ const missing = await runtime.admitJob(project, {}, "session-missing", missingState, missingJob)
172
+ assert.deepEqual(missing, { admitted: false, reason: "checklist_unavailable" })
173
+ assert.equal(missingJob.paused, true)
174
+ assert.equal(missingJob.lastChecklistError, "file-missing")
175
+ assert.equal(missingState.jobs.length, 1, "preserve job for explicit recovery")
176
+ } finally {
177
+ await rm(project, { force: true, recursive: true })
178
+ }
179
+
133
180
  console.log("run admission runtime tests passed")
package/src/index.js CHANGED
@@ -141,6 +141,7 @@ function parseLoopArgs(raw, defaults = {}) {
141
141
  until: defaults.until,
142
142
  stopFile: defaults.stopFile,
143
143
  progressFile: defaults.progressFile,
144
+ untilChecklistComplete: defaults.untilChecklistComplete ?? false,
144
145
  promptFile: defaults.promptFile,
145
146
  includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
146
147
  watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
@@ -221,6 +222,9 @@ function parseLoopArgs(raw, defaults = {}) {
221
222
  [found, rest] = takeFlag(rest, "--pause-on-verify-fail");
222
223
  if (found)
223
224
  job.pauseOnVerifyFail = true;
225
+ [found, rest] = takeFlag(rest, "--until-checklist-complete");
226
+ if (found)
227
+ job.untilChecklistComplete = true;
224
228
  [found, rest] = takeFlag(rest, "--dry-run");
225
229
  if (found)
226
230
  job.dryRun = true;
@@ -369,6 +373,8 @@ function parseLoopArgs(raw, defaults = {}) {
369
373
  job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS;
370
374
  }
371
375
  job.lastRunAt = job.immediate ? 0 : now();
376
+ if (job.untilChecklistComplete && !job.progressFile)
377
+ return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" };
372
378
  if (!job.action && !job.promptFile && !job.goalFile)
373
379
  return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" };
374
380
  return { ok: true, job };
@@ -1445,6 +1451,8 @@ function decoratePrompt(job) {
1445
1451
  additions.push(continuation);
1446
1452
  if (job.progressFile)
1447
1453
  additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
1454
+ if (job.untilChecklistComplete)
1455
+ additions.push("When all actual Markdown tasks in the configured progress file are checked, stop creating new work and report results. Loop will make the stopping decision itself from the project file at a safe host boundary; do not attempt to invoke /loop-clear as assistant text.");
1448
1456
  if (job.lastVerifyFailure)
1449
1457
  additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
1450
1458
  if (job.askNever)
@@ -3625,6 +3633,125 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3625
3633
  return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
3626
3634
  }
3627
3635
 
3636
+ // src/source/runtime/checklist-completion.js
3637
+ import { promises as fs8, constants } from "fs";
3638
+ import path9 from "path";
3639
+ import { createHash } from "crypto";
3640
+ var MAX_CHECKLIST_BYTES = 256000;
3641
+ var EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"]);
3642
+ function unavailable(reason) {
3643
+ return { status: "unavailable", reason, total: 0, pending: 0 };
3644
+ }
3645
+ function summarizeTaskChecklist(markdown) {
3646
+ let total = 0;
3647
+ let pending = 0;
3648
+ let fence = null;
3649
+ let comment = false;
3650
+ for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
3651
+ let line = "";
3652
+ let offset = 0;
3653
+ while (offset < original.length) {
3654
+ if (comment) {
3655
+ const end = original.indexOf("-->", offset);
3656
+ if (end < 0)
3657
+ break;
3658
+ comment = false;
3659
+ offset = end + 3;
3660
+ } else {
3661
+ const start = original.indexOf("<!--", offset);
3662
+ if (start < 0) {
3663
+ line += original.slice(offset);
3664
+ break;
3665
+ }
3666
+ line += original.slice(offset, start);
3667
+ comment = true;
3668
+ offset = start + 4;
3669
+ }
3670
+ }
3671
+ const trimmed = line.trimStart();
3672
+ const indentation = line.length - trimmed.length;
3673
+ const marker = trimmed.charAt(0);
3674
+ let markerSize = 0;
3675
+ if (marker === "~" || marker.charCodeAt(0) === 96) {
3676
+ while (trimmed.charAt(markerSize) === marker)
3677
+ markerSize++;
3678
+ }
3679
+ if (fence) {
3680
+ if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim())
3681
+ fence = null;
3682
+ continue;
3683
+ }
3684
+ if (indentation <= 3 && markerSize >= 3) {
3685
+ fence = { marker, length: markerSize };
3686
+ continue;
3687
+ }
3688
+ if (/^\s*>/.test(line))
3689
+ continue;
3690
+ const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/);
3691
+ if (!match)
3692
+ continue;
3693
+ total++;
3694
+ if (match[1] === " ")
3695
+ pending++;
3696
+ }
3697
+ return {
3698
+ status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
3699
+ reason: total === 0 ? "no-task-checkboxes" : "",
3700
+ total,
3701
+ pending
3702
+ };
3703
+ }
3704
+ async function inspectTaskChecklist(directory, progressFile) {
3705
+ const name = String(progressFile || "").trim();
3706
+ if (!name || name.includes("\x00"))
3707
+ return unavailable("missing-progress-file");
3708
+ if (path9.isAbsolute(name) || path9.win32.isAbsolute(name))
3709
+ return unavailable("absolute-path");
3710
+ const root = path9.resolve(directory);
3711
+ const target = path9.resolve(root, name);
3712
+ const relative = path9.relative(root, target);
3713
+ if (!relative || relative === ".." || relative.startsWith(".." + path9.sep) || path9.isAbsolute(relative)) {
3714
+ return unavailable("outside-project");
3715
+ }
3716
+ const parts = relative.split(path9.sep);
3717
+ if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase())))
3718
+ return unavailable("control-plane-path");
3719
+ try {
3720
+ const rootInfo = await fs8.lstat(root);
3721
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
3722
+ return unavailable("unsafe-project-root");
3723
+ let current = root;
3724
+ for (let index = 0;index < parts.length; index++) {
3725
+ current = path9.join(current, parts[index]);
3726
+ const info = await fs8.lstat(current);
3727
+ if (info.isSymbolicLink())
3728
+ return unavailable("symlink-path");
3729
+ if (index < parts.length - 1 && !info.isDirectory())
3730
+ return unavailable("invalid-parent");
3731
+ if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
3732
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
3733
+ }
3734
+ }
3735
+ const handle = await fs8.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
3736
+ try {
3737
+ const info = await handle.stat();
3738
+ if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
3739
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
3740
+ }
3741
+ const content = await handle.readFile({ encoding: "utf8" });
3742
+ const after = await fs8.lstat(target);
3743
+ if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
3744
+ return unavailable("file-changed-during-read");
3745
+ }
3746
+ return { ...summarizeTaskChecklist(content), digest: createHash("sha256").update(content).digest("hex") };
3747
+ } finally {
3748
+ await handle.close();
3749
+ }
3750
+ } catch (error) {
3751
+ return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable");
3752
+ }
3753
+ }
3754
+
3628
3755
  // src/source/runtime/run-finalization.js
3629
3756
  function requireFunction8(value, label) {
3630
3757
  if (typeof value !== "function")
@@ -3659,8 +3786,16 @@ function createRunFinalizationRuntime(options = {}) {
3659
3786
  if (verify.code === 0) {
3660
3787
  job.failureCount = 0;
3661
3788
  job.lastVerifyFailure = "";
3789
+ if (job.untilChecklistComplete) {
3790
+ const inspected = await inspectTaskChecklist(directory, job.progressFile);
3791
+ if (inspected.status !== "unavailable")
3792
+ job.lastVerifyChecklistDigest = inspected.digest;
3793
+ else
3794
+ delete job.lastVerifyChecklistDigest;
3795
+ }
3662
3796
  await toast2(client, "Loop verify passed: " + job.verifyCommand, "success");
3663
3797
  } else {
3798
+ delete job.lastVerifyChecklistDigest;
3664
3799
  job.failureCount = (job.failureCount || 0) + 1;
3665
3800
  job.lastVerifyFailure = (job.verifyCommand + `
3666
3801
  exit=` + verify.code + `
@@ -3748,7 +3883,7 @@ exit=` + postrun.code + `
3748
3883
  }
3749
3884
 
3750
3885
  // src/source/runtime/run-admission.js
3751
- import path9 from "path";
3886
+ import path10 from "path";
3752
3887
  function requireFunction9(value, label) {
3753
3888
  if (typeof value !== "function")
3754
3889
  throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
@@ -3786,12 +3921,30 @@ function createRunAdmissionRuntime(options = {}) {
3786
3921
  if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
3787
3922
  return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
3788
3923
  }
3789
- if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) {
3924
+ if (job.stopFile && await pathExists2(path10.resolve(directory, job.stopFile))) {
3790
3925
  return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
3791
3926
  }
3792
3927
  if (await untilReached(directory, job)) {
3793
3928
  return await stopAndRemove(directory, client, sessionID, state, job, "until_reached", `Loop stopped by --until: ${job.until}`);
3794
3929
  }
3930
+ if (job.untilChecklistComplete) {
3931
+ const checklist = await inspectTaskChecklist(directory, job.progressFile);
3932
+ job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending };
3933
+ if (checklist.status === "unavailable") {
3934
+ job.paused = true;
3935
+ job.pauseReason = "checklist-unavailable";
3936
+ job.lastChecklistError = checklist.reason;
3937
+ await writeState2(directory, sessionID, state);
3938
+ await notifyJob2(directory, job, "checklist_unavailable");
3939
+ await toast2(client, "Loop paused: checklist " + job.progressFile + " could not be validated (" + checklist.reason + ").", "warning");
3940
+ await reschedule(directory, client, sessionID);
3941
+ return { admitted: false, reason: "checklist_unavailable" };
3942
+ }
3943
+ delete job.lastChecklistError;
3944
+ if (checklist.status === "complete" && (!job.verifyCommand || job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest)) {
3945
+ return await stopAndRemove(directory, client, sessionID, state, job, "checklist_complete", "Loop stopped: " + job.progressFile + " checklist completed.", "checklist-complete");
3946
+ }
3947
+ }
3795
3948
  if (job.preflightCommand) {
3796
3949
  if (job.safe && dangerousShell2(job.preflightCommand)) {
3797
3950
  if (runNowRequested)