@bybrawe/opencode-loop 0.6.7 → 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.
@@ -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")