@bybrawe/opencode-loop 0.5.31 → 0.5.33

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,105 +1,13 @@
1
- import { promises as fs } from "node:fs"
2
- import path from "node:path"
3
- import { DEFAULT_GOAL_MAX_NO_PROGRESS, now, safeID } from "../core/args.js"
4
- import { matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
5
- import { stateDir, ensureDir, readState, writeState } from "../core/state.js"
6
- import { appendLoopLog, readSmallTextFile } from "../core/process.js"
1
+ import { now } from "../core/args.js"
2
+ import { matchJob, isGoalJob } from "../core/jobs.js"
3
+ import { readState, writeState } from "../core/state.js"
4
+ import { appendLoopLog } from "../core/process.js"
5
+ import { writeGoalReport } from "./goal-report.js"
6
+ import { hasConcreteGoalEvidence, goalChecksPassed, goalRequiresPassingChecks } from "./goal-evidence.js"
7
7
 
8
- const GOAL_REPORT_DIR = "goals"
9
- const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
10
-
11
- export async function buildGoalPrompt(directory, job) {
12
- const sections = []
13
- sections.push(`Working directory:\n${path.resolve(directory)}\nKeep every file operation inside this directory. Prefer workspace-relative paths such as \"src/index.js\"; never turn a relative path into a root path such as \"/src/index.js\".`)
14
- const objective = String(job.action || "").trim()
15
- if (objective) sections.push(`Goal objective:\n${objective}`)
16
- if (job.goalFile) {
17
- const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
18
- if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
19
- else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
20
- }
21
- if (job.promptFile) {
22
- const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
23
- if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
24
- }
25
- if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
26
- if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
27
- if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
28
- if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
29
- if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
30
- if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
31
- if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) sections.push(`No-progress guard:\n${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`)
32
- if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
33
- for (const file of job.includeFiles || []) {
34
- const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
35
- if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
36
- }
37
-
38
- return `${GOAL_PROMPT_PREFIX}.
39
-
40
- You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
41
-
42
- Rules:
43
- - Work on the next smallest useful step toward the goal.
44
- - Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
45
- - Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
46
- - If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
47
- - Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
48
- - When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
49
- - If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
50
- - If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
51
- - If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
52
- - Do not call completion tools just to be polite; only call them when the state is real.
53
- - Do not ask the user questions unless blocked; make reasonable assumptions and continue.
54
- - Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
55
-
56
- ${sections.join("\n\n---\n\n")}`
57
- }
58
-
59
- export function goalReportPath(directory, sessionID, job) {
60
- return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
61
- }
62
-
63
- export function goalReportText(job) {
64
- const lines = []
65
- lines.push(`# OpenCode Loop Goal Report`)
66
- lines.push("")
67
- lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
68
- lines.push(`Goal: ${job.action || job.goalFile || ""}`)
69
- lines.push(`Created: ${job.createdAt || ""}`)
70
- if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
71
- if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
72
- if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
73
- if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
74
- if (job.runCount) lines.push(`Turns: ${job.runCount}`)
75
- if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
76
- lines.push("")
77
- if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
78
- if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
79
- if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
80
- if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
81
- if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
82
- if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
83
- if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
84
- if (job.lastGoalChecks?.length) {
85
- lines.push("## Latest checks", "")
86
- for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
87
- lines.push("")
88
- }
89
- if (job.goalProgress?.length) {
90
- lines.push("## Progress", "")
91
- for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
92
- lines.push("")
93
- }
94
- return lines.join("\n")
95
- }
96
-
97
- export async function writeGoalReport(directory, sessionID, job) {
98
- if (!isGoalJob(job)) return
99
- const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
100
- await ensureDir(path.dirname(target))
101
- await fs.writeFile(target, goalReportText(job), "utf8")
102
- }
8
+ export { buildGoalPrompt } from "./goal-prompt.js"
9
+ export { goalReportPath, goalReportText, writeGoalReport } from "./goal-report.js"
10
+ export { hasConcreteGoalEvidence, goalChecksPassed, goalRequiresPassingChecks, goalProgressSnapshot, goalMadeMeaningfulProgress } from "./goal-evidence.js"
103
11
 
104
12
  export function pickGoalJob(state, target = "") {
105
13
  const goals = (state.jobs || []).filter(isGoalJob)
@@ -115,46 +23,6 @@ export function parseGoalToolText(args, fields) {
115
23
  return result
116
24
  }
117
25
 
118
- export function hasConcreteGoalEvidence(value) {
119
- const text = String(value || "").trim()
120
- if (text.length < 24) return false
121
- const normalized = text.toLowerCase().replace(/\s+/g, " ")
122
- const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
123
- if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
124
- return /(\b(npm|pnpm|yarn|bun|node|pytest|cargo|dotnet|go test|tsc|typecheck|test|tests|lint|build|check|checks|exit\s*\d|passed|verified|changed|updated|created|fixed|file|files|diff|commit)\b|[`\\/][\w./:-]+)/i.test(text) || text.length >= 80
125
- }
126
-
127
- export function goalChecksPassed(job) {
128
- return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
129
- }
130
-
131
- export function goalRequiresPassingChecks(job) {
132
- return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
133
- }
134
-
135
- export function goalProgressSnapshot(job) {
136
- return {
137
- status: job?.goalStatus || "",
138
- progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
139
- evidence: String(job?.goalEvidence || ""),
140
- checksPassedAt: Number(job?.goalChecksPassedAt || 0),
141
- lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
142
- lastVerifyAt: Number(job?.lastVerifyAt || 0),
143
- lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
144
- }
145
- }
146
-
147
- export function goalMadeMeaningfulProgress(beforeJob, afterJob) {
148
- const before = goalProgressSnapshot(beforeJob || {})
149
- const after = goalProgressSnapshot(afterJob || {})
150
- if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
151
- if (after.progressCount > before.progressCount) return true
152
- if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
153
- if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
154
- if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
155
- return false
156
- }
157
-
158
26
  export async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
159
27
  job.goalCompletionRejectedAt = now()
160
28
  job.goalCompletionRejectedReason = reason
@@ -0,0 +1,132 @@
1
+ import path from "node:path"
2
+ import { now as defaultNow } from "../core/args.js"
3
+ import { isGoalJob } from "../core/jobs.js"
4
+ import { pathExists as defaultPathExists, writeState as defaultWriteState } from "../core/state.js"
5
+ import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
6
+ import { toast as defaultToast } from "../opencode/host.js"
7
+ import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
8
+
9
+ function requireFunction(value, label) {
10
+ if (typeof value !== "function") throw new TypeError(`createRunAdmissionRuntime requires ${label}`)
11
+ return value
12
+ }
13
+
14
+ export function createRunAdmissionRuntime(options = {}) {
15
+ const untilReached = requireFunction(options.untilReached, "untilReached")
16
+ const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
17
+
18
+ const now = typeof options.now === "function" ? options.now : defaultNow
19
+ const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists
20
+ const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
21
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
22
+ const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand
23
+ const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob
24
+ const toast = typeof options.toast === "function" ? options.toast : defaultToast
25
+ const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
26
+
27
+ function dueJobs(state, force = false) {
28
+ const current = now()
29
+ const due = (state.jobs || []).filter((job) => {
30
+ if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
31
+ if (!job.enabled || job.paused) return false
32
+ if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
33
+ if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
34
+ if (Number(job.runNowRequestedAt || 0) > 0) return true
35
+ if (force) return true
36
+ if (job.watchPaths?.length) return job.watchTriggered === true
37
+ return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
38
+ })
39
+ return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0))
40
+ }
41
+
42
+ async function reschedule(directory, client, sessionID) {
43
+ await scheduleDueWork(directory, client, sessionID)
44
+ }
45
+
46
+ async function stopAndRemove(directory, client, sessionID, state, job, reason, message, logEvent) {
47
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
48
+ await writeState(directory, sessionID, state)
49
+ await notifyJob(directory, job, reason)
50
+ await toast(client, message, "success")
51
+ if (logEvent) await appendLoopLog(directory, logEvent, { sessionID, job: job.name || job.id })
52
+ await reschedule(directory, client, sessionID)
53
+ return { admitted: false, reason }
54
+ }
55
+
56
+ async function admitJob(directory, client, sessionID, state, job) {
57
+ const runNowRequested = Number(job.runNowRequestedAt || 0) > 0
58
+
59
+ if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
60
+ return await stopAndRemove(
61
+ directory,
62
+ client,
63
+ sessionID,
64
+ state,
65
+ job,
66
+ "max_runtime_reached",
67
+ `Loop stopped by --max-runtime: ${job.name || job.id}`,
68
+ "max-runtime",
69
+ )
70
+ }
71
+
72
+ if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
73
+ return await stopAndRemove(
74
+ directory,
75
+ client,
76
+ sessionID,
77
+ state,
78
+ job,
79
+ "stop_file",
80
+ "Loop stopped by --stop-file: " + job.stopFile,
81
+ )
82
+ }
83
+
84
+ if (await untilReached(directory, job)) {
85
+ return await stopAndRemove(
86
+ directory,
87
+ client,
88
+ sessionID,
89
+ state,
90
+ job,
91
+ "until_reached",
92
+ `Loop stopped by --until: ${job.until}`,
93
+ )
94
+ }
95
+
96
+ if (job.preflightCommand) {
97
+ if (job.safe && dangerousShell(job.preflightCommand)) {
98
+ if (runNowRequested) delete job.runNowRequestedAt
99
+ job.paused = true
100
+ await writeState(directory, sessionID, state)
101
+ await notifyJob(directory, job, "preflight_blocked")
102
+ await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
103
+ await reschedule(directory, client, sessionID)
104
+ return { admitted: false, reason: "preflight_blocked" }
105
+ }
106
+
107
+ const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
108
+ await appendLoopLog(directory, "preflight", {
109
+ sessionID,
110
+ job: job.name || job.id,
111
+ command: job.preflightCommand,
112
+ code: preflight.code,
113
+ })
114
+ if (preflight.code !== 0) {
115
+ if (runNowRequested) delete job.runNowRequestedAt
116
+ job.paused = true
117
+ job.failureCount = (job.failureCount || 0) + 1
118
+ job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
119
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
120
+ await writeState(directory, sessionID, state)
121
+ await notifyJob(directory, job, "preflight_failed")
122
+ await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
123
+ await reschedule(directory, client, sessionID)
124
+ return { admitted: false, reason: "preflight_failed" }
125
+ }
126
+ }
127
+
128
+ return { admitted: true, job, runNowRequested }
129
+ }
130
+
131
+ return { dueJobs, admitJob }
132
+ }
@@ -0,0 +1,100 @@
1
+ import { now as defaultNow } from "../core/args.js"
2
+ import { isGoalJob } from "../core/jobs.js"
3
+ import { writeState as defaultWriteState } from "../core/state.js"
4
+ import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
5
+ import { toast as defaultToast } from "../opencode/host.js"
6
+ import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
7
+ import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
8
+
9
+ function requireFunction(value, label) {
10
+ if (typeof value !== "function") throw new TypeError(`createRunFinalizationRuntime requires ${label}`)
11
+ return value
12
+ }
13
+
14
+ export function createRunFinalizationRuntime(options = {}) {
15
+ const runGoalChecks = requireFunction(options.runGoalChecks, "runGoalChecks")
16
+ const applyGoalNoProgressGuard = requireFunction(options.applyGoalNoProgressGuard, "applyGoalNoProgressGuard")
17
+ const createCheckpoint = requireFunction(options.createCheckpoint, "createCheckpoint")
18
+ const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
19
+
20
+ const now = typeof options.now === "function" ? options.now : defaultNow
21
+ const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
22
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
23
+ const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand
24
+ const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob
25
+ const toast = typeof options.toast === "function" ? options.toast : defaultToast
26
+ const writeGoalReport = typeof options.writeGoalReport === "function" ? options.writeGoalReport : defaultWriteGoalReport
27
+ const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
28
+
29
+ async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
30
+ if (job.verifyCommand) {
31
+ const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
32
+ job.lastVerifyAt = now()
33
+ job.lastVerifyCode = verify.code
34
+ if (verify.code === 0) {
35
+ job.failureCount = 0
36
+ job.lastVerifyFailure = ""
37
+ await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
38
+ } else {
39
+ job.failureCount = (job.failureCount || 0) + 1
40
+ job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
41
+ await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
42
+ if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
43
+ job.paused = true
44
+ await notifyJob(directory, job, "verify_failed")
45
+ }
46
+ }
47
+ await appendLoopLog(directory, "verify", {
48
+ sessionID,
49
+ job: job.name || job.id,
50
+ command: job.verifyCommand,
51
+ code: verify.code,
52
+ failures: job.failureCount || 0,
53
+ })
54
+ }
55
+
56
+ if (job.postrunCommand) {
57
+ if (job.safe && dangerousShell(job.postrunCommand)) {
58
+ await appendLoopLog(directory, "postrun-blocked", {
59
+ sessionID,
60
+ job: job.name || job.id,
61
+ command: job.postrunCommand,
62
+ })
63
+ } else {
64
+ const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
65
+ job.lastPostrunCode = postrun.code
66
+ job.lastPostrunAt = now()
67
+ if (postrun.code !== 0) {
68
+ job.failureCount = (job.failureCount || 0) + 1
69
+ job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
70
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
71
+ job.paused = true
72
+ await notifyJob(directory, job, "postrun_failed")
73
+ }
74
+ }
75
+ await appendLoopLog(directory, "postrun", {
76
+ sessionID,
77
+ job: job.name || job.id,
78
+ command: job.postrunCommand,
79
+ code: postrun.code,
80
+ })
81
+ }
82
+ }
83
+
84
+ if (isGoalJob(job)) {
85
+ job = await runGoalChecks(directory, sessionID, job, client)
86
+ job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob)
87
+ }
88
+
89
+ state.jobs = (state.jobs || [])
90
+ .map((candidate) => candidate.id === job.id ? job : candidate)
91
+ .filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
92
+ await writeState(directory, sessionID, state)
93
+ if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
94
+ await createCheckpoint(directory, sessionID, job, client)
95
+ await scheduleDueWork(directory, client, sessionID)
96
+ return job
97
+ }
98
+
99
+ return { finalizeJob }
100
+ }