@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.
@@ -135,6 +135,7 @@ export function parseLoopArgs(raw, defaults = {}) {
135
135
  until: defaults.until,
136
136
  stopFile: defaults.stopFile,
137
137
  progressFile: defaults.progressFile,
138
+ untilChecklistComplete: defaults.untilChecklistComplete ?? false,
138
139
  promptFile: defaults.promptFile,
139
140
  includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
140
141
  watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
@@ -197,6 +198,7 @@ export function parseLoopArgs(raw, defaults = {}) {
197
198
  ;[found, rest] = takeFlag(rest, "--git-checkpoint"); if (found) job.gitCheckpoint = true
198
199
  ;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
199
200
  ;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
201
+ ;[found, rest] = takeFlag(rest, "--until-checklist-complete"); if (found) job.untilChecklistComplete = true
200
202
  ;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
201
203
  ;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
202
204
  ;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
@@ -276,6 +278,7 @@ export function parseLoopArgs(raw, defaults = {}) {
276
278
  }
277
279
  job.lastRunAt = job.immediate ? 0 : now()
278
280
 
281
+ if (job.untilChecklistComplete && !job.progressFile) return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" }
279
282
  if (!job.action && !job.promptFile && !job.goalFile) 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" }
280
283
  return { ok: true, job }
281
284
  }
@@ -58,6 +58,7 @@ export function decoratePrompt(job) {
58
58
  const continuation = continuationProjectInstruction(job.action)
59
59
  if (continuation) additions.push(continuation)
60
60
  if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
61
+ if (job.untilChecklistComplete) 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.")
61
62
  if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
62
63
  if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
63
64
  if (job.safe) additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.")
@@ -13,6 +13,7 @@ const HELP = `OpenCode Loop: native OpenCode 2 runtime
13
13
  /loop 5m <task> --verify "npm test" --pause-on-verify-fail
14
14
  /loop --watch progress.md <task>
15
15
  /loop 0s <task> --prompt-file instructions.md --include-file progress.md
16
+ /loop 0s <task> --progress-file TASK.md --until-checklist-complete --verify "npm test"
16
17
  /loop 0s <task> --compact-every 3
17
18
  /loop 5m --shell npm test
18
19
  /loop 15m --compact /compact
@@ -20,6 +21,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
20
21
  /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
21
22
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
22
23
  Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
24
+ --progress-file alone is context, not a stop condition. --until-checklist-complete is an explicit host-verified stop condition; missing/unsafe/no-task files pause for review, and --verify must have passed before stopping.
25
+ Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it.
23
26
  Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
24
27
  Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
25
28
  An unfinished dedicated Goal reserves its session; Loop will not override it.
@@ -2,6 +2,7 @@ import { stat } from "node:fs/promises"
2
2
  import path from "node:path"
3
3
  import { runShellCommand, appendLoopLog } from "../core/process.js"
4
4
  import { createJobWorkspaceRuntime, dangerousShell } from "../runtime/job-workspace.js"
5
+ import { inspectTaskChecklist } from "../runtime/checklist-completion.js"
5
6
 
6
7
  export function createNativeJobPolicy(options = {}) {
7
8
  const now = options.now || Date.now
@@ -44,6 +45,20 @@ export function createNativeJobPolicy(options = {}) {
44
45
  job.enabled = false
45
46
  return pause(job, "until-reached")
46
47
  }
48
+ if (job.untilChecklistComplete) {
49
+ const checklist = await inspectTaskChecklist(scope.directory, job.progressFile)
50
+ job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending }
51
+ if (checklist.status === "unavailable") {
52
+ job.lastChecklistError = checklist.reason
53
+ return pause(job, "checklist-unavailable")
54
+ }
55
+ delete job.lastChecklistError
56
+ if (checklist.status === "complete" && (!job.verifyCommand || (job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest))) {
57
+ job.enabled = false
58
+ job.checklistCompletedAt = now()
59
+ return pause(job, "checklist-complete")
60
+ }
61
+ }
47
62
  return true
48
63
  }
49
64
 
@@ -71,11 +86,17 @@ export function createNativeJobPolicy(options = {}) {
71
86
  job.lastVerifyAt = now()
72
87
  job.lastVerifyOutput = `${result.stdout || ""}\n${result.stderr || ""}`.trim().slice(-8000)
73
88
  if (result.code !== 0) {
89
+ delete job.lastVerifyChecklistDigest
74
90
  job.failureCount = (job.failureCount || 0) + 1
75
91
  if (result.blocked || job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) pause(job, "verification-failed")
76
92
  return
77
93
  }
78
94
  job.failureCount = 0
95
+ if (job.untilChecklistComplete) {
96
+ const verified = await inspectTaskChecklist(scope.directory, job.progressFile)
97
+ if (verified.status !== "unavailable") job.lastVerifyChecklistDigest = verified.digest
98
+ else delete job.lastVerifyChecklistDigest
99
+ }
79
100
  }
80
101
  if (!isCurrent()) return
81
102
  if (job.postrunCommand) {
@@ -81,6 +81,7 @@ export function createNativeLoopRuntime(options = {}) {
81
81
  for (const job of state.jobs || []) {
82
82
  if (!eligible(job) || job.v2Run || blocked(job).length) continue
83
83
  if (job.watchPaths?.length || job.stopFile || job.until) delay = Math.min(delay, 1000)
84
+ if (job.untilChecklistComplete) delay = Math.min(delay, 5000)
84
85
  const due = dueAt(job)
85
86
  if (due > now()) delay = Math.min(delay, due - now())
86
87
  // A zero-delay deferred preset still needs a first idle-safe wake after
@@ -28,6 +28,10 @@ export function formatOpenCode2LoopStatus(state, current = Date.now()) {
28
28
  job.noOverlap ? "no-overlap" : undefined,
29
29
  job.checkpointOnly ? "checkpoint-only" : undefined,
30
30
  job.gitCheckpoint ? "git-checkpoint" : undefined,
31
+ job.untilChecklistComplete ? "checklist-stop:" + (job.progressFile || "missing-file") : undefined,
32
+ job.pauseReason ? "reason:" + job.pauseReason : undefined,
33
+ job.lastChecklistError ? "checklist-error:" + job.lastChecklistError : undefined,
34
+ !job.maxRuns && !job.maxRuntimeMs && !job.until && !job.stopFile && !job.untilChecklistComplete ? "unlimited" : undefined,
31
35
  ].filter(Boolean).join(",")
32
36
  return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
33
37
  })
@@ -35,6 +39,6 @@ export function formatOpenCode2LoopStatus(state, current = Date.now()) {
35
39
 
36
40
  return Object.freeze({
37
41
  jobs,
38
- text: `OpenCode loop status:\n${lines.join("\n")}`,
42
+ text: `OpenCode loop status:\n${lines.join("\n")}${jobs.length ? "\nHost controls: run /loop-stop <name-or-id> or /loop-clear to stop jobs; asking the model to print those commands does not execute them." : ""}`,
39
43
  })
40
44
  }
@@ -0,0 +1,113 @@
1
+ import { promises as fs, constants } from "node:fs"
2
+ import path from "node:path"
3
+ import { createHash } from "node:crypto"
4
+
5
+ const MAX_CHECKLIST_BYTES = 256_000
6
+ const EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"])
7
+
8
+ function unavailable(reason) {
9
+ return { status: "unavailable", reason, total: 0, pending: 0 }
10
+ }
11
+
12
+ export function summarizeTaskChecklist(markdown) {
13
+ let total = 0
14
+ let pending = 0
15
+ let fence = null
16
+ let comment = false
17
+
18
+ for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
19
+ // Only project tasks count, never examples in HTML comments or fenced code.
20
+ let line = ""
21
+ let offset = 0
22
+ while (offset < original.length) {
23
+ if (comment) {
24
+ const end = original.indexOf("-->", offset)
25
+ if (end < 0) break
26
+ comment = false
27
+ offset = end + 3
28
+ } else {
29
+ const start = original.indexOf("<!--", offset)
30
+ if (start < 0) {
31
+ line += original.slice(offset)
32
+ break
33
+ }
34
+ line += original.slice(offset, start)
35
+ comment = true
36
+ offset = start + 4
37
+ }
38
+ }
39
+
40
+ const trimmed = line.trimStart()
41
+ const indentation = line.length - trimmed.length
42
+ const marker = trimmed.charAt(0)
43
+ let markerSize = 0
44
+ if (marker === "~" || marker.charCodeAt(0) === 96) {
45
+ while (trimmed.charAt(markerSize) === marker) markerSize++
46
+ }
47
+ if (fence) {
48
+ if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim()) fence = null
49
+ continue
50
+ }
51
+ if (indentation <= 3 && markerSize >= 3) {
52
+ fence = { marker, length: markerSize }
53
+ continue
54
+ }
55
+ // A quoted example must not be mistaken for an actionable checkbox.
56
+ if (/^\s*>/.test(line)) continue
57
+ const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/)
58
+ if (!match) continue
59
+ total++
60
+ if (match[1] === " ") pending++
61
+ }
62
+
63
+ return { status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
64
+ reason: total === 0 ? "no-task-checkboxes" : "",
65
+ total, pending }
66
+ }
67
+
68
+ export async function inspectTaskChecklist(directory, progressFile) {
69
+ const name = String(progressFile || "").trim()
70
+ if (!name || name.includes("\u0000")) return unavailable("missing-progress-file")
71
+ if (path.isAbsolute(name) || path.win32.isAbsolute(name)) return unavailable("absolute-path")
72
+ const root = path.resolve(directory)
73
+ const target = path.resolve(root, name)
74
+ const relative = path.relative(root, target)
75
+ if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) {
76
+ return unavailable("outside-project")
77
+ }
78
+ const parts = relative.split(path.sep)
79
+ if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase()))) return unavailable("control-plane-path")
80
+
81
+ try {
82
+ const rootInfo = await fs.lstat(root)
83
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) return unavailable("unsafe-project-root")
84
+ let current = root
85
+ for (let index = 0; index < parts.length; index++) {
86
+ current = path.join(current, parts[index])
87
+ const info = await fs.lstat(current)
88
+ if (info.isSymbolicLink()) return unavailable("symlink-path")
89
+ if (index < parts.length - 1 && !info.isDirectory()) return unavailable("invalid-parent")
90
+ if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
91
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large")
92
+ }
93
+ }
94
+ // Do not follow a last-component symlink introduced between lstat and open.
95
+ const handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
96
+ try {
97
+ const info = await handle.stat()
98
+ if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
99
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large")
100
+ }
101
+ const content = await handle.readFile({ encoding: "utf8" })
102
+ const after = await fs.lstat(target)
103
+ if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
104
+ return unavailable("file-changed-during-read")
105
+ }
106
+ return { ...summarizeTaskChecklist(content), digest: createHash("sha256").update(content).digest("hex") }
107
+ } finally {
108
+ await handle.close()
109
+ }
110
+ } catch (error) {
111
+ return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable")
112
+ }
113
+ }
@@ -4,6 +4,7 @@ import { pathExists as defaultPathExists, writeState as defaultWriteState } from
4
4
  import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
5
5
  import { toast as defaultToast } from "../opencode/host.js"
6
6
  import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
7
+ import { inspectTaskChecklist } from "./checklist-completion.js"
7
8
  import { dueJobs as sharedDueJobs } from "./schedule-policy.js"
8
9
 
9
10
  function requireFunction(value, label) {
@@ -82,6 +83,30 @@ export function createRunAdmissionRuntime(options = {}) {
82
83
  )
83
84
  }
84
85
 
86
+ if (job.untilChecklistComplete) {
87
+ const checklist = await inspectTaskChecklist(directory, job.progressFile)
88
+ job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending }
89
+ if (checklist.status === "unavailable") {
90
+ job.paused = true
91
+ job.pauseReason = "checklist-unavailable"
92
+ job.lastChecklistError = checklist.reason
93
+ await writeState(directory, sessionID, state)
94
+ await notifyJob(directory, job, "checklist_unavailable")
95
+ await toast(client, "Loop paused: checklist " + job.progressFile + " could not be validated (" + checklist.reason + ").", "warning")
96
+ await reschedule(directory, client, sessionID)
97
+ return { admitted: false, reason: "checklist_unavailable" }
98
+ }
99
+ delete job.lastChecklistError
100
+ if (checklist.status === "complete" && (!job.verifyCommand || (job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest))) {
101
+ return await stopAndRemove(
102
+ directory, client, sessionID, state, job,
103
+ "checklist_complete",
104
+ "Loop stopped: " + job.progressFile + " checklist completed.",
105
+ "checklist-complete",
106
+ )
107
+ }
108
+ }
109
+
85
110
  if (job.preflightCommand) {
86
111
  if (job.safe && dangerousShell(job.preflightCommand)) {
87
112
  if (runNowRequested) delete job.runNowRequestedAt
@@ -6,6 +6,7 @@ import { toast as defaultToast } from "../opencode/host.js"
6
6
  import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
7
7
  import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
8
8
  import { applyTerminalContinuationGuard as defaultApplyTerminalContinuationGuard } from "./terminal-guard.js"
9
+ import { inspectTaskChecklist } from "./checklist-completion.js"
9
10
 
10
11
  function requireFunction(value, label) {
11
12
  if (typeof value !== "function") throw new TypeError(`createRunFinalizationRuntime requires ${label}`)
@@ -49,8 +50,14 @@ export function createRunFinalizationRuntime(options = {}) {
49
50
  if (verify.code === 0) {
50
51
  job.failureCount = 0
51
52
  job.lastVerifyFailure = ""
53
+ if (job.untilChecklistComplete) {
54
+ const inspected = await inspectTaskChecklist(directory, job.progressFile)
55
+ if (inspected.status !== "unavailable") job.lastVerifyChecklistDigest = inspected.digest
56
+ else delete job.lastVerifyChecklistDigest
57
+ }
52
58
  await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
53
59
  } else {
60
+ delete job.lastVerifyChecklistDigest
54
61
  job.failureCount = (job.failureCount || 0) + 1
55
62
  job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
56
63
  await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")