@bybrawe/opencode-loop 0.5.32 → 0.5.34
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.
- package/CHANGELOG.md +17 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/action-dispatch-test.mjs +117 -0
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/run-admission-test.mjs +133 -0
- package/scripts/run-finalization-test.mjs +119 -0
- package/scripts/schedule-policy-test.mjs +114 -0
- package/scripts/schedule-syntax-test.mjs +75 -0
- package/scripts/session-status-idle-recovery-test.mjs +44 -0
- package/src/index.js +916 -473
- package/src/source/core/continuation.js +20 -0
- package/src/source/core/jobs.js +3 -0
- package/src/source/core/schedule-syntax.js +93 -0
- package/src/source/opencode/loop-commands.js +39 -30
- package/src/source/opencode/loop-registration.js +40 -3
- package/src/source/runtime/action-dispatch.js +108 -0
- package/src/source/runtime/companion-goal.js +37 -0
- package/src/source/runtime/executor.js +55 -228
- package/src/source/runtime/goal-evidence.js +39 -0
- package/src/source/runtime/goal-policy.js +1 -1
- package/src/source/runtime/goal-prompt.js +53 -0
- package/src/source/runtime/goal-report.js +52 -0
- package/src/source/runtime/goal-runtime.js +9 -141
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/run-admission.js +121 -0
- package/src/source/runtime/run-finalization.js +100 -0
- package/src/source/runtime/schedule-policy.js +80 -0
- package/src/source/runtime/scheduler-diagnostics.js +30 -0
- package/src/source/runtime/scheduler.js +10 -23
- package/src/source/runtime/session-status.js +28 -4
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
1
|
+
import { now as defaultNow } from "../core/args.js"
|
|
2
|
+
import { isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
5
4
|
import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
|
|
6
|
-
import { sdkErrorMessage as defaultErrorMessage
|
|
7
|
-
import {
|
|
8
|
-
import { fireSdk as defaultFireSdk, compactTuiCommandName as defaultCompactTuiCommandName, log as defaultLog, toast as defaultToast } from "../opencode/host.js"
|
|
9
|
-
import { guardLoopOwnedUserMessage as defaultGuardLoopOwnedUserMessage } from "../opencode/messages.js"
|
|
10
|
-
import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
|
|
5
|
+
import { sdkErrorMessage as defaultErrorMessage } from "../opencode/sdk.js"
|
|
6
|
+
import { fireSdk as defaultFireSdk, log as defaultLog, toast as defaultToast } from "../opencode/host.js"
|
|
11
7
|
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
12
8
|
import { createSessionStatusRuntime } from "./session-status.js"
|
|
13
9
|
import { createCompactionRuntime } from "./compaction.js"
|
|
10
|
+
import { createActionDispatcher } from "./action-dispatch.js"
|
|
11
|
+
import { createRunFinalizationRuntime } from "./run-finalization.js"
|
|
12
|
+
import { createRunAdmissionRuntime } from "./run-admission.js"
|
|
14
13
|
|
|
15
14
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
16
15
|
const DEFAULT_BUSY_RETRY_MS = 5_000
|
|
@@ -38,21 +37,13 @@ export function createLoopExecutor(options = {}) {
|
|
|
38
37
|
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
39
38
|
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
40
39
|
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
41
|
-
const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists
|
|
42
40
|
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
43
41
|
const runShellCommand = typeof options.runShellCommand === "function" ? options.runShellCommand : defaultRunShellCommand
|
|
44
42
|
const notifyJob = typeof options.notifyJob === "function" ? options.notifyJob : defaultNotifyJob
|
|
45
43
|
const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : defaultErrorMessage
|
|
46
|
-
const sdkCall = typeof options.sdkCall === "function" ? options.sdkCall : defaultSdkCall
|
|
47
|
-
const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
|
|
48
44
|
const fireSdk = typeof options.fireSdk === "function" ? options.fireSdk : defaultFireSdk
|
|
49
|
-
const compactTuiCommandName = typeof options.compactTuiCommandName === "function" ? options.compactTuiCommandName : defaultCompactTuiCommandName
|
|
50
45
|
const log = typeof options.log === "function" ? options.log : defaultLog
|
|
51
46
|
const toast = typeof options.toast === "function" ? options.toast : defaultToast
|
|
52
|
-
const guardLoopOwnedUserMessage = typeof options.guardLoopOwnedUserMessage === "function"
|
|
53
|
-
? options.guardLoopOwnedUserMessage
|
|
54
|
-
: defaultGuardLoopOwnedUserMessage
|
|
55
|
-
const writeGoalReport = typeof options.writeGoalReport === "function" ? options.writeGoalReport : defaultWriteGoalReport
|
|
56
47
|
const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
|
|
57
48
|
const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0
|
|
58
49
|
? Number(options.activeGuardMs)
|
|
@@ -91,20 +82,47 @@ export function createLoopExecutor(options = {}) {
|
|
|
91
82
|
now,
|
|
92
83
|
})
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
85
|
+
const actionDispatcher = createActionDispatcher({
|
|
86
|
+
buildPrompt,
|
|
87
|
+
compactionRuntime,
|
|
88
|
+
appendLoopLog,
|
|
89
|
+
sdkCall: options.sdkCall,
|
|
90
|
+
normalizedModelRef: options.normalizedModelRef,
|
|
91
|
+
fireSdk: options.fireSdk,
|
|
92
|
+
compactTuiCommandName: options.compactTuiCommandName,
|
|
93
|
+
toast,
|
|
94
|
+
guardLoopOwnedUserMessage: options.guardLoopOwnedUserMessage,
|
|
95
|
+
dangerousShell,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const finalizationRuntime = createRunFinalizationRuntime({
|
|
99
|
+
runGoalChecks,
|
|
100
|
+
applyGoalNoProgressGuard,
|
|
101
|
+
createCheckpoint,
|
|
102
|
+
scheduleDueWork,
|
|
103
|
+
now,
|
|
104
|
+
writeState,
|
|
105
|
+
appendLoopLog,
|
|
106
|
+
runShellCommand,
|
|
107
|
+
notifyJob,
|
|
108
|
+
toast,
|
|
109
|
+
writeGoalReport: options.writeGoalReport,
|
|
110
|
+
dangerousShell,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const admissionRuntime = createRunAdmissionRuntime({
|
|
114
|
+
untilReached,
|
|
115
|
+
scheduleDueWork,
|
|
116
|
+
now,
|
|
117
|
+
pathExists: options.pathExists,
|
|
118
|
+
writeState,
|
|
119
|
+
appendLoopLog,
|
|
120
|
+
runShellCommand,
|
|
121
|
+
notifyJob,
|
|
122
|
+
toast,
|
|
123
|
+
dangerousShell,
|
|
124
|
+
})
|
|
125
|
+
const dueJobs = admissionRuntime.dueJobs
|
|
108
126
|
|
|
109
127
|
function clearActiveRun(sessionID) {
|
|
110
128
|
const active = activeRuns.get(sessionID)
|
|
@@ -181,149 +199,11 @@ export function createLoopExecutor(options = {}) {
|
|
|
181
199
|
})
|
|
182
200
|
}
|
|
183
201
|
|
|
184
|
-
|
|
185
|
-
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
186
|
-
job.lastVerifyAt = now()
|
|
187
|
-
job.lastVerifyCode = verify.code
|
|
188
|
-
if (verify.code === 0) {
|
|
189
|
-
job.failureCount = 0
|
|
190
|
-
job.lastVerifyFailure = ""
|
|
191
|
-
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
192
|
-
} else {
|
|
193
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
194
|
-
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
195
|
-
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|
|
196
|
-
if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
|
|
197
|
-
job.paused = true
|
|
198
|
-
await notifyJob(directory, job, "verify_failed")
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
await appendLoopLog(directory, "verify", {
|
|
202
|
-
sessionID,
|
|
203
|
-
job: job.name || job.id,
|
|
204
|
-
command: job.verifyCommand,
|
|
205
|
-
code: verify.code,
|
|
206
|
-
failures: job.failureCount || 0,
|
|
207
|
-
})
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (job.postrunCommand) {
|
|
211
|
-
if (job.safe && dangerousShell(job.postrunCommand)) {
|
|
212
|
-
await appendLoopLog(directory, "postrun-blocked", {
|
|
213
|
-
sessionID,
|
|
214
|
-
job: job.name || job.id,
|
|
215
|
-
command: job.postrunCommand,
|
|
216
|
-
})
|
|
217
|
-
} else {
|
|
218
|
-
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
|
|
219
|
-
job.lastPostrunCode = postrun.code
|
|
220
|
-
job.lastPostrunAt = now()
|
|
221
|
-
if (postrun.code !== 0) {
|
|
222
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
223
|
-
job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
|
|
224
|
-
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
225
|
-
job.paused = true
|
|
226
|
-
await notifyJob(directory, job, "postrun_failed")
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
await appendLoopLog(directory, "postrun", {
|
|
230
|
-
sessionID,
|
|
231
|
-
job: job.name || job.id,
|
|
232
|
-
command: job.postrunCommand,
|
|
233
|
-
code: postrun.code,
|
|
234
|
-
})
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (isGoalJob(job)) {
|
|
239
|
-
job = await runGoalChecks(directory, sessionID, job, client)
|
|
240
|
-
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
|
|
241
|
-
}
|
|
242
|
-
state.jobs = (state.jobs || [])
|
|
243
|
-
.map((candidate) => candidate.id === job.id ? job : candidate)
|
|
244
|
-
.filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
245
|
-
await writeState(directory, sessionID, state)
|
|
246
|
-
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
247
|
-
await createCheckpoint(directory, sessionID, job, client)
|
|
248
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
202
|
+
await finalizationRuntime.finalizeJob(directory, client, sessionID, state, job, active.job)
|
|
249
203
|
return true
|
|
250
204
|
}
|
|
251
205
|
|
|
252
|
-
|
|
253
|
-
const action = String(job.action || "").trim()
|
|
254
|
-
const kind = actionKind(action, job)
|
|
255
|
-
const agent = job.agent || "build"
|
|
256
|
-
const model = normalizedModelRef(job.model)
|
|
257
|
-
|
|
258
|
-
if (kind === "compact") {
|
|
259
|
-
const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false)
|
|
260
|
-
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (kind === "command") {
|
|
264
|
-
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
265
|
-
const [command, argumentsText] = splitFirst(normalized)
|
|
266
|
-
if (!command) {
|
|
267
|
-
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
268
|
-
return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
|
|
269
|
-
}
|
|
270
|
-
const tuiCommand = compactTuiCommandName(command)
|
|
271
|
-
if (tuiCommand) {
|
|
272
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
273
|
-
const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false)
|
|
274
|
-
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
275
|
-
}
|
|
276
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
277
|
-
const commandBody = { command, arguments: argumentsText, agent }
|
|
278
|
-
if (model) commandBody.model = `${model.providerID}/${model.modelID}`
|
|
279
|
-
await sdkCall(
|
|
280
|
-
client.session.command.bind(client.session),
|
|
281
|
-
{ path: { id: sessionID }, body: commandBody },
|
|
282
|
-
{ path: { sessionID }, body: commandBody },
|
|
283
|
-
{ sessionID, ...commandBody },
|
|
284
|
-
)
|
|
285
|
-
return { startsAssistantTurn: true }
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (kind === "shell") {
|
|
289
|
-
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
290
|
-
if (job.safe && dangerousShell(command)) {
|
|
291
|
-
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
292
|
-
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
293
|
-
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
|
|
294
|
-
}
|
|
295
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
296
|
-
const shellBody = { command, agent }
|
|
297
|
-
if (model) shellBody.model = model
|
|
298
|
-
const dispatch = fireSdk(
|
|
299
|
-
client,
|
|
300
|
-
"session.shell",
|
|
301
|
-
client.session.shell.bind(client.session),
|
|
302
|
-
{ path: { id: sessionID }, body: shellBody },
|
|
303
|
-
{ path: { sessionID }, body: shellBody },
|
|
304
|
-
{ sessionID, ...shellBody },
|
|
305
|
-
)
|
|
306
|
-
return { startsAssistantTurn: true, dispatch }
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const prompt = await buildPrompt(directory, job)
|
|
310
|
-
const prefix = kind === "goal"
|
|
311
|
-
? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real."
|
|
312
|
-
: "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
|
|
313
|
-
const promptText = `${prefix}\n\n${prompt}`
|
|
314
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
315
|
-
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
316
|
-
if (model) promptBody.model = model
|
|
317
|
-
const dispatch = fireSdk(
|
|
318
|
-
client,
|
|
319
|
-
"session.prompt",
|
|
320
|
-
client.session.prompt.bind(client.session),
|
|
321
|
-
{ path: { id: sessionID }, body: promptBody },
|
|
322
|
-
{ path: { sessionID }, body: promptBody },
|
|
323
|
-
{ sessionID, ...promptBody },
|
|
324
|
-
)
|
|
325
|
-
return { startsAssistantTurn: true, dispatch }
|
|
326
|
-
}
|
|
206
|
+
const fireAction = actionDispatcher.fireAction
|
|
327
207
|
|
|
328
208
|
async function maybeRunDueJobs(directory, client, sessionID, runOptions = {}) {
|
|
329
209
|
rememberSession(directory, client, sessionID)
|
|
@@ -367,64 +247,11 @@ export function createLoopExecutor(options = {}) {
|
|
|
367
247
|
return
|
|
368
248
|
}
|
|
369
249
|
job = due[0]
|
|
370
|
-
const runNowRequested = Number(job.runNowRequestedAt || 0) > 0
|
|
371
|
-
|
|
372
|
-
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
373
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
374
|
-
await writeState(directory, sessionID, state)
|
|
375
|
-
await notifyJob(directory, job, "max_runtime_reached")
|
|
376
|
-
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
377
|
-
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
378
|
-
await reschedule()
|
|
379
|
-
return
|
|
380
|
-
}
|
|
381
|
-
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
382
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
383
|
-
await writeState(directory, sessionID, state)
|
|
384
|
-
await notifyJob(directory, job, "stop_file")
|
|
385
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
386
|
-
await reschedule()
|
|
387
|
-
return
|
|
388
|
-
}
|
|
389
|
-
if (await untilReached(directory, job)) {
|
|
390
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
391
|
-
await writeState(directory, sessionID, state)
|
|
392
|
-
await notifyJob(directory, job, "until_reached")
|
|
393
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
394
|
-
await reschedule()
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
250
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
await writeState(directory, sessionID, state)
|
|
403
|
-
await notifyJob(directory, job, "preflight_blocked")
|
|
404
|
-
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
405
|
-
await reschedule()
|
|
406
|
-
return
|
|
407
|
-
}
|
|
408
|
-
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
409
|
-
await appendLoopLog(directory, "preflight", {
|
|
410
|
-
sessionID,
|
|
411
|
-
job: job.name || job.id,
|
|
412
|
-
command: job.preflightCommand,
|
|
413
|
-
code: preflight.code,
|
|
414
|
-
})
|
|
415
|
-
if (preflight.code !== 0) {
|
|
416
|
-
if (runNowRequested) delete job.runNowRequestedAt
|
|
417
|
-
job.paused = true
|
|
418
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
419
|
-
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
420
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
421
|
-
await writeState(directory, sessionID, state)
|
|
422
|
-
await notifyJob(directory, job, "preflight_failed")
|
|
423
|
-
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
424
|
-
await reschedule()
|
|
425
|
-
return
|
|
426
|
-
}
|
|
427
|
-
}
|
|
251
|
+
const admission = await admissionRuntime.admitJob(directory, client, sessionID, state, job)
|
|
252
|
+
if (!admission.admitted) return
|
|
253
|
+
job = admission.job
|
|
254
|
+
const runNowRequested = admission.runNowRequested
|
|
428
255
|
|
|
429
256
|
job = await ensureBranch(directory, job, client, sessionID)
|
|
430
257
|
const compactResult = await compactionRuntime.maybeCompact(directory, client, sessionID, job)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function hasConcreteGoalEvidence(value) {
|
|
2
|
+
const text = String(value || "").trim()
|
|
3
|
+
if (text.length < 24) return false
|
|
4
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ")
|
|
5
|
+
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
|
|
6
|
+
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
|
|
7
|
+
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
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function goalChecksPassed(job) {
|
|
11
|
+
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function goalRequiresPassingChecks(job) {
|
|
15
|
+
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function goalProgressSnapshot(job) {
|
|
19
|
+
return {
|
|
20
|
+
status: job?.goalStatus || "",
|
|
21
|
+
progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
|
|
22
|
+
evidence: String(job?.goalEvidence || ""),
|
|
23
|
+
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
24
|
+
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
25
|
+
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
26
|
+
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
31
|
+
const before = goalProgressSnapshot(beforeJob || {})
|
|
32
|
+
const after = goalProgressSnapshot(afterJob || {})
|
|
33
|
+
if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
|
|
34
|
+
if (after.progressCount > before.progressCount) return true
|
|
35
|
+
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
|
|
36
|
+
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
|
|
37
|
+
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_GOAL_MAX_NO_PROGRESS, now as defaultNow } from "../core/args.js"
|
|
2
2
|
import { isGoalJob } from "../core/jobs.js"
|
|
3
3
|
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
4
|
-
import { goalMadeMeaningfulProgress } from "./goal-
|
|
4
|
+
import { goalMadeMeaningfulProgress } from "./goal-evidence.js"
|
|
5
5
|
|
|
6
6
|
function requireFunction(value, name) {
|
|
7
7
|
if (typeof value !== "function") throw new TypeError(`createGoalExecutionPolicy requires ${name}`)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { DEFAULT_GOAL_MAX_NO_PROGRESS } from "../core/args.js"
|
|
3
|
+
import { readSmallTextFile } from "../core/process.js"
|
|
4
|
+
|
|
5
|
+
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
6
|
+
|
|
7
|
+
export async function buildGoalPrompt(directory, job) {
|
|
8
|
+
const sections = []
|
|
9
|
+
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\".`)
|
|
10
|
+
const objective = String(job.action || "").trim()
|
|
11
|
+
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
12
|
+
if (job.goalFile) {
|
|
13
|
+
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
14
|
+
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
15
|
+
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
16
|
+
}
|
|
17
|
+
if (job.promptFile) {
|
|
18
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
19
|
+
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
20
|
+
}
|
|
21
|
+
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
22
|
+
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
23
|
+
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
24
|
+
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
25
|
+
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
26
|
+
if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
|
|
27
|
+
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.`)
|
|
28
|
+
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
29
|
+
for (const file of job.includeFiles || []) {
|
|
30
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
31
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return `${GOAL_PROMPT_PREFIX}.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
Rules:
|
|
39
|
+
- Work on the next smallest useful step toward the goal.
|
|
40
|
+
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
41
|
+
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
42
|
+
- 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.
|
|
43
|
+
- Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
|
|
44
|
+
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
45
|
+
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
46
|
+
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
47
|
+
- If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
|
|
48
|
+
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
49
|
+
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
50
|
+
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
51
|
+
|
|
52
|
+
${sections.join("\n\n---\n\n")}`
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { DEFAULT_GOAL_MAX_NO_PROGRESS, safeID } from "../core/args.js"
|
|
4
|
+
import { isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
|
+
import { stateDir, ensureDir } from "../core/state.js"
|
|
6
|
+
|
|
7
|
+
const GOAL_REPORT_DIR = "goals"
|
|
8
|
+
|
|
9
|
+
export function goalReportPath(directory, sessionID, job) {
|
|
10
|
+
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function goalReportText(job) {
|
|
14
|
+
const lines = []
|
|
15
|
+
lines.push(`# OpenCode Loop Goal Report`)
|
|
16
|
+
lines.push("")
|
|
17
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
18
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
19
|
+
lines.push(`Created: ${job.createdAt || ""}`)
|
|
20
|
+
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
21
|
+
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
22
|
+
if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
|
|
23
|
+
if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
|
|
24
|
+
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
25
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
|
|
26
|
+
lines.push("")
|
|
27
|
+
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
28
|
+
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
29
|
+
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
30
|
+
if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
|
|
31
|
+
if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
|
|
32
|
+
if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
|
|
33
|
+
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
34
|
+
if (job.lastGoalChecks?.length) {
|
|
35
|
+
lines.push("## Latest checks", "")
|
|
36
|
+
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
37
|
+
lines.push("")
|
|
38
|
+
}
|
|
39
|
+
if (job.goalProgress?.length) {
|
|
40
|
+
lines.push("## Progress", "")
|
|
41
|
+
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
42
|
+
lines.push("")
|
|
43
|
+
}
|
|
44
|
+
return lines.join("\n")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeGoalReport(directory, sessionID, job) {
|
|
48
|
+
if (!isGoalJob(job)) return
|
|
49
|
+
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
50
|
+
await ensureDir(path.dirname(target))
|
|
51
|
+
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
52
|
+
}
|
|
@@ -1,105 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
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
|