@bojackduy/opencode-loopd 1.0.0

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/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@bojackduy/opencode-loopd",
4
+ "version": "1.0.0",
5
+ "description": "Codex-inspired background goal engine for OpenCode — autonomous child sessions, engine-driven loop, and modal TUI dashboard",
6
+ "type": "module",
7
+ "license": "AGPL-3.0-only",
8
+ "private": false,
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/bojackduy/opencode-loopd.git"
12
+ },
13
+ "homepage": "https://github.com/bojackduy/opencode-loopd#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/bojackduy/opencode-loopd/issues"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "tui",
21
+ "loop",
22
+ "goal",
23
+ "background",
24
+ "automation",
25
+ "worker",
26
+ "subagent",
27
+ "opencode-loopd"
28
+ ],
29
+ "bin": {
30
+ "opencode-loopd": "./scripts/install-node.mjs"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "scripts",
35
+ "skills",
36
+ "commands",
37
+ "README.md",
38
+ "LICENSE",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "engines": {
45
+ "bun": ">=1.1.0"
46
+ },
47
+ "exports": {
48
+ "./server": {
49
+ "import": "./dist/server.js"
50
+ },
51
+ "./tui": {
52
+ "import": "./dist/tui.js"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "typecheck": "tsc --noEmit",
57
+ "build:server": "bun build src/server/plugin.ts --outfile dist/server.js --target bun --external @opencode-ai/plugin --external @opencode-ai/plugin/tool",
58
+ "build:tui": "bun scripts/build-tui.ts",
59
+ "build": "bun run build:server && bun run build:tui",
60
+ "clean": "rm -rf dist",
61
+ "prepack": "bun run typecheck && bun test && bun run build",
62
+ "test": "bun test",
63
+ "release:patch": "bun run typecheck && bun test && npm version patch -m \"chore: release %s\" && git push && git push origin $(git describe --tags --abbrev=0)",
64
+ "release:minor": "bun run typecheck && bun test && npm version minor -m \"chore: release %s\" && git push && git push origin $(git describe --tags --abbrev=0)",
65
+ "release:major": "bun run typecheck && bun test && npm version major -m \"chore: release %s\" && git push && git push origin $(git describe --tags --abbrev=0)"
66
+ },
67
+ "peerDependencies": {
68
+ "@opencode-ai/plugin": ">=1.4.0 <2",
69
+ "@opentui/core": "*",
70
+ "@opentui/solid": "*",
71
+ "solid-js": "*"
72
+ },
73
+ "devDependencies": {
74
+ "@opencode-ai/plugin": "^1.18.15",
75
+ "@opentui/core": "^0.4.1",
76
+ "@opentui/solid": "^0.4.1",
77
+ "@types/bun": "^1.3.14",
78
+ "@types/node": "^26.2.0",
79
+ "solid-js": "^1.9.13",
80
+ "typescript": "^5.7.0"
81
+ }
82
+ }
@@ -0,0 +1,20 @@
1
+ import solidPlugin from "@opentui/solid/bun-plugin"
2
+
3
+ const result = await Bun.build({
4
+ entrypoints: ["src/tui/plugin.tsx"],
5
+ outdir: "dist",
6
+ naming: "tui.js",
7
+ target: "bun",
8
+ external: [
9
+ "@opencode-ai/plugin/tui",
10
+ "@opentui/core",
11
+ "@opentui/solid",
12
+ "solid-js",
13
+ ],
14
+ plugins: [solidPlugin],
15
+ })
16
+
17
+ if (!result.success) {
18
+ for (const log of result.logs) console.error(log)
19
+ process.exit(1)
20
+ }
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
3
+ import { homedir } from "node:os"
4
+ import { dirname, join } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
8
+ const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
9
+ const pluginDir = join(config, "plugins")
10
+ const commandDir = join(config, "commands")
11
+ const skillDir = join(config, "skills", "loopd")
12
+ const packagePath = join(config, "package.json")
13
+ const packageName = "opencode-loopd"
14
+ const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
15
+ const packageSpec = `${packageName}@${packageVersion}`
16
+ const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
17
+ const installerArgs = process.argv.slice(2)
18
+ const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
19
+
20
+ if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
21
+ console.log(`opencode-loopd installer/updater\n\nUsage:\n opencode-loopd\n npx -y opencode-loopd@latest\n npx -y opencode-loopd@latest --uninstall\n\nInstall/update registers the plugin, installs /goal command and loopd skill.\nUninstall removes the plugin registration, command and skill.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
22
+ process.exit(0)
23
+ }
24
+
25
+ if (installerArgs.includes("--version") || installerArgs.includes("-v")) {
26
+ console.log(packageVersion)
27
+ process.exit(0)
28
+ }
29
+
30
+ if (installerArgs.length && !uninstallRequested) {
31
+ console.error(`Unknown installer option: ${installerArgs[0]}`)
32
+ process.exit(2)
33
+ }
34
+
35
+ function stripJsonComments(input) {
36
+ let out = "", quote = "", esc = false, lc = false, bc = false
37
+ for (let i = 0; i < input.length; i++) {
38
+ const c = input[i], n = input[i + 1]
39
+ if (lc) { if (c === "\n" || c === "\r") { lc = false; out += c } continue }
40
+ if (bc) { if (c === "*" && n === "/") { bc = false; i++ } else if (c === "\n" || c === "\r") out += c; continue }
41
+ if (quote) { out += c; if (esc) esc = false; else if (c === "\\") esc = true; else if (c === quote) quote = ""; continue }
42
+ if (c === '"') { quote = c; out += c; continue }
43
+ if (c === "/" && n === "/") { lc = true; i++; continue }
44
+ if (c === "/" && n === "*") { bc = true; i++; continue }
45
+ out += c
46
+ }
47
+ return out
48
+ }
49
+ function stripTrailingCommas(input) {
50
+ let out = "", quote = "", esc = false
51
+ for (let i = 0; i < input.length; i++) {
52
+ const c = input[i]
53
+ if (quote) { out += c; if (esc) esc = false; else if (c === "\\") esc = true; else if (c === quote) quote = ""; continue }
54
+ if (c === '"') { quote = c; out += c; continue }
55
+ if (c === ",") { let j = i + 1; while (/\s/.test(input[j] || "")) j++; if (input[j] === "]" || input[j] === "}") continue }
56
+ out += c
57
+ }
58
+ return out
59
+ }
60
+ function parseJsonc(input) {
61
+ const p = JSON.parse(stripTrailingCommas(stripJsonComments(input)))
62
+ if (!p || typeof p !== "object" || Array.isArray(p)) throw new Error("OpenCode config root must be an object")
63
+ return p
64
+ }
65
+ function isPackageSpec(v) { const s = String(v||"").trim(); return s === packageName || s.startsWith(`${packageName}@`) }
66
+ function skipTrivia(s, i) { while (i < s.length) { const c=s[i]||"", n=s[i+1]||""; if (/\s/.test(c)) {i++;continue} if (c==="/"&&n==="/") {i+=2; while(i<s.length&&s[i]!=="\n"&&s[i]!=="\r") i++; continue} if (c==="/"&&n==="*") { const e=s.indexOf("*/",i+2); if(e<0) throw new Error("unterminated block comment"); i=e+2; continue } break } return i }
67
+ function readJsonString(s, i) { if(s[i]!=='"') throw new Error("expected JSON string"); let esc=false; for(let j=i+1;j<s.length;j++){const c=s[j]||""; if(esc){esc=false; continue} if(c==="\\"){esc=true;continue} if(c==='"'){return {value:JSON.parse(s.slice(i,j+1)), end:j+1}}} throw new Error("unterminated JSON string") }
68
+ function skipJsonValue(s,i){ const vs=skipTrivia(s,i), f=s[vs]; if(f==='"') return readJsonString(s,vs).end; if(f==="{"||f==="["){const st=[]; let q=false,esc=false,lc=false,bc=false; for(let j=vs;j<s.length;j++){const c=s[j]||"",n=s[j+1]||""; if(lc){if(c==="\n"||c==="\r") lc=false; continue} if(bc){if(c==="*"&&n==="/"){bc=false;j++} continue} if(q){if(esc) esc=false; else if(c==="\\") esc=true; else if(c==='"') q=false; continue} if(c==='"'){q=true;continue} if(c==="/"&&n==="/"){lc=true;j++;continue} if(c==="/"&&n==="*"){bc=true;j++;continue} if(c==="{"||c==="[") st.push(c); else if(c==="}"||c==="]"){const e=c==="}"?"{":"["; if(st.at(-1)!==e) throw new Error("mismatched delimiters"); st.pop(); if(!st.length) return j+1} } throw new Error("unterminated JSON value") } let j=vs; while(j<s.length&&![",","}","]"].includes(s[j])) j++; return j }
69
+ function findRootProperty(s, name){ let i=skipTrivia(s,0); if(s[i]!=="{") throw new Error("OpenCode config must be root object"); i++; while(true){ i=skipTrivia(s,i); if(s[i]==="}") return null; const k=readJsonString(s,i); i=skipTrivia(s,k.end); if(s[i]!==":") throw new Error(`expected ':' after ${k.value}`); const vs=skipTrivia(s,i+1), ve=skipJsonValue(s,vs); if(k.value===name){ const ls=Math.max(s.lastIndexOf("\n",vs-1),s.lastIndexOf("\r",vs-1))+1; const kls=Math.max(s.lastIndexOf("\n",k.end-1),s.lastIndexOf("\r",k.end-1))+1; const indent=s.slice(kls,k.end-k.value.length-2).match(/^[\t ]*/)?.[0]||" "; return {valueStart:vs,valueEnd:ve,indent,lineStart:ls} } const av=skipTrivia(s,ve); if(s[av]===",") i=av+1; else if(s[av]==="}") return null; else throw new Error(`expected ',' or '}' after ${k.value}`) } }
70
+ function formatPluginArray(vals, indent, eol){ if(!vals.length) return "[]"; const ci=`${indent} `; return `[${eol}${vals.map(v=>`${ci}${JSON.stringify(v)}`).join(`,${eol}`)}${eol}${indent}]` }
71
+ function rewriteExistingPluginArray(source, next){ const prop=findRootProperty(source,"plugin"); if(!prop) return source; const eol=source.includes("\r\n")?"\r\n":"\n"; const rep=formatPluginArray(next,prop.indent,eol); return `${source.slice(0,prop.valueStart)}${rep}${source.slice(prop.valueEnd)}` }
72
+
73
+ async function configurePackagePlugin() {
74
+ let configured = false; const updatedFiles=[]
75
+ for (const name of configCandidates) {
76
+ const target = join(config, name)
77
+ try {
78
+ const source = await readFile(target, "utf8")
79
+ const parsed = parseJsonc(source)
80
+ if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error("plugin must be array")
81
+ const plugins = parsed.plugin || []
82
+ if (!plugins.some(isPackageSpec)) continue
83
+ configured = true
84
+ const next = plugins.filter(v=>!isPackageSpec(v))
85
+ next.push(packageSpec)
86
+ const upd = rewriteExistingPluginArray(source, next)
87
+ if (upd !== source) { await writeFile(target, upd, "utf8"); updatedFiles.push(target) }
88
+ } catch(e){ if(e?.code!=="ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
89
+ }
90
+ return {configured, updatedFiles}
91
+ }
92
+ async function ensureDependency(){
93
+ let pkg={}
94
+ try{ pkg=JSON.parse(await readFile(packagePath,"utf8")) }catch(e){ if(e?.code!=="ENOENT"){ console.warn(`Could not update ${packagePath}: ${e.message}`); return } }
95
+ if(!pkg||typeof pkg!=="object"||Array.isArray(pkg)) pkg={}
96
+ pkg.dependencies = pkg.dependencies && typeof pkg.dependencies==="object" && !Array.isArray(pkg.dependencies) ? pkg.dependencies : {}
97
+ if(!pkg.dependencies["@opencode-ai/plugin"]){ pkg.dependencies["@opencode-ai/plugin"]=">=1.4.0"; await writeFile(packagePath, JSON.stringify(pkg,null,2)+"\n","utf8") }
98
+ }
99
+ async function removePackagedFiles(srcDir, tgtDir){
100
+ try{ for(const n of await readdir(srcDir)) if(n.endsWith(".md")) await rm(join(tgtDir,n),{force:true}) }catch{}
101
+ }
102
+ async function uninstall(){
103
+ const plans=[]
104
+ for(const name of configCandidates){
105
+ const target=join(config,name)
106
+ try{
107
+ const source=await readFile(target,"utf8")
108
+ const parsed=parseJsonc(source)
109
+ if(parsed.plugin!==undefined&&!Array.isArray(parsed.plugin)) throw new Error("plugin must be array")
110
+ const plugins=parsed.plugin||[]
111
+ const next=plugins.filter(v=>!isPackageSpec(v))
112
+ const upd= next.length===plugins.length ? source : rewriteExistingPluginArray(source,next)
113
+ plans.push({target,source,updated:upd})
114
+ }catch(e){ if(e?.code!=="ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
115
+ }
116
+ for(const p of plans) if(p.updated!==p.source) await writeFile(p.target,p.updated,"utf8")
117
+ // remove loopd artifacts
118
+ await rm(join(config,"tui.json"),{force:true}).catch(()=>{}) // legacy if any
119
+ await removePackagedFiles(join(root,"commands"), join(config,"commands"))
120
+ await rm(join(config,"skills","loopd","SKILL.md"),{force:true})
121
+ try{ const d=join(config,"skills","loopd"); const files=await readdir(d); if(!files.length) await rm(d,{force:true}) }catch{}
122
+ const changed=plans.filter(p=>p.updated!==p.source).length
123
+ console.log(changed ? `Removed ${packageName} from ${changed} config file(s).` : `${packageName} was not registered.`)
124
+ console.log("Removed loopd command and skill when present. Project state under .opencode/loopd is preserved.")
125
+ console.log("Restart OpenCode to finish unloading.")
126
+ }
127
+ async function installOrUpdate(){
128
+ await mkdir(join(config,"plugins"),{recursive:true})
129
+ await mkdir(join(config,"commands"),{recursive:true})
130
+ await mkdir(join(config,"skills","loopd"),{recursive:true})
131
+ const pc=await configurePackagePlugin()
132
+ const usePkg=pc.configured
133
+ if(!usePkg){ await ensureDependency() }
134
+ // commands
135
+ for(const n of await readdir(join(root,"commands"))){
136
+ if(n.endsWith(".md")) await copyFile(join(root,"commands",n), join(config,"commands",n))
137
+ }
138
+ // skill
139
+ await copyFile(join(root,"skills","loopd","SKILL.md"), join(config,"skills","loopd","SKILL.md"))
140
+ if(usePkg){
141
+ const pin=pc.updatedFiles.length ? `pinned to ${packageSpec}` : `already pinned to ${packageSpec}`
142
+ console.log(`OpenCode Loopd is already configured as package in ${config}; ${pin}.`)
143
+ } else {
144
+ console.log(`Installed opencode-loopd to ${config}`)
145
+ }
146
+ console.log(`Installed ${packageName} command to ${join(config,"commands","goal.md")}`)
147
+ console.log(`Installed ${packageName} skill to ${join(config,"skills","loopd","SKILL.md")}`)
148
+ console.log("Restart OpenCode, then run: /goal or /loop")
149
+ }
150
+ if(uninstallRequested) await uninstall()
151
+ else await installOrUpdate()
@@ -0,0 +1,206 @@
1
+ ---
2
+ name: loopd
3
+ description: "Agent skill for loopd background goals. Teaches when to create goals, how to inspect/steer/pause them, and how to send instructions to workers."
4
+ metadata:
5
+ version: "1.0.0"
6
+ status: active
7
+ tags: [opencode, loop, goal, background, automation]
8
+ ---
9
+
10
+ # Loopd Background Goals
11
+
12
+ Loopd runs long-running or autonomous tasks as background goals, each with a dedicated worker session. The parent chat stays interactive while work happens behind the scenes.
13
+
14
+ ## When to Use Loopd
15
+
16
+ Use loopd when:
17
+
18
+ - A task takes many turns and would block the main chat
19
+ - You want autonomous work that continues while the user does other things
20
+ - A task needs progress tracking, completion checks, or blocking
21
+ - You want a dashboard to monitor and steer background work
22
+
23
+ Do not use loopd when:
24
+
25
+ - The task is trivial (1-3 turns)
26
+ - The user needs to answer questions at every step
27
+ - The task modifies production systems without review gates
28
+
29
+ ## Goal Lifecycle
30
+
31
+ ```
32
+ created → active → complete
33
+ → blocked (needs user intervention)
34
+ paused (user-initiated, can resume)
35
+ ```
36
+
37
+ ## Creating a Goal
38
+
39
+ Use `loopd_create_goal` after clarifying the objective with the user:
40
+
41
+ ```
42
+ loopd_create_goal({
43
+ name: "short-name",
44
+ objective: "Detailed description of what the goal should accomplish.",
45
+ checks: ["npm test"], // optional: shell commands for completion verification
46
+ progressFile: ".opencode/loopd/progress.md", // optional: worker reads/writes this
47
+ maxTurns: 50, // optional: safety budget
48
+ maxNoProgress: 5, // optional: auto-block without progress
49
+ maxFailures: 3, // optional: auto-block on failures
50
+ compactEvery: 3, // optional: compact worker session every N turns
51
+ })
52
+ ```
53
+
54
+ The goal starts immediately. The user can monitor it via `/loop` (Ctrl+L).
55
+
56
+ ## Worker Tools (Running Inside the Goal)
57
+
58
+ These tools are available to the worker session doing the actual work:
59
+
60
+ ### get_goal
61
+
62
+ Call at the start of every turn to read the objective, config, and current state. Always call this first.
63
+
64
+ ### report_goal_progress
65
+
66
+ Call after durable state changes (file writes, verifications). Resets failure and no-progress counters.
67
+
68
+ ```
69
+ report_goal_progress({
70
+ summary: "What was accomplished this turn.",
71
+ next: "The next concrete step.",
72
+ evidence: "Optional proof (file path, test output, etc.)."
73
+ })
74
+ ```
75
+
76
+ ### complete_goal
77
+
78
+ Call only when ALL acceptance criteria pass with concrete evidence. Runs configured completion checks before accepting.
79
+
80
+ ```
81
+ complete_goal({
82
+ summary: "What was completed.",
83
+ evidence: "Concrete evidence (test output, file existence, etc.)."
84
+ })
85
+ ```
86
+
87
+ ### block_goal
88
+
89
+ Call only for a real external blocker requiring user intervention (e.g., missing credentials, ambiguous requirements).
90
+
91
+ ```
92
+ block_goal({
93
+ reason: "Why the goal is blocked.",
94
+ needed: "What is needed to unblock."
95
+ })
96
+ ```
97
+
98
+ ### Worker questions vs send_goal_input
99
+
100
+ | Tool | Who calls it | Direction | When to use |
101
+ |------|-------------|-----------|-------------|
102
+ | `question` (OpenCode builtin) | Worker (inside goal) | Worker → User | Worker needs info only the user can provide. Question appears in the TUI footer as a blocker tab. |
103
+ | `send_goal_input` | Owner (parent chat) | User → Worker | Owner sends an instruction, answer, or redirect to the worker. |
104
+
105
+ **question** — The worker uses OpenCode's built-in `question` tool when it's genuinely stuck or ambiguous. The question appears in the TUI footer (blocker tab) and the user answers there. No goal status change needed — the worker stays busy until answered.
106
+
107
+ **send_goal_input** — The owner calls this to steer the worker. Messages are injected into the worker's next continuation prompt. Use it to:
108
+ - Redirect the worker to a different approach
109
+ - Refine scope or add constraints
110
+ - Provide missing context
111
+
112
+ ## Owner Tools (Parent Chat)
113
+
114
+ These tools are available in the parent chat that created the goal:
115
+
116
+ ### list_background_goals
117
+
118
+ Lists all active goals owned by this session. Shows status and progress.
119
+
120
+ ### inspect_background_goal
121
+
122
+ Shows detailed info: objective, config, progress, blocker, runtime state.
123
+
124
+ ### read_goal_transcript
125
+
126
+ Reads the last N messages from the worker session. Useful for debugging what the worker is doing.
127
+
128
+ ### send_goal_input
129
+
130
+ Sends a message to the worker's next turn. See table above.
131
+
132
+ ## Dashboard Commands
133
+
134
+ Open the dashboard with `/loop` or Ctrl+L.
135
+
136
+ ### Keyboard Shortcuts (Normal Mode)
137
+
138
+ | Key | Action |
139
+ |-----|--------|
140
+ | `j` / `k` | Move selection down / up |
141
+ | `g` / `G` | Jump to top / bottom |
142
+ | `o` | Open child (view details) |
143
+ | `L` | Toggle log view |
144
+ | `?` | Toggle help |
145
+ | `Ctrl+N` | Switch to normal mode |
146
+ | `:` | Enter command mode |
147
+
148
+ ### Command Mode (`:` prefix)
149
+
150
+ | Command | Description |
151
+ |---------|-------------|
152
+ | `:send <message>` | Send instruction to the selected goal's worker |
153
+ | `:open` | Open the selected goal's child session |
154
+ | `:force <summary> --evidence <text>` | Force-complete (bypass verification checks) |
155
+ | `:block <reason> --needed <text>` | Force-block the selected goal |
156
+ | `:pause` | Pause the selected goal |
157
+ | `:resume` | Resume the paused goal |
158
+ | `:retry` | Retry the blocked goal |
159
+ | `:clear` | Clear the selected goal |
160
+ | `:logs` | Toggle log view |
161
+ | `:help` | Show help |
162
+ | `:q` / `:close` | Close dashboard |
163
+
164
+ > Goal creation (`:goal start`) was removed from the dashboard — create goals via `/goal` in the parent chat so the agent can clarify the objective first.
165
+
166
+ ## Safety Patterns
167
+
168
+ 1. **Always call `get_goal` first** — Read the objective before doing any work.
169
+ 2. **Progress after durable changes** — Call `report_goal_progress` after file writes or verifications, not after thinking.
170
+ 3. **Complete with evidence** — Never call `complete_goal` without concrete proof (test output, file checks passing).
171
+ 4. **Block for real blockers only** — Don't block for things you can figure out. Block when you genuinely need user input.
172
+ 5. **Use checks for verification** — Set `checks` on goal creation to auto-verify completion (e.g., `["npm test", "test -f README.md"]`).
173
+ 6. **Set safety budgets** — Use `maxTurns`, `maxNoProgress`, and `maxFailures` to prevent runaway goals.
174
+ 7. **Compact periodically** — Use `compactEvery` to keep the worker session's context manageable.
175
+
176
+ ## Example: Creating and Monitoring a Goal
177
+
178
+ **Parent chat:**
179
+ ```
180
+ User: Write a README for this project and create a changelog.
181
+
182
+ Agent: I'll set up a background goal for this.
183
+
184
+ loopd_create_goal({
185
+ name: "docs-write",
186
+ objective: "Write a README.md covering: what it is, install, usage, architecture, and examples. Then create CHANGELOG.md with a v1.0.0 entry.",
187
+ checks: ["test -f README.md", "test -f CHANGELOG.md"],
188
+ progressFile: ".opencode/loopd/docs-progress.md",
189
+ maxTurns: 20
190
+ })
191
+
192
+ # Goal starts. User can monitor with /loop.
193
+ ```
194
+
195
+ **Worker session (automatic):**
196
+ ```
197
+ # Turn 1
198
+ get_goal() → reads objective
199
+ # ... explores codebase, writes README.md
200
+ report_goal_progress({ summary: "README.md written", next: "Create CHANGELOG.md" })
201
+
202
+ # Turn 2
203
+ get_goal() → reads objective
204
+ # ... writes CHANGELOG.md
205
+ complete_goal({ summary: "Docs complete", evidence: "README.md and CHANGELOG.md exist" })
206
+ ```