@bybrawe/opencode-loop 0.5.6 → 0.5.7
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 +12 -0
- package/README.md +109 -2
- package/commands/loop-goal-blocked.md +5 -0
- package/commands/loop-goal-clear.md +5 -0
- package/commands/loop-goal-done.md +5 -0
- package/commands/loop-goal-pause.md +5 -0
- package/commands/loop-goal-resume.md +5 -0
- package/commands/loop-goal-status.md +5 -0
- package/commands/loop-goal.md +5 -0
- package/package.json +2 -2
- package/src/index.js +374 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.7
|
|
4
|
+
|
|
5
|
+
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
6
|
+
- Added goal lifecycle commands: `/loop-goal-status`, `/loop-goal-pause`, `/loop-goal-resume`, `/loop-goal-clear`, `/loop-goal-done`, and `/loop-goal-blocked`.
|
|
7
|
+
- Added local goal tools exposed to the agent: `opencode_loop_goal_complete`, `opencode_loop_goal_blocked`, and `opencode_loop_goal_progress`.
|
|
8
|
+
- Added goal acceptance criteria with repeated `--acceptance` / `--success` flags.
|
|
9
|
+
- Added post-turn goal check commands with repeated `--check` flags.
|
|
10
|
+
- Added `--complete-when-checks-pass` for goals that should automatically stop once all configured checks pass.
|
|
11
|
+
- Added `--max-turns` alias for goal run limits.
|
|
12
|
+
- Added goal reports under `.opencode/opencode-loop/goals/` and optional `--evidence-file`.
|
|
13
|
+
- Updated README with simple examples explaining prompt loops, command loops, shell loops, scheduled checks, and experimental persistent goals.
|
|
14
|
+
|
|
3
15
|
## 0.5.6
|
|
4
16
|
|
|
5
17
|
- Added clearer README examples for prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, first-run timing, and idle-safe behavior.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ NPM package name: **@bybrawe/opencode-loop**
|
|
|
11
11
|
|
|
12
12
|
## Current status
|
|
13
13
|
|
|
14
|
-
**v0.5.
|
|
14
|
+
**v0.5.7 adds experimental Goal Mode.** OpenCode Loop still fixes the recent OpenCode update compatibility issue and keeps the clear action types from v0.5.6. The TUI loop waits for the session to become idle, debounces idle events, and avoids starting a new run while OpenCode is still busy or queued. It separates prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, compact loops, and now experimental persistent goals so commands like `/compact` are not sent as normal chat text.
|
|
15
15
|
|
|
16
16
|
The known update-related symptoms from older builds are fixed:
|
|
17
17
|
|
|
@@ -21,6 +21,7 @@ The known update-related symptoms from older builds are fixed:
|
|
|
21
21
|
- prompt, shell, or toast calls silently fail after OpenCode SDK changes
|
|
22
22
|
- `/compact` accidentally being treated as a normal agent prompt
|
|
23
23
|
- intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
|
|
24
|
+
- experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, or cleared
|
|
24
25
|
|
|
25
26
|
The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
|
|
26
27
|
|
|
@@ -85,6 +86,7 @@ OpenCode Loop is designed for developers searching for:
|
|
|
85
86
|
- **Branch setup** with `--branch ai-loop`.
|
|
86
87
|
- **Stop controls** with `--stop-file STOP_LOOP`, `--until`, `/loop-stop`, `/loop-pause`, `/loop-resume`, and `/loop-remove`.
|
|
87
88
|
- **Watch mode** with `--watch progress.md`.
|
|
89
|
+
- **Experimental Goal Mode** with `/loop-goal`, goal status, goal tools, acceptance criteria, and verification checks.
|
|
88
90
|
- **Diagnostics** with `/loop-doctor`.
|
|
89
91
|
- **Starter progress file** with `/loop-init`.
|
|
90
92
|
- **State export** with `/loop-export`.
|
|
@@ -264,7 +266,7 @@ If the commands do not appear:
|
|
|
264
266
|
|
|
265
267
|
## Quick start
|
|
266
268
|
|
|
267
|
-
### The
|
|
269
|
+
### The 6 examples most people need
|
|
268
270
|
|
|
269
271
|
Auto-continue every time OpenCode finishes a turn:
|
|
270
272
|
|
|
@@ -302,6 +304,12 @@ Run a real shell command every 10 minutes when idle:
|
|
|
302
304
|
/loop-shell 10m npm test
|
|
303
305
|
```
|
|
304
306
|
|
|
307
|
+
Experimental goal mode: keep working until the objective is actually done, blocked, paused, or cleared:
|
|
308
|
+
|
|
309
|
+
```text
|
|
310
|
+
/loop-goal finish the feature, run tsc --noEmit and build, fix every error, and stop only when everything passes
|
|
311
|
+
```
|
|
312
|
+
|
|
305
313
|
### What “when idle” means
|
|
306
314
|
|
|
307
315
|
OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
|
|
@@ -340,6 +348,89 @@ That can look like a chat prompt in older versions or unclear setups. Prefer `/l
|
|
|
340
348
|
/loop-compact 200m
|
|
341
349
|
```
|
|
342
350
|
|
|
351
|
+
### Experimental Goal Mode
|
|
352
|
+
|
|
353
|
+
Goal Mode is experimental. Use it when you do **not** want a timer such as “every 5 minutes”. Use it when you want OpenCode to keep pursuing a result until it is complete or blocked.
|
|
354
|
+
|
|
355
|
+
Basic goal:
|
|
356
|
+
|
|
357
|
+
```text
|
|
358
|
+
/loop-goal fix the TypeScript build errors. Run tsc --noEmit and npm run build. Stop only when both pass.
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
What happens:
|
|
362
|
+
|
|
363
|
+
1. The goal is saved as the active experimental goal.
|
|
364
|
+
2. OpenCode starts working immediately by default.
|
|
365
|
+
3. When OpenCode becomes idle, the plugin checks whether the goal is still active.
|
|
366
|
+
4. If the goal is not complete or blocked, the plugin injects the next Goal Mode continuation prompt.
|
|
367
|
+
5. The agent is instructed to call a local goal tool when it has real progress, completion evidence, or a blocked reason.
|
|
368
|
+
|
|
369
|
+
Goal Mode is idle-safe. If OpenCode is busy, it waits. It does not intentionally start another turn on top of a running turn.
|
|
370
|
+
|
|
371
|
+
Simple status and control commands:
|
|
372
|
+
|
|
373
|
+
```text
|
|
374
|
+
/loop-goal-status
|
|
375
|
+
/loop-goal-pause
|
|
376
|
+
/loop-goal-resume
|
|
377
|
+
/loop-goal-clear
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
You can also use subcommands through `/loop-goal`:
|
|
381
|
+
|
|
382
|
+
```text
|
|
383
|
+
/loop-goal status
|
|
384
|
+
/loop-goal pause
|
|
385
|
+
/loop-goal resume
|
|
386
|
+
/loop-goal clear
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Manual completion or blocked state:
|
|
390
|
+
|
|
391
|
+
```text
|
|
392
|
+
/loop-goal-done shipped the feature and all checks pass
|
|
393
|
+
/loop-goal-blocked missing API key for the staging provider
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Goal with acceptance criteria:
|
|
397
|
+
|
|
398
|
+
```text
|
|
399
|
+
/loop-goal --acceptance "tsc --noEmit passes" --acceptance "npm run build passes" --acceptance "no new TODO hacks" fix all build errors
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Goal with automatic check commands after each assistant turn:
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
/loop-goal --check "npm run typecheck" --check "npm run build" fix every TypeScript and build error
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Goal that automatically stops when all configured checks pass:
|
|
409
|
+
|
|
410
|
+
```text
|
|
411
|
+
/loop-goal --check "npm run typecheck" --check "npm run build" --complete-when-checks-pass make the project build cleanly
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Goal with a safety/runtime limit:
|
|
415
|
+
|
|
416
|
+
```text
|
|
417
|
+
/loop-goal --max-turns 20 --max-runtime 3h --check "npm test" fix the failing tests without destructive commands
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Goal Mode writes a report under:
|
|
421
|
+
|
|
422
|
+
```text
|
|
423
|
+
.opencode/opencode-loop/goals/
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
You can choose a report file:
|
|
427
|
+
|
|
428
|
+
```text
|
|
429
|
+
/loop-goal --evidence-file goal-report.md --check "npm test" make tests pass and document the result
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Goal Mode is inspired by persistent-objective workflows such as Codex `/goal`, but this package implements it at the OpenCode plugin layer. It is intentionally marked experimental because model tool-calling behavior and OpenCode plugin APIs may change.
|
|
433
|
+
|
|
343
434
|
### Safer development loop
|
|
344
435
|
|
|
345
436
|
```text
|
|
@@ -453,6 +544,13 @@ Use `--name` to manage separate named loops, or `--multi` when you intentionally
|
|
|
453
544
|
| `/loop-ask <interval> <prompt/question>` | Schedule a recurring question/check prompt; first run waits for the interval by default |
|
|
454
545
|
| `/loop-prompt <interval> <prompt>` | Force prompt mode, even when you want explicit prompt-loop naming |
|
|
455
546
|
| `/loop-shell <interval> <shell-command>` | Schedule a shell command loop |
|
|
547
|
+
| `/loop-goal <objective>` | Start experimental persistent Goal Mode |
|
|
548
|
+
| `/loop-goal-status` | Show experimental goal state |
|
|
549
|
+
| `/loop-goal-pause` | Pause the active experimental goal |
|
|
550
|
+
| `/loop-goal-resume` | Resume the active experimental goal |
|
|
551
|
+
| `/loop-goal-clear` | Clear experimental goals |
|
|
552
|
+
| `/loop-goal-done <summary>` | Manually mark the active goal complete |
|
|
553
|
+
| `/loop-goal-blocked <reason>` | Manually mark the active goal blocked |
|
|
456
554
|
| `/loop-help` | Show usage help inside OpenCode |
|
|
457
555
|
| `/loop-status` | Show active loop jobs |
|
|
458
556
|
| `/loop-logs` | Show recent loop log entries |
|
|
@@ -499,6 +597,7 @@ OpenCode Loop has separate types for prompts, OpenCode slash commands, and shell
|
|
|
499
597
|
| Scheduled question | Ask/check on a timer | `/loop-ask 1h did you run tests and build?` | After the interval by default |
|
|
500
598
|
| Slash-command loop | Run OpenCode commands like `/compact` | `/loop-command 200m /compact` | After the interval by default |
|
|
501
599
|
| Shell loop | Run real terminal commands | `/loop-shell 10m npm test` | After the interval by default |
|
|
600
|
+
| Experimental goal | Keep pursuing an outcome until complete/blocked | `/loop-goal make build pass` | Now by default |
|
|
502
601
|
|
|
503
602
|
You can still force the type on `/loop`:
|
|
504
603
|
|
|
@@ -957,6 +1056,14 @@ Improve the application in small safe steps.
|
|
|
957
1056
|
|
|
958
1057
|
## Changelog highlights
|
|
959
1058
|
|
|
1059
|
+
### v0.5.7
|
|
1060
|
+
|
|
1061
|
+
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
1062
|
+
- Added goal lifecycle commands and local goal tools so the agent can report real progress, completion evidence, or blocked state.
|
|
1063
|
+
- Added repeated `--acceptance`, `--success`, and `--check` flags for goal criteria and verification.
|
|
1064
|
+
- Added `--complete-when-checks-pass`, `--max-turns`, and `--evidence-file` for controlled goal workflows.
|
|
1065
|
+
- Goal reports are written under `.opencode/opencode-loop/goals/` by default.
|
|
1066
|
+
|
|
960
1067
|
### v0.5.6
|
|
961
1068
|
|
|
962
1069
|
- Added clearer README examples for prompt loops, scheduled question loops, command loops, shell loops, first-run timing, and idle-safe behavior.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Experimental persistent goal mode. Usage: /loop-goal finish the feature and keep tests green
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Experimental goal command received. The OpenCode Loop plugin handles this goal locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code style /loop
|
|
3
|
+
"version": "0.5.7",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: auto-continue, command loops, compact scheduling, verification, checkpoints, and safe autonomous coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,8 @@ const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
|
8
8
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
9
9
|
const MAX_SCAN_FILES = 200
|
|
10
10
|
const MAX_SCAN_BYTES = 2_000_000
|
|
11
|
+
const GOAL_REPORT_DIR = "goals"
|
|
12
|
+
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
11
13
|
|
|
12
14
|
const activeRuns = new Map()
|
|
13
15
|
const handledCommands = new Map()
|
|
@@ -149,7 +151,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
149
151
|
} else if (first) {
|
|
150
152
|
const parsedDuration = parseDuration(first)
|
|
151
153
|
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
152
|
-
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
154
|
+
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-goal <objective> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
153
155
|
else rest = input
|
|
154
156
|
}
|
|
155
157
|
}
|
|
@@ -182,6 +184,16 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
182
184
|
notifyCommand: defaults.notifyCommand,
|
|
183
185
|
branch: defaults.branch,
|
|
184
186
|
branchDone: false,
|
|
187
|
+
goalStatus: defaults.goalStatus,
|
|
188
|
+
goalFile: defaults.goalFile,
|
|
189
|
+
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
190
|
+
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
191
|
+
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
192
|
+
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
193
|
+
goalSummary: defaults.goalSummary || "",
|
|
194
|
+
goalEvidence: defaults.goalEvidence || "",
|
|
195
|
+
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
196
|
+
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
185
197
|
noOverlap: defaults.noOverlap ?? true,
|
|
186
198
|
safe: defaults.safe ?? false,
|
|
187
199
|
quiet: defaults.quiet ?? false,
|
|
@@ -226,9 +238,13 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
226
238
|
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
227
239
|
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
228
240
|
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
241
|
+
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
242
|
+
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
243
|
+
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
229
244
|
|
|
230
245
|
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
231
246
|
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
247
|
+
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
232
248
|
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
233
249
|
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
234
250
|
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
@@ -236,6 +252,8 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
236
252
|
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
237
253
|
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
238
254
|
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
255
|
+
;[value, rest] = takeFlagValue(rest, "--goal-file"); if (value !== undefined) job.goalFile = stripOuterQuotes(value)
|
|
256
|
+
;[value, rest] = takeFlagValue(rest, "--evidence-file"); if (value !== undefined) job.goalEvidenceFile = stripOuterQuotes(value)
|
|
239
257
|
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
240
258
|
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
241
259
|
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
@@ -254,12 +272,33 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
254
272
|
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
255
273
|
rest = includes[1]
|
|
256
274
|
|
|
275
|
+
const acceptances = takeAllFlagValues(rest, "--acceptance")
|
|
276
|
+
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean))
|
|
277
|
+
rest = acceptances[1]
|
|
278
|
+
|
|
279
|
+
const success = takeAllFlagValues(rest, "--success")
|
|
280
|
+
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean))
|
|
281
|
+
rest = success[1]
|
|
282
|
+
|
|
283
|
+
const checks = takeAllFlagValues(rest, "--check")
|
|
284
|
+
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean))
|
|
285
|
+
rest = checks[1]
|
|
286
|
+
|
|
257
287
|
job.action = stripOuterQuotes(rest || job.action || "")
|
|
258
288
|
job.watchPaths = [...new Set(job.watchPaths)]
|
|
259
289
|
job.includeFiles = [...new Set(job.includeFiles)]
|
|
290
|
+
job.goalAcceptance = [...new Set(job.goalAcceptance || [])]
|
|
291
|
+
job.goalChecks = [...new Set(job.goalChecks || [])]
|
|
292
|
+
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
293
|
+
job.name = job.name || "goal"
|
|
294
|
+
job.goalStatus = job.goalStatus || "active"
|
|
295
|
+
job.safe = job.safe !== false
|
|
296
|
+
job.askNever = job.askNever !== false
|
|
297
|
+
job.noOverlap = job.noOverlap !== false
|
|
298
|
+
}
|
|
260
299
|
job.lastRunAt = job.immediate ? 0 : now()
|
|
261
300
|
|
|
262
|
-
if (!job.action && !job.promptFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md or /loop 0s --prompt-file loop-prompt.md" }
|
|
301
|
+
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" }
|
|
263
302
|
return { ok: true, job }
|
|
264
303
|
}
|
|
265
304
|
|
|
@@ -271,15 +310,15 @@ async function pathExists(filePath) { try { await fs.access(filePath); return tr
|
|
|
271
310
|
async function readState(directory, sessionID) {
|
|
272
311
|
try {
|
|
273
312
|
const parsed = JSON.parse(await fs.readFile(statePath(directory, sessionID), "utf8"))
|
|
274
|
-
return { version:
|
|
313
|
+
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
275
314
|
} catch {
|
|
276
|
-
return { version:
|
|
315
|
+
return { version: 4, jobs: [] }
|
|
277
316
|
}
|
|
278
317
|
}
|
|
279
318
|
|
|
280
319
|
async function writeState(directory, sessionID, state) {
|
|
281
320
|
await ensureDir(stateDir(directory))
|
|
282
|
-
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version:
|
|
321
|
+
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
|
|
283
322
|
}
|
|
284
323
|
|
|
285
324
|
async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
|
|
@@ -497,6 +536,7 @@ function actionKind(action, job = {}) {
|
|
|
497
536
|
const text = String(action || "").trim()
|
|
498
537
|
const forced = String(job.kind || "").trim().toLowerCase()
|
|
499
538
|
if (forced === "compact") return "compact"
|
|
539
|
+
if (forced === "goal") return "goal"
|
|
500
540
|
if (text === "/compact" || text === "/summarize") return "compact"
|
|
501
541
|
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
502
542
|
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
@@ -520,7 +560,63 @@ function decoratePrompt(job) {
|
|
|
520
560
|
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
521
561
|
}
|
|
522
562
|
|
|
563
|
+
function isGoalJob(job) {
|
|
564
|
+
return String(job?.kind || "").toLowerCase() === "goal"
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function goalStatusText(job) {
|
|
568
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
569
|
+
if (!status) return ""
|
|
570
|
+
if (status === "completed") return "completed"
|
|
571
|
+
if (status === "blocked") return "blocked"
|
|
572
|
+
if (job?.paused) return "paused"
|
|
573
|
+
return status
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function buildGoalPrompt(directory, job) {
|
|
577
|
+
const sections = []
|
|
578
|
+
const objective = String(job.action || "").trim()
|
|
579
|
+
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
580
|
+
if (job.goalFile) {
|
|
581
|
+
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
582
|
+
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
583
|
+
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
584
|
+
}
|
|
585
|
+
if (job.promptFile) {
|
|
586
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
587
|
+
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
588
|
+
}
|
|
589
|
+
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
590
|
+
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
591
|
+
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
592
|
+
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
593
|
+
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
594
|
+
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
595
|
+
for (const file of job.includeFiles || []) {
|
|
596
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
597
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return `${GOAL_PROMPT_PREFIX}.
|
|
601
|
+
|
|
602
|
+
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.
|
|
603
|
+
|
|
604
|
+
Rules:
|
|
605
|
+
- Work on the next smallest useful step toward the goal.
|
|
606
|
+
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
607
|
+
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
608
|
+
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
609
|
+
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
610
|
+
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
611
|
+
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
612
|
+
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
613
|
+
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
614
|
+
|
|
615
|
+
${sections.join("\n\n---\n\n")}`
|
|
616
|
+
}
|
|
617
|
+
|
|
523
618
|
async function buildPrompt(directory, job) {
|
|
619
|
+
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
524
620
|
const sections = []
|
|
525
621
|
if (job.promptFile) {
|
|
526
622
|
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
@@ -688,6 +784,7 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
688
784
|
function dueJobs(state, force = false) {
|
|
689
785
|
const current = now()
|
|
690
786
|
return (state.jobs || []).filter((job) => {
|
|
787
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
691
788
|
if (!job.enabled || job.paused) return false
|
|
692
789
|
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
693
790
|
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
@@ -703,6 +800,146 @@ function clearActiveRun(sessionID) {
|
|
|
703
800
|
activeRuns.delete(sessionID)
|
|
704
801
|
}
|
|
705
802
|
|
|
803
|
+
function goalReportPath(directory, sessionID, job) {
|
|
804
|
+
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function goalReportText(job) {
|
|
808
|
+
const lines = []
|
|
809
|
+
lines.push(`# OpenCode Loop Goal Report`)
|
|
810
|
+
lines.push("")
|
|
811
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
812
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
813
|
+
lines.push(`Created: ${job.createdAt || ""}`)
|
|
814
|
+
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
815
|
+
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
816
|
+
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
817
|
+
lines.push("")
|
|
818
|
+
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
819
|
+
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
820
|
+
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
821
|
+
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
822
|
+
if (job.lastGoalChecks?.length) {
|
|
823
|
+
lines.push("## Latest checks", "")
|
|
824
|
+
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
825
|
+
lines.push("")
|
|
826
|
+
}
|
|
827
|
+
if (job.goalProgress?.length) {
|
|
828
|
+
lines.push("## Progress", "")
|
|
829
|
+
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
830
|
+
lines.push("")
|
|
831
|
+
}
|
|
832
|
+
return lines.join("\n")
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function writeGoalReport(directory, sessionID, job) {
|
|
836
|
+
if (!isGoalJob(job)) return
|
|
837
|
+
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
838
|
+
await ensureDir(path.dirname(target))
|
|
839
|
+
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function pickGoalJob(state, target = "") {
|
|
843
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
844
|
+
if (!goals.length) return undefined
|
|
845
|
+
const text = String(target || "").trim()
|
|
846
|
+
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
847
|
+
return goals.find((job, index) => matchJob(job, text, index))
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function parseGoalToolText(args, fields) {
|
|
851
|
+
const result = {}
|
|
852
|
+
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
853
|
+
return result
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
857
|
+
const state = await readState(directory, sessionID)
|
|
858
|
+
const job = pickGoalJob(state, args.target)
|
|
859
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
860
|
+
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
861
|
+
job.goalStatus = "completed"
|
|
862
|
+
job.enabled = false
|
|
863
|
+
job.paused = true
|
|
864
|
+
job.goalCompletedAt = now()
|
|
865
|
+
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
866
|
+
job.goalEvidence = parsed.evidence || job.goalEvidence || "No evidence provided."
|
|
867
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
868
|
+
await writeState(directory, sessionID, state)
|
|
869
|
+
await writeGoalReport(directory, sessionID, job)
|
|
870
|
+
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
871
|
+
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
875
|
+
const state = await readState(directory, sessionID)
|
|
876
|
+
const job = pickGoalJob(state, args.target)
|
|
877
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
878
|
+
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
879
|
+
job.goalStatus = "blocked"
|
|
880
|
+
job.enabled = false
|
|
881
|
+
job.paused = true
|
|
882
|
+
job.goalBlockedAt = now()
|
|
883
|
+
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
884
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
885
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
886
|
+
await writeState(directory, sessionID, state)
|
|
887
|
+
await writeGoalReport(directory, sessionID, job)
|
|
888
|
+
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
889
|
+
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
893
|
+
const state = await readState(directory, sessionID)
|
|
894
|
+
const job = pickGoalJob(state, args.target)
|
|
895
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
896
|
+
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
897
|
+
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
898
|
+
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
899
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
900
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
901
|
+
await writeState(directory, sessionID, state)
|
|
902
|
+
await writeGoalReport(directory, sessionID, job)
|
|
903
|
+
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
904
|
+
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function runGoalChecks(directory, sessionID, job, client) {
|
|
908
|
+
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
|
|
909
|
+
const results = []
|
|
910
|
+
for (const command of job.goalChecks) {
|
|
911
|
+
if (job.safe && dangerousShell(command)) {
|
|
912
|
+
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
913
|
+
continue
|
|
914
|
+
}
|
|
915
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
|
|
916
|
+
results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
|
|
917
|
+
}
|
|
918
|
+
job.lastGoalCheckAt = now()
|
|
919
|
+
job.lastGoalChecks = results
|
|
920
|
+
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
921
|
+
if (allPassed) {
|
|
922
|
+
job.goalChecksPassedAt = now()
|
|
923
|
+
job.failureCount = 0
|
|
924
|
+
await toast(client, "Goal checks passed.", "success")
|
|
925
|
+
if (job.goalCompleteWhenChecksPass) {
|
|
926
|
+
job.goalStatus = "completed"
|
|
927
|
+
job.enabled = false
|
|
928
|
+
job.paused = true
|
|
929
|
+
job.goalCompletedAt = now()
|
|
930
|
+
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
931
|
+
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
|
|
932
|
+
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
|
|
933
|
+
}
|
|
934
|
+
} else {
|
|
935
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
936
|
+
job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
|
|
937
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
|
|
938
|
+
}
|
|
939
|
+
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
940
|
+
return job
|
|
941
|
+
}
|
|
942
|
+
|
|
706
943
|
async function finalizeActiveRun(directory, client, sessionID) {
|
|
707
944
|
const active = activeRuns.get(sessionID)
|
|
708
945
|
if (!active) return
|
|
@@ -750,8 +987,10 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
750
987
|
}
|
|
751
988
|
}
|
|
752
989
|
|
|
753
|
-
|
|
990
|
+
if (isGoalJob(job)) job = await runGoalChecks(directory, sessionID, job, client)
|
|
991
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
754
992
|
await writeState(directory, sessionID, state)
|
|
993
|
+
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
755
994
|
await createCheckpoint(directory, sessionID, job, client)
|
|
756
995
|
}
|
|
757
996
|
|
|
@@ -788,7 +1027,14 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
788
1027
|
return { startsAssistantTurn: true }
|
|
789
1028
|
}
|
|
790
1029
|
const prompt = await buildPrompt(directory, job)
|
|
791
|
-
|
|
1030
|
+
const prefix = kind === "goal"
|
|
1031
|
+
? "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."
|
|
1032
|
+
: "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."
|
|
1033
|
+
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1034
|
+
|
|
1035
|
+
${prompt}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
|
|
1036
|
+
|
|
1037
|
+
${prompt}` }] } })
|
|
792
1038
|
return { startsAssistantTurn: true }
|
|
793
1039
|
}
|
|
794
1040
|
|
|
@@ -870,7 +1116,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
870
1116
|
const result = await fireAction(directory, client, sessionID, job)
|
|
871
1117
|
if (!result.startsAssistantTurn) {
|
|
872
1118
|
const fresh = await readState(directory, sessionID)
|
|
873
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false)
|
|
1119
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
874
1120
|
await writeState(directory, sessionID, fresh)
|
|
875
1121
|
}
|
|
876
1122
|
if (result.startsAssistantTurn) {
|
|
@@ -949,12 +1195,81 @@ async function updateJobState(directory, client, sessionID, args, updater, messa
|
|
|
949
1195
|
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
950
1196
|
}
|
|
951
1197
|
|
|
1198
|
+
async function statusGoal(directory, client, sessionID) {
|
|
1199
|
+
const state = await readState(directory, sessionID)
|
|
1200
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1201
|
+
const lines = goals.length ? goals.map((job, index) => {
|
|
1202
|
+
const status = goalStatusText(job)
|
|
1203
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
1204
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
1205
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
1206
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}`
|
|
1207
|
+
}) : ["No experimental goal jobs."]
|
|
1208
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
1209
|
+
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function pauseGoal(directory, client, sessionID, args) {
|
|
1213
|
+
const target = String(args || "").trim() || "goal"
|
|
1214
|
+
const state = await readState(directory, sessionID)
|
|
1215
|
+
let count = 0
|
|
1216
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
1217
|
+
await writeState(directory, sessionID, state)
|
|
1218
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async function resumeGoal(directory, client, sessionID, args) {
|
|
1222
|
+
const target = String(args || "").trim() || "goal"
|
|
1223
|
+
const state = await readState(directory, sessionID)
|
|
1224
|
+
let count = 0
|
|
1225
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
1226
|
+
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
1227
|
+
count++
|
|
1228
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0 }
|
|
1229
|
+
})
|
|
1230
|
+
await writeState(directory, sessionID, state)
|
|
1231
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1232
|
+
if (count) scheduleIdleWork(directory, client, sessionID)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function clearGoal(directory, client, sessionID, args) {
|
|
1236
|
+
const target = String(args || "").trim()
|
|
1237
|
+
const state = await readState(directory, sessionID)
|
|
1238
|
+
const before = state.jobs.length
|
|
1239
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
1240
|
+
await writeState(directory, sessionID, state)
|
|
1241
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1245
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done." })
|
|
1246
|
+
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1250
|
+
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
1251
|
+
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function addGoal(directory, client, sessionID, args) {
|
|
1255
|
+
const text = String(args || "").trim()
|
|
1256
|
+
const [maybeCommand, rest] = splitFirst(text)
|
|
1257
|
+
const sub = maybeCommand.toLowerCase()
|
|
1258
|
+
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
1259
|
+
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
1260
|
+
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
1261
|
+
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
1262
|
+
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
1263
|
+
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
1264
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
1265
|
+
}
|
|
1266
|
+
|
|
952
1267
|
async function statusLoop(directory, client, sessionID) {
|
|
953
1268
|
const state = await readState(directory, sessionID)
|
|
954
1269
|
const jobs = state.jobs || []
|
|
955
1270
|
const lines = jobs.length ? jobs.map((job, index) => {
|
|
956
1271
|
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
957
|
-
const flags = [job.paused ? "paused" : "active", job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
1272
|
+
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
958
1273
|
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
959
1274
|
}) : ["No active loop jobs."]
|
|
960
1275
|
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
@@ -975,6 +1290,9 @@ async function helpLoop(client, sessionID) {
|
|
|
975
1290
|
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
976
1291
|
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
977
1292
|
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1293
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1294
|
+
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
1295
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
978
1296
|
"/loop 200m --command /compact same as command loop",
|
|
979
1297
|
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
980
1298
|
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
@@ -1004,6 +1322,7 @@ async function doctorLoop(directory, client, sessionID) {
|
|
|
1004
1322
|
`- node: ${process.version}`,
|
|
1005
1323
|
`- platform: ${process.platform}`,
|
|
1006
1324
|
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
1325
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
1007
1326
|
].join("\n"))
|
|
1008
1327
|
}
|
|
1009
1328
|
|
|
@@ -1037,6 +1356,13 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1037
1356
|
return true
|
|
1038
1357
|
}
|
|
1039
1358
|
|
|
1359
|
+
if (name === "loop-goal") return await addGoal(directory, client, sessionID, args), handled()
|
|
1360
|
+
if (name === "loop-goal-status") return await statusGoal(directory, client, sessionID), handled()
|
|
1361
|
+
if (name === "loop-goal-pause") return await pauseGoal(directory, client, sessionID, args), handled()
|
|
1362
|
+
if (name === "loop-goal-resume") return await resumeGoal(directory, client, sessionID, args), handled()
|
|
1363
|
+
if (name === "loop-goal-clear") return await clearGoal(directory, client, sessionID, args), handled()
|
|
1364
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete") return await completeGoalCommand(directory, client, sessionID, args), handled()
|
|
1365
|
+
if (name === "loop-goal-blocked") return await blockGoalCommand(directory, client, sessionID, args), handled()
|
|
1040
1366
|
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
1041
1367
|
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
1042
1368
|
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
@@ -1054,9 +1380,48 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1054
1380
|
return false
|
|
1055
1381
|
}
|
|
1056
1382
|
|
|
1383
|
+
function goalTools(defaultDirectory) {
|
|
1384
|
+
return {
|
|
1385
|
+
opencode_loop_goal_complete: {
|
|
1386
|
+
description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
1387
|
+
args: {
|
|
1388
|
+
summary: { type: "string", description: "Short human-readable summary of what was completed." },
|
|
1389
|
+
evidence: { type: "string", description: "Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results." },
|
|
1390
|
+
},
|
|
1391
|
+
execute: async (args, context) => {
|
|
1392
|
+
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1393
|
+
return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
|
|
1394
|
+
},
|
|
1395
|
+
},
|
|
1396
|
+
opencode_loop_goal_blocked: {
|
|
1397
|
+
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
1398
|
+
args: {
|
|
1399
|
+
reason: { type: "string", description: "Why the goal is blocked." },
|
|
1400
|
+
needed: { type: "string", description: "What user input, credential, decision, or manual action is needed to continue." },
|
|
1401
|
+
},
|
|
1402
|
+
execute: async (args, context) => {
|
|
1403
|
+
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
1404
|
+
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
opencode_loop_goal_progress: {
|
|
1408
|
+
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
1409
|
+
args: {
|
|
1410
|
+
summary: { type: "string", description: "What useful progress was made." },
|
|
1411
|
+
next: { type: "string", description: "The next step toward completing the goal." },
|
|
1412
|
+
},
|
|
1413
|
+
execute: async (args, context) => {
|
|
1414
|
+
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
1415
|
+
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
1416
|
+
},
|
|
1417
|
+
},
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1057
1421
|
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
1058
1422
|
await log(client, "info", "Plugin initialized", { directory })
|
|
1059
1423
|
return {
|
|
1424
|
+
tool: goalTools(directory),
|
|
1060
1425
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
1061
1426
|
event: async ({ event }) => {
|
|
1062
1427
|
if (event.type === "command.executed") {
|