diff --git a/README.md b/README.md index f5f27d5c..15ca2576 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,18 @@ Create a starter progress file first if needed: /loop-init ``` +### Stop when a Markdown checklist is complete (unreleased) + +The native V2 source on this branch supports an **opt-in, host-checked** completion guard. It is **not part of the published 0.6.8 npm package** until a verified release ships: + +```text +/loop --progress-file TASK.md --until-checklist-complete --verify "npm test" finish the tasks in TASK.md +``` + +On a safe idle/scheduling boundary, Loop reads `TASK.md` as a regular project file. If it has at least one Markdown checkbox and all actionable boxes are checked, Loop stops scheduling further turns. If `--verify` is configured, Loop also requires a previously observed successful host verification. Empty/missing/oversized/unsafe/symlink/control-plane files pause for review rather than being called finished; code-fenced and quoted checkbox examples do not count. + +**Without `--until-checklist-complete`, `--progress-file` only supplies context** and normal unlimited loops stay unlimited. The host/user controls `/loop-stop` and `/loop-clear` directly; having a model print those names is not an actual command invocation. Follow [#174](https://github.com/ByBrawe/opencode-loop/issues/174) and [#175](https://github.com/ByBrawe/opencode-loop/issues/175). + ### Repeat every 5 minutes ```text diff --git a/docs/SCHEDULING.md b/docs/SCHEDULING.md index e694497f..3a9d648d 100644 --- a/docs/SCHEDULING.md +++ b/docs/SCHEDULING.md @@ -330,6 +330,20 @@ If the desired contract is to stop when the project is demonstrably complete, ma /loop --safe --ask-never --progress-file progress.md devam et bitene kadar; projeyi bitir ``` +## Opt-in checklist completion guard (unreleased on main) + +The native V2 code under development adds `--until-checklist-complete` when `--progress-file` names the user-owned project Markdown checklist: + +```text +/loop --progress-file TASK.md --until-checklist-complete --verify "npm test" finish remaining tasks +``` + +`--progress-file` **alone** is not a completion condition, and ordinary idle loops remain intentionally unlimited. With the opt-in flag, the host checks real Markdown task boxes on safe idle/admission boundaries, ignoring quoted and code-fenced examples. At least one task must exist, and every task must be `[x]`/`[X]`. If `--verify` is configured, a successful host-observed verification must also exist. Missing, unsafe, unreadable, control-plane or oversized files pause instead of claiming success. See [#174](https://github.com/ByBrawe/opencode-loop/issues/174). + +Users can stop any job directly with `/loop-stop` or `/loop-clear`. An assistant simply printing those strings in chat does **not** execute the host-native slash command; see [#175](https://github.com/ByBrawe/opencode-loop/issues/175). + +This feature is **not shipped in npm 0.6.8**; verify the release notes of a later version before relying on it. + ## Stopping and limits Idle loops are intentionally unlimited by default. Bound them when needed: diff --git a/package.json b/package.json index 9113a667..cfb2d27d 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/native.js --outfile=src/native.js --target=bun --format=esm", "build:plugin:npm": "npm run build:plugin", "prepack": "node --check src/plugin.js && node --check src/v2.js && node --check src/index.js && node --check src/server.js && node --check src/native.js", - "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/state-process-test.mjs && node --check scripts/state-exfat-lock-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js", - "test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/state-process-test.mjs && node scripts/state-exfat-lock-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", + "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/state-process-test.mjs && node --check scripts/state-exfat-lock-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/source/runtime/checklist-completion.js && node --check scripts/checklist-completion-test.mjs && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js", + "test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/checklist-completion-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/state-process-test.mjs && node scripts/state-exfat-lock-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs", "install:global": "node scripts/install-with-goals.mjs", "pack:zip": "node scripts/make-zip.mjs", diff --git a/scripts/checklist-completion-test.mjs b/scripts/checklist-completion-test.mjs new file mode 100644 index 00000000..0993645c --- /dev/null +++ b/scripts/checklist-completion-test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict" +import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { parseLoopArgs } from "../src/source/core/args.js" +import { summarizeTaskChecklist, inspectTaskChecklist } from "../src/source/runtime/checklist-completion.js" +import { formatOpenCode2LoopStatus } from "../src/source/opencode2/status.js" +import { createNativeJobPolicy } from "../src/source/opencode2/native-policy.js" + +let checks = 0 +function expectSummary(input, status, total, pending) { + const got = summarizeTaskChecklist(input) + assert.deepEqual([got.status, got.total, got.pending], [status, total, pending]) + checks++ +} +const ticks = String.fromCharCode(96).repeat(3) +expectSummary("- [ ] first\n- [x] second", "pending", 2, 1) +expectSummary("- [X] first\r\n * [x] second\r\n + [x] third", "complete", 3, 0) +expectSummary("\uFEFF- [x] already finished\n", "complete", 1, 0) +expectSummary("- [ ]", "pending", 1, 1) +expectSummary("- [x]\n- [ ] TODO", "pending", 2, 1) +expectSummary(ticks + "md\n- [ ] fake\n" + ticks + "\n- [x] real", "complete", 1, 0) +expectSummary("~~~~\n- [ ] fake\n~~~~\n- [x] real", "complete", 1, 0) +expectSummary("> - [ ] fake\n- [x] real", "complete", 1, 0) +expectSummary("\n- [x] real", "complete", 1, 0) +expectSummary("\n- [x] real", "complete", 1, 0) +expectSummary("No Markdown task boxes here.", "unavailable", 0, 0) +expectSummary(ticks + "\n- [x] fake\n" + ticks, "unavailable", 0, 0) +expectSummary("## [x] Not a task\n- [other] Nope", "unavailable", 0, 0) + +const args = parseLoopArgs("0s --progress-file TASK.md --until-checklist-complete task") +assert.equal(args.ok, true) +assert.equal(args.job.untilChecklistComplete, true) +assert.equal(args.job.progressFile, "TASK.md") +assert.equal(parseLoopArgs("0s --until-checklist-complete task").ok, false) +assert.equal(parseLoopArgs("0s --progress-file TASK.md task").job.untilChecklistComplete, false) +checks += 5 +assert.equal(formatOpenCode2LoopStatus({ jobs: [] }).text, "OpenCode loop status:\nNo active loop jobs.") +checks++ + +const root = await mkdtemp(path.join(os.tmpdir(), "loop-checklist-")) +try { + await writeFile(path.join(root, "TASK.md"), "- [ ] one\n- [x] two\n") + let state = await inspectTaskChecklist(root, "TASK.md") + assert.deepEqual([state.status, state.total, state.pending], ["pending", 2, 1]) + checks++ + await writeFile(path.join(root, "TASK.md"), "- [X] one\n- [x] two\n") + state = await inspectTaskChecklist(root, "TASK.md") + assert.equal(state.status, "complete") + assert.match(state.digest, /^[0-9a-f]{64}$/) + checks++ + const verifiedHash = state.digest + const policy = createNativeJobPolicy() + const job = { id: "verify", untilChecklistComplete: true, progressFile: "TASK.md", + verifyCommand: "npm test", lastVerifyCode: 0, lastVerifyAt: 10, lastVerifyChecklistDigest: verifiedHash } + await writeFile(path.join(root, "TASK.md"), "- [X] one\n- [x] two\n- [x] three\n") + assert.equal(await policy.checkStop({ directory: root }, job), true, + "stale digest must not complete a modified checklist") + assert.equal(job.enabled, undefined) + assert.equal(job.paused, undefined) + checks++ + job.lastVerifyChecklistDigest = (await inspectTaskChecklist(root, "TASK.md")).digest + assert.equal(await policy.checkStop({ directory: root }, job), false) + assert.equal(job.paused, true) + assert.equal(job.pauseReason, "checklist-complete") + checks++ + await mkdir(path.join(root, "plans")) + await writeFile(path.join(root, "plans", "tasks.md"), " - [x] done\n") + assert.equal((await inspectTaskChecklist(root, "plans/tasks.md")).status, "complete") + checks++ + + const bad = [ + ["missing.md", "file-missing"], + ["../escape.md", "outside-project"], + ["/tmp/not-a-project-task.md", "absolute-path"], + [".opencode/goals/goal.md", "control-plane-path"], + [".git/TASK.md", "control-plane-path"], + ["node_modules/TASK.md", "control-plane-path"], + ["plans", "not-regular-file"], + ] + for (const [file, reason] of bad) { + const result = await inspectTaskChecklist(root, file) + assert.equal(result.status, "unavailable", file) + assert.equal(result.reason, reason, file) + checks++ + } + await writeFile(path.join(root, "empty.md"), "Just prose\n") + assert.equal((await inspectTaskChecklist(root, "empty.md")).reason, "no-task-checkboxes") + checks++ + await writeFile(path.join(root, "huge.md"), "x".repeat(256_001)) + assert.equal((await inspectTaskChecklist(root, "huge.md")).reason, "file-too-large") + checks++ + try { + await symlink(path.join(root, "TASK.md"), path.join(root, "linked.md")) + assert.equal((await inspectTaskChecklist(root, "linked.md")).reason, "symlink-path") + checks++ + } catch (error) { + if (!["EPERM", "EACCES", "ENOTSUP", "EOPNOTSUPP"].includes(error?.code)) throw error + } +} finally { + await rm(root, { force: true, recursive: true }) +} + +console.log("checklist completion regression tests passed: " + checks) diff --git a/scripts/native-v2-test.mjs b/scripts/native-v2-test.mjs index 1a78d383..a880691b 100644 --- a/scripts/native-v2-test.mjs +++ b/scripts/native-v2-test.mjs @@ -47,6 +47,67 @@ async function test(name, run, extra) { finally { await f.close() } } +await test("explicit checklist stop prevents the next V2 model turn", async (f) => { + await writeFile(path.join(f.directory, "TASK.md"), "- [ ] implement\n- [x] setup\n") + const added = await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3") + assert.equal(added.accepted, true) + await f.wake() + assert.equal(f.prompts.length, 1) + await writeFile(path.join(f.directory, "TASK.md"), "- [x] implement\n- [x] setup\n") + await f.complete() + const job = (await f.state()).jobs[0] + assert.equal(f.prompts.length, 1, "no redundant model prompt after host checks TASK.md") + assert.equal(job.enabled, false) + assert.equal(job.paused, true) + assert.equal(job.pauseReason, "checklist-complete") + assert.deepEqual(job.checklistStatus, { status: "complete", total: 2, pending: 0 }) +}) + +await test("explicit checklist refuses missing project file instead of spending tokens", async (f) => { + assert.equal((await f.add("0s --progress-file MISSING.md --until-checklist-complete implement")).accepted, true) + await f.wake() + const job = (await f.state()).jobs[0] + assert.equal(f.prompts.length, 0) + assert.equal(job.paused, true) + assert.equal(job.pauseReason, "checklist-unavailable") + assert.equal(job.lastChecklistError, "file-missing") +}) + +await test("an already-complete checklist still requires host verification when requested", async (f) => { + await writeFile(path.join(f.directory, "TASK.md"), "- [x] done\n") + await f.add('0s --progress-file TASK.md --until-checklist-complete --verify "npm test" check work --max-runs 2') + await f.wake() + assert.equal(f.prompts.length, 1, "unobserved verify must not pass by default") + await f.complete() + const job = (await f.state()).jobs[0] + assert.equal(f.hooks.length, 1) + assert.equal(f.hooks[0], "npm test") + assert.equal(f.prompts.length, 1, "observed verify pass stops before another turn") + assert.equal(job.enabled, false) + assert.equal(job.pauseReason, "checklist-complete") + assert.equal(job.lastVerifyCode, 0) +}) + +await test("progress-file without opt-in still follows existing finite or infinite semantics", async (f) => { + await writeFile(path.join(f.directory, "TASK.md"), "- [x] all done\n") + await f.add("0s --progress-file TASK.md task --max-runs 1") + await f.wake() + assert.equal(f.prompts.length, 1) + assert.equal((await f.state()).jobs[0].untilChecklistComplete, false) +}) + +await test("native checklist flag requires explicit progress file and status reveals stop controls", async (f) => { + const rejected = await f.add("0s --until-checklist-complete implement") + assert.equal(rejected.accepted, false) + assert.match(rejected.error, /--progress-file/) + await writeFile(path.join(f.directory, "TASK.md"), "- [ ] todo\n") + await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3") + const status = await f.control("loop-status") + assert.match(status.status.text, /checklist-stop:TASK\.md/) + assert.match(status.status.text, /\/loop-stop/) + assert.match(status.status.text, /\/loop-clear/) +}) + await test("one durable prompt under concurrent idle notifications", async (f) => { assert.equal((await f.add("0s keep working --max-runs 2")).accepted, true) await Promise.all(Array.from({ length: 12 }, () => f.wake())) diff --git a/scripts/run-admission-test.mjs b/scripts/run-admission-test.mjs index f525179f..fdfa2ec9 100644 --- a/scripts/run-admission-test.mjs +++ b/scripts/run-admission-test.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict" import path from "node:path" +import os from "node:os" +import { mkdtemp, writeFile, rm } from "node:fs/promises" import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js" +import { inspectTaskChecklist } from "../src/source/runtime/checklist-completion.js" assert.throws(() => createRunAdmissionRuntime({}), /untilReached/) assert.throws(() => createRunAdmissionRuntime({ untilReached: async () => false }), /scheduleDueWork/) @@ -130,4 +133,48 @@ assert.equal(admitted.paused, undefined) assert.equal(admitted.runNowRequestedAt, clock) assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 0) +const project = await mkdtemp(path.join(os.tmpdir(), "loop-admission-checklist-")) +try { + await writeFile(path.join(project, "TASK.md"), "- [ ] code\n- [x] plan\n") + const checklistJob = { id: "checklist-job", enabled: true, progressFile: "TASK.md", untilChecklistComplete: true } + const checklistState = { jobs: [checklistJob] } + assert.equal((await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob)).admitted, true) + assert.deepEqual(checklistJob.checklistStatus, { status: "pending", total: 2, pending: 1 }) + + await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n") + assert.deepEqual(await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob), + { admitted: false, reason: "checklist_complete" }) + assert.equal(checklistState.jobs.length, 0) + assert.equal(notifications.at(-1)[2], "checklist_complete") + + const verifyJob = { id: "verify-checklist", enabled: true, progressFile: "TASK.md", + untilChecklistComplete: true, verifyCommand: "npm test" } + const verifyState = { jobs: [verifyJob] } + assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true, + "unchecked host verification cannot imply completion") + verifyJob.lastVerifyCode = 1 + verifyJob.lastVerifyAt = clock + assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true, + "failed host verification cannot imply completion") + verifyJob.lastVerifyCode = 0 + assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true, + "successful verify without recorded checklist fingerprint is not authoritative") + verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest + await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n- [x] later task\n") + assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true, + "verification for older checklist bytes cannot certify newly changed work") + verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest + assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).reason, "checklist_complete") + + const missingJob = { id: "missing-checklist", enabled: true, progressFile: "MISSING.md", untilChecklistComplete: true } + const missingState = { jobs: [missingJob] } + const missing = await runtime.admitJob(project, {}, "session-missing", missingState, missingJob) + assert.deepEqual(missing, { admitted: false, reason: "checklist_unavailable" }) + assert.equal(missingJob.paused, true) + assert.equal(missingJob.lastChecklistError, "file-missing") + assert.equal(missingState.jobs.length, 1, "preserve job for explicit recovery") +} finally { + await rm(project, { force: true, recursive: true }) +} + console.log("run admission runtime tests passed") diff --git a/src/index.js b/src/index.js index cf0b399b..be10b9e3 100644 --- a/src/index.js +++ b/src/index.js @@ -141,6 +141,7 @@ function parseLoopArgs(raw, defaults = {}) { until: defaults.until, stopFile: defaults.stopFile, progressFile: defaults.progressFile, + untilChecklistComplete: defaults.untilChecklistComplete ?? false, promptFile: defaults.promptFile, includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [], watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [], @@ -221,6 +222,9 @@ function parseLoopArgs(raw, defaults = {}) { [found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true; + [found, rest] = takeFlag(rest, "--until-checklist-complete"); + if (found) + job.untilChecklistComplete = true; [found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true; @@ -369,6 +373,8 @@ function parseLoopArgs(raw, defaults = {}) { job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS; } job.lastRunAt = job.immediate ? 0 : now(); + if (job.untilChecklistComplete && !job.progressFile) + return { ok: false, error: "--until-checklist-complete requires --progress-file " }; 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" }; return { ok: true, job }; @@ -1445,6 +1451,8 @@ function decoratePrompt(job) { additions.push(continuation); if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`); + if (job.untilChecklistComplete) + additions.push("When all actual Markdown tasks in the configured progress file are checked, stop creating new work and report results. Loop will make the stopping decision itself from the project file at a safe host boundary; do not attempt to invoke /loop-clear as assistant text."); if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200)); if (job.askNever) @@ -3625,6 +3633,125 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job, return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text }; } +// src/source/runtime/checklist-completion.js +import { promises as fs8, constants } from "fs"; +import path9 from "path"; +import { createHash } from "crypto"; +var MAX_CHECKLIST_BYTES = 256000; +var EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"]); +function unavailable(reason) { + return { status: "unavailable", reason, total: 0, pending: 0 }; +} +function summarizeTaskChecklist(markdown) { + let total = 0; + let pending = 0; + let fence = null; + let comment = false; + for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) { + let line = ""; + let offset = 0; + while (offset < original.length) { + if (comment) { + const end = original.indexOf("-->", offset); + if (end < 0) + break; + comment = false; + offset = end + 3; + } else { + const start = original.indexOf("", offset); + if (end < 0) + break; + comment = false; + offset = end + 3; + } else { + const start = original.indexOf("", offset); + if (end < 0) + break; + comment = false; + offset = end + 3; + } else { + const start = original.indexOf("", offset) + if (end < 0) break + comment = false + offset = end + 3 + } else { + const start = original.indexOf("