@bybrawe/opencode-loop 0.6.8 → 0.6.9
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 +9 -0
- package/README.md +21 -9
- package/examples/OPENCODE2-0.6.9.md +34 -0
- package/package.json +3 -3
- package/scripts/checklist-completion-test.mjs +104 -0
- package/scripts/comprehensive-test.mjs +9 -1
- package/scripts/desktop-command-diagnostics-test.mjs +97 -2
- package/scripts/desktop-command-diagnostics.mjs +70 -9
- package/scripts/loopd-test.mjs +1 -1
- package/scripts/loopd.mjs +4 -2
- package/scripts/native-cli-io-test.mjs +6 -3
- package/scripts/native-entry-test.mjs +16 -6
- package/scripts/native-release.mjs +2 -2
- package/scripts/native-v2-test.mjs +61 -0
- package/scripts/opencode2-loop-canary.mjs +2 -1
- package/scripts/package-native-smoke.mjs +41 -7
- package/scripts/publish-workflow-test.mjs +3 -2
- package/scripts/run-admission-test.mjs +47 -0
- package/src/index.js +155 -2
- package/src/native.js +176 -15
- package/src/server.js +180 -19
- package/src/source/core/args.js +3 -0
- package/src/source/core/jobs.js +1 -0
- package/src/source/opencode2/native-plugin.js +3 -0
- package/src/source/opencode2/native-policy.js +21 -0
- package/src/source/opencode2/native-runtime.js +1 -0
- package/src/source/opencode2/status.js +5 -1
- package/src/source/runtime/checklist-completion.js +113 -0
- package/src/source/runtime/run-admission.js +25 -0
- package/src/source/runtime/run-finalization.js +7 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from "node:test"
|
|
2
2
|
import assert from "node:assert/strict"
|
|
3
3
|
import { spawn } from "node:child_process"
|
|
4
|
-
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
|
|
4
|
+
import { mkdtemp, mkdir, writeFile, readFile, realpath, rm } from "node:fs/promises"
|
|
5
5
|
import os from "node:os"
|
|
6
6
|
import path from "node:path"
|
|
7
7
|
import { fileURLToPath } from "node:url"
|
|
@@ -47,9 +47,12 @@ process.stdin.resume();
|
|
|
47
47
|
assert.equal(result.code, 0, `${mode}: ${result.stdout}\n${result.stderr}`)
|
|
48
48
|
const calls = (await readFile(log, "utf8")).trim().split(/\r?\n/).map(JSON.parse)
|
|
49
49
|
if (mode === "pwd") {
|
|
50
|
+
// macOS /var/folders is an alias of /private/var/folders; the V2 host
|
|
51
|
+
// compares project PWD with the physical cwd, not the original alias.
|
|
52
|
+
const canonical = process.platform === "win32" ? project : await realpath(project)
|
|
50
53
|
for (const call of calls.filter(call => ["session", "run"].includes(call.args[0]))) {
|
|
51
|
-
assert.equal(call.pwd,
|
|
52
|
-
assert.equal(call.cwd,
|
|
54
|
+
assert.equal(call.pwd, canonical, "native lookup and run must use the canonical selected project, not the caller's PWD")
|
|
55
|
+
assert.equal(call.cwd, canonical)
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
if (mode === "stdin") assert.match(result.stdout, /INPUT=\r?\n/)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import { spawnSync } from "node:child_process"
|
|
3
|
-
import { readFileSync } from "node:fs"
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs"
|
|
4
4
|
import path from "node:path"
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
6
6
|
|
|
@@ -11,7 +11,9 @@ assert.ok(process.argv.length <= 3, "expected at most one consumer directory")
|
|
|
11
11
|
const loop = pkg.name === "@bybrawe/opencode-loop"
|
|
12
12
|
const cwd = process.argv[2] ? path.resolve(process.argv[2]) : root
|
|
13
13
|
const packageRoot = process.argv[2] ? path.join(cwd, "node_modules", ...pkg.name.split("/")) : root
|
|
14
|
-
|
|
14
|
+
// Node resolves ESM file URLs to real paths on macOS, where /var aliases /private/var.
|
|
15
|
+
// Guard and delegated V1 entry identities must use the same canonical URLs as the loader.
|
|
16
|
+
const url = (relative) => pathToFileURL(realpathSync.native(path.join(packageRoot, relative))).href
|
|
15
17
|
const legacyEntries = loop ? [url("src/v1.js"), url("src/source/v1.js")] : [url("dist/legacy-v1.js")]
|
|
16
18
|
const blocked = loop
|
|
17
19
|
? [...legacyEntries, url("src/index.js"), url("src/source/legacy-v1.js")]
|
|
@@ -23,15 +25,23 @@ const serverEntries = loop ? [`${pkg.name}/server`, url("src/source/server.js")]
|
|
|
23
25
|
function probe(mode) {
|
|
24
26
|
const stub = "globalThis.__legacyModuleLoads = (globalThis.__legacyModuleLoads || 0) + 1; export default async (...args) => { if (args[0]?.failure) throw args[0].failure; return { args }; }"
|
|
25
27
|
const loader = `
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
import { realpathSync } from "node:fs";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
const physical = (url) => {
|
|
31
|
+
if (!url.startsWith("file:")) return url;
|
|
32
|
+
const name = realpathSync.native(fileURLToPath(url));
|
|
33
|
+
return process.platform === "win32" ? name.toLowerCase() : name;
|
|
34
|
+
};
|
|
35
|
+
const blocked = new Set(${JSON.stringify(blocked)}.map(physical));
|
|
36
|
+
const legacyEntries = new Set(${JSON.stringify(legacyEntries)}.map(physical));
|
|
28
37
|
export async function resolve(specifier, context, nextResolve) {
|
|
29
38
|
if (specifier.startsWith("@opencode-ai/")) throw new Error("V2_LEGACY_IMPORT: " + specifier);
|
|
30
39
|
const result = await nextResolve(specifier, context);
|
|
31
|
-
|
|
40
|
+
const actual = physical(result.url);
|
|
41
|
+
if (${JSON.stringify(mode)} === "delegate" && legacyEntries.has(actual)) {
|
|
32
42
|
return { url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(stub)}`)}, shortCircuit: true };
|
|
33
43
|
}
|
|
34
|
-
if (blocked.
|
|
44
|
+
if (blocked.has(actual)) throw new Error("V2_LEGACY_IMPORT: " + result.url);
|
|
35
45
|
return result;
|
|
36
46
|
}
|
|
37
47
|
`
|
|
@@ -7,11 +7,11 @@ export function validateRelease(request, pkg, env) {
|
|
|
7
7
|
assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
|
|
8
8
|
assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
|
|
9
9
|
assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
|
|
10
|
-
assert.equal(request?.version, "0.6.
|
|
10
|
+
assert.equal(request?.version, "0.6.9", "release version is not authorized")
|
|
11
11
|
assert.equal(request?.name, "@bybrawe/opencode-loop")
|
|
12
12
|
assert.equal(pkg.name, request.name)
|
|
13
13
|
assert.equal(pkg.version, request.version)
|
|
14
|
-
assert.equal(request.predecessor, "0.6.
|
|
14
|
+
assert.equal(request.predecessor, "0.6.8")
|
|
15
15
|
assert.equal(request.goal?.version, "1.3.49")
|
|
16
16
|
assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
|
|
17
17
|
return request
|
|
@@ -47,6 +47,67 @@ async function test(name, run, extra) {
|
|
|
47
47
|
finally { await f.close() }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
await test("explicit checklist stop prevents the next V2 model turn", async (f) => {
|
|
51
|
+
await writeFile(path.join(f.directory, "TASK.md"), "- [ ] implement\n- [x] setup\n")
|
|
52
|
+
const added = await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3")
|
|
53
|
+
assert.equal(added.accepted, true)
|
|
54
|
+
await f.wake()
|
|
55
|
+
assert.equal(f.prompts.length, 1)
|
|
56
|
+
await writeFile(path.join(f.directory, "TASK.md"), "- [x] implement\n- [x] setup\n")
|
|
57
|
+
await f.complete()
|
|
58
|
+
const job = (await f.state()).jobs[0]
|
|
59
|
+
assert.equal(f.prompts.length, 1, "no redundant model prompt after host checks TASK.md")
|
|
60
|
+
assert.equal(job.enabled, false)
|
|
61
|
+
assert.equal(job.paused, true)
|
|
62
|
+
assert.equal(job.pauseReason, "checklist-complete")
|
|
63
|
+
assert.deepEqual(job.checklistStatus, { status: "complete", total: 2, pending: 0 })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await test("explicit checklist refuses missing project file instead of spending tokens", async (f) => {
|
|
67
|
+
assert.equal((await f.add("0s --progress-file MISSING.md --until-checklist-complete implement")).accepted, true)
|
|
68
|
+
await f.wake()
|
|
69
|
+
const job = (await f.state()).jobs[0]
|
|
70
|
+
assert.equal(f.prompts.length, 0)
|
|
71
|
+
assert.equal(job.paused, true)
|
|
72
|
+
assert.equal(job.pauseReason, "checklist-unavailable")
|
|
73
|
+
assert.equal(job.lastChecklistError, "file-missing")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await test("an already-complete checklist still requires host verification when requested", async (f) => {
|
|
77
|
+
await writeFile(path.join(f.directory, "TASK.md"), "- [x] done\n")
|
|
78
|
+
await f.add('0s --progress-file TASK.md --until-checklist-complete --verify "npm test" check work --max-runs 2')
|
|
79
|
+
await f.wake()
|
|
80
|
+
assert.equal(f.prompts.length, 1, "unobserved verify must not pass by default")
|
|
81
|
+
await f.complete()
|
|
82
|
+
const job = (await f.state()).jobs[0]
|
|
83
|
+
assert.equal(f.hooks.length, 1)
|
|
84
|
+
assert.equal(f.hooks[0], "npm test")
|
|
85
|
+
assert.equal(f.prompts.length, 1, "observed verify pass stops before another turn")
|
|
86
|
+
assert.equal(job.enabled, false)
|
|
87
|
+
assert.equal(job.pauseReason, "checklist-complete")
|
|
88
|
+
assert.equal(job.lastVerifyCode, 0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
await test("progress-file without opt-in still follows existing finite or infinite semantics", async (f) => {
|
|
92
|
+
await writeFile(path.join(f.directory, "TASK.md"), "- [x] all done\n")
|
|
93
|
+
await f.add("0s --progress-file TASK.md task --max-runs 1")
|
|
94
|
+
await f.wake()
|
|
95
|
+
assert.equal(f.prompts.length, 1)
|
|
96
|
+
assert.equal((await f.state()).jobs[0].untilChecklistComplete, false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await test("native checklist flag requires explicit progress file and status reveals stop controls", async (f) => {
|
|
100
|
+
const rejected = await f.add("0s --until-checklist-complete implement")
|
|
101
|
+
assert.equal(rejected.accepted, false)
|
|
102
|
+
assert.match(rejected.error, /--progress-file/)
|
|
103
|
+
await writeFile(path.join(f.directory, "TASK.md"), "- [ ] todo\n")
|
|
104
|
+
await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3")
|
|
105
|
+
const status = await f.control("loop-status")
|
|
106
|
+
assert.match(status.status.text, /checklist-stop:TASK\.md/)
|
|
107
|
+
assert.match(status.status.text, /\/loop-stop/)
|
|
108
|
+
assert.match(status.status.text, /\/loop-clear/)
|
|
109
|
+
})
|
|
110
|
+
|
|
50
111
|
await test("one durable prompt under concurrent idle notifications", async (f) => {
|
|
51
112
|
assert.equal((await f.add("0s keep working --max-runs 2")).accepted, true)
|
|
52
113
|
await Promise.all(Array.from({ length: 12 }, () => f.wake()))
|
|
@@ -224,7 +224,8 @@ function commandNames(payload) {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
async function main() {
|
|
227
|
-
|
|
227
|
+
const supported = process.platform === "linux" || (process.platform === "darwin" && process.env.OPENCODE2_MACOS_NATIVE_CANARY === "1")
|
|
228
|
+
assert.ok(supported, "real OpenCode 2 canary supports Linux or explicitly opted-in macOS CI only")
|
|
228
229
|
|
|
229
230
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
|
|
230
231
|
const home = path.join(workspace, ".home")
|
|
@@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url"
|
|
|
8
8
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
9
9
|
const npmCLI = process.env.npm_execpath
|
|
10
10
|
assert.ok(npmCLI, "run this through npm run package:smoke")
|
|
11
|
-
function run(command, args, cwd) {
|
|
11
|
+
function run(command, args, cwd, timeout = 180_000) {
|
|
12
12
|
const result = spawnSync(command, args, {
|
|
13
|
-
cwd, encoding: "utf8", timeout
|
|
13
|
+
cwd, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024,
|
|
14
14
|
windowsHide: true,
|
|
15
15
|
env: { ...process.env, npm_config_fetch_retries: "1", npm_config_fetch_timeout: "90000" },
|
|
16
16
|
})
|
|
@@ -30,22 +30,54 @@ try {
|
|
|
30
30
|
await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
|
|
31
31
|
const packedTarball = path.join(temp, packed[0].filename)
|
|
32
32
|
const installArgs = ["install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund"]
|
|
33
|
+
const strictInstall = process.env.OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL === "1"
|
|
34
|
+
// A cache-only npm install should finish quickly. On Windows, a cache miss
|
|
35
|
+
// sometimes blocks inside npm for the entire 180-second subprocess deadline.
|
|
36
|
+
// Bound the best-effort offline attempt; strict opt-in keeps the full gate.
|
|
37
|
+
const offlineTimeout = process.platform === "win32" && !strictInstall ? 30_000 : 180_000
|
|
38
|
+
async function strictCleanOnlineInstall() {
|
|
39
|
+
// Busy Windows runners can stall fetching npm metadata even after npm ci.
|
|
40
|
+
// Retry once on subprocess ETIMEDOUT using a *new* empty consumer.
|
|
41
|
+
// This path never accepts the tar archive or CI-installed dependency tree
|
|
42
|
+
// as a substitute for a real npm install.
|
|
43
|
+
const maxAttempts = process.platform === "win32" ? 2 : 1
|
|
44
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
const timeoutMs = process.platform === "win32" && attempt === 2 ? 270_000 : 180_000
|
|
47
|
+
run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer, timeoutMs)
|
|
48
|
+
console.log("Strict clean npm tarball consumer install PASS")
|
|
49
|
+
return
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (attempt >= maxAttempts || error?.cause?.code !== "ETIMEDOUT") throw error
|
|
52
|
+
console.warn("Strict Windows npm install timed out; retrying once from a fresh empty consumer, not a fallback")
|
|
53
|
+
await rm(consumer, { recursive: true, force: true, maxRetries: 8, retryDelay: 150 })
|
|
54
|
+
await mkdir(consumer, { recursive: true })
|
|
55
|
+
await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
33
59
|
try {
|
|
34
60
|
// npm ci populated this job's npm cache from the lockfile. A cache-only
|
|
35
61
|
// install remains an exact, clean npm consumer when all metadata is present.
|
|
36
|
-
run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer)
|
|
62
|
+
run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer, offlineTimeout)
|
|
37
63
|
console.log("Clean tarball npm install from local cache PASS")
|
|
38
64
|
} catch (error) {
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
const cacheMissing = /ENOTCACHED|cache mode is .only-if-cached./i.test(String(error))
|
|
66
|
+
const windowsOfflineTimeout = process.platform === "win32" && !strictInstall && error?.cause?.code === "ETIMEDOUT"
|
|
67
|
+
if (!cacheMissing && !windowsOfflineTimeout) throw error
|
|
68
|
+
if (process.platform !== "win32" || strictInstall) {
|
|
41
69
|
console.warn("Offline cache incomplete: performing strict bounded online npm consumer install")
|
|
42
|
-
|
|
70
|
+
if (strictInstall) await strictCleanOnlineInstall()
|
|
71
|
+
else run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
|
|
43
72
|
} else {
|
|
44
73
|
// GitHub Windows runners sporadically hang for minutes while npm fetches
|
|
45
74
|
// transient dependency packuments. We still check the EXACT production
|
|
46
75
|
// archive and isolated V2 imports on Windows, using the already-verified
|
|
47
76
|
// npm-ci dependency tree. Linux continues to gate a real clean npm install.
|
|
48
77
|
// This is intentionally NOT labelled a successful Windows npm install.
|
|
78
|
+
// An interrupted npm process may have left a partial consumer tree.
|
|
79
|
+
// Never combine that unverified tree with the deterministic archive test.
|
|
80
|
+
await rm(path.join(consumer, "node_modules"), { recursive: true, force: true, maxRetries: 8, retryDelay: 150 })
|
|
49
81
|
const packageRoot = path.join(consumer, "node_modules", "@bybrawe", "opencode-loop")
|
|
50
82
|
await mkdir(packageRoot, { recursive: true })
|
|
51
83
|
run("tar", ["-xzf", packedTarball, "-C", packageRoot, "--strip-components=1"], temp)
|
|
@@ -56,7 +88,9 @@ try {
|
|
|
56
88
|
path.join(scope, "plugin"),
|
|
57
89
|
"junction",
|
|
58
90
|
)
|
|
59
|
-
console.warn(
|
|
91
|
+
console.warn(windowsOfflineTimeout
|
|
92
|
+
? "Windows offline npm install exceeded 30s; checking the production archive with pinned CI dependencies (NOT a clean npm install)"
|
|
93
|
+
: "Windows npm cache lacks package metadata; checking the production archive with pinned CI dependencies (NOT a clean npm install)")
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
96
|
console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
|
|
@@ -20,6 +20,7 @@ assert.match(workflow, /needs:\s*\[native-regressions, real-native-pair\]/)
|
|
|
20
20
|
assert.match(workflow, /node scripts\/native-release\.mjs ci/)
|
|
21
21
|
assert.match(workflow, /git diff --exit-code -- src\/index.js src\/server.js src\/native.js/)
|
|
22
22
|
assert.match(workflow, /npm run package:smoke/)
|
|
23
|
+
assert.match(workflow, /OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL:\s*\x271\x27/, "Windows/Linux npm release gate must require a strict clean consumer install, never a tarball fallback")
|
|
23
24
|
assert.match(workflow, /windows-latest/)
|
|
24
25
|
assert.match(workflow, /Decide whether npm publish is needed/)
|
|
25
26
|
assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
|
|
@@ -48,14 +49,14 @@ assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$att
|
|
|
48
49
|
assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
|
|
49
50
|
|
|
50
51
|
const sha = "a".repeat(40)
|
|
51
|
-
const request = { name: "@bybrawe/opencode-loop", version: "0.6.
|
|
52
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.9", predecessor: "0.6.8", goal: { version: "1.3.49", sha: "b".repeat(40) } }
|
|
52
53
|
const pkg = { name: request.name, version: request.version }
|
|
53
54
|
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
54
55
|
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
55
56
|
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
56
57
|
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
57
58
|
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
|
|
58
|
-
assert.equal(releaseDecision(404, null, request, sha, "0.6.
|
|
59
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.6.8"), true)
|
|
59
60
|
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
|
|
60
61
|
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
61
62
|
const manifest = { ...pkg, gitHead: sha }
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises"
|
|
3
5
|
import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js"
|
|
6
|
+
import { inspectTaskChecklist } from "../src/source/runtime/checklist-completion.js"
|
|
4
7
|
|
|
5
8
|
assert.throws(() => createRunAdmissionRuntime({}), /untilReached/)
|
|
6
9
|
assert.throws(() => createRunAdmissionRuntime({ untilReached: async () => false }), /scheduleDueWork/)
|
|
@@ -130,4 +133,48 @@ assert.equal(admitted.paused, undefined)
|
|
|
130
133
|
assert.equal(admitted.runNowRequestedAt, clock)
|
|
131
134
|
assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 0)
|
|
132
135
|
|
|
136
|
+
const project = await mkdtemp(path.join(os.tmpdir(), "loop-admission-checklist-"))
|
|
137
|
+
try {
|
|
138
|
+
await writeFile(path.join(project, "TASK.md"), "- [ ] code\n- [x] plan\n")
|
|
139
|
+
const checklistJob = { id: "checklist-job", enabled: true, progressFile: "TASK.md", untilChecklistComplete: true }
|
|
140
|
+
const checklistState = { jobs: [checklistJob] }
|
|
141
|
+
assert.equal((await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob)).admitted, true)
|
|
142
|
+
assert.deepEqual(checklistJob.checklistStatus, { status: "pending", total: 2, pending: 1 })
|
|
143
|
+
|
|
144
|
+
await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n")
|
|
145
|
+
assert.deepEqual(await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob),
|
|
146
|
+
{ admitted: false, reason: "checklist_complete" })
|
|
147
|
+
assert.equal(checklistState.jobs.length, 0)
|
|
148
|
+
assert.equal(notifications.at(-1)[2], "checklist_complete")
|
|
149
|
+
|
|
150
|
+
const verifyJob = { id: "verify-checklist", enabled: true, progressFile: "TASK.md",
|
|
151
|
+
untilChecklistComplete: true, verifyCommand: "npm test" }
|
|
152
|
+
const verifyState = { jobs: [verifyJob] }
|
|
153
|
+
assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
|
|
154
|
+
"unchecked host verification cannot imply completion")
|
|
155
|
+
verifyJob.lastVerifyCode = 1
|
|
156
|
+
verifyJob.lastVerifyAt = clock
|
|
157
|
+
assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
|
|
158
|
+
"failed host verification cannot imply completion")
|
|
159
|
+
verifyJob.lastVerifyCode = 0
|
|
160
|
+
assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
|
|
161
|
+
"successful verify without recorded checklist fingerprint is not authoritative")
|
|
162
|
+
verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest
|
|
163
|
+
await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n- [x] later task\n")
|
|
164
|
+
assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
|
|
165
|
+
"verification for older checklist bytes cannot certify newly changed work")
|
|
166
|
+
verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest
|
|
167
|
+
assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).reason, "checklist_complete")
|
|
168
|
+
|
|
169
|
+
const missingJob = { id: "missing-checklist", enabled: true, progressFile: "MISSING.md", untilChecklistComplete: true }
|
|
170
|
+
const missingState = { jobs: [missingJob] }
|
|
171
|
+
const missing = await runtime.admitJob(project, {}, "session-missing", missingState, missingJob)
|
|
172
|
+
assert.deepEqual(missing, { admitted: false, reason: "checklist_unavailable" })
|
|
173
|
+
assert.equal(missingJob.paused, true)
|
|
174
|
+
assert.equal(missingJob.lastChecklistError, "file-missing")
|
|
175
|
+
assert.equal(missingState.jobs.length, 1, "preserve job for explicit recovery")
|
|
176
|
+
} finally {
|
|
177
|
+
await rm(project, { force: true, recursive: true })
|
|
178
|
+
}
|
|
179
|
+
|
|
133
180
|
console.log("run admission runtime tests passed")
|
package/src/index.js
CHANGED
|
@@ -141,6 +141,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
141
141
|
until: defaults.until,
|
|
142
142
|
stopFile: defaults.stopFile,
|
|
143
143
|
progressFile: defaults.progressFile,
|
|
144
|
+
untilChecklistComplete: defaults.untilChecklistComplete ?? false,
|
|
144
145
|
promptFile: defaults.promptFile,
|
|
145
146
|
includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
|
|
146
147
|
watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
|
|
@@ -221,6 +222,9 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
221
222
|
[found, rest] = takeFlag(rest, "--pause-on-verify-fail");
|
|
222
223
|
if (found)
|
|
223
224
|
job.pauseOnVerifyFail = true;
|
|
225
|
+
[found, rest] = takeFlag(rest, "--until-checklist-complete");
|
|
226
|
+
if (found)
|
|
227
|
+
job.untilChecklistComplete = true;
|
|
224
228
|
[found, rest] = takeFlag(rest, "--dry-run");
|
|
225
229
|
if (found)
|
|
226
230
|
job.dryRun = true;
|
|
@@ -369,6 +373,8 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
369
373
|
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS;
|
|
370
374
|
}
|
|
371
375
|
job.lastRunAt = job.immediate ? 0 : now();
|
|
376
|
+
if (job.untilChecklistComplete && !job.progressFile)
|
|
377
|
+
return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" };
|
|
372
378
|
if (!job.action && !job.promptFile && !job.goalFile)
|
|
373
379
|
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" };
|
|
374
380
|
return { ok: true, job };
|
|
@@ -1445,6 +1451,8 @@ function decoratePrompt(job) {
|
|
|
1445
1451
|
additions.push(continuation);
|
|
1446
1452
|
if (job.progressFile)
|
|
1447
1453
|
additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
|
|
1454
|
+
if (job.untilChecklistComplete)
|
|
1455
|
+
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.");
|
|
1448
1456
|
if (job.lastVerifyFailure)
|
|
1449
1457
|
additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
|
|
1450
1458
|
if (job.askNever)
|
|
@@ -3625,6 +3633,125 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
|
|
|
3625
3633
|
return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3626
3634
|
}
|
|
3627
3635
|
|
|
3636
|
+
// src/source/runtime/checklist-completion.js
|
|
3637
|
+
import { promises as fs8, constants } from "fs";
|
|
3638
|
+
import path9 from "path";
|
|
3639
|
+
import { createHash } from "crypto";
|
|
3640
|
+
var MAX_CHECKLIST_BYTES = 256000;
|
|
3641
|
+
var EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"]);
|
|
3642
|
+
function unavailable(reason) {
|
|
3643
|
+
return { status: "unavailable", reason, total: 0, pending: 0 };
|
|
3644
|
+
}
|
|
3645
|
+
function summarizeTaskChecklist(markdown) {
|
|
3646
|
+
let total = 0;
|
|
3647
|
+
let pending = 0;
|
|
3648
|
+
let fence = null;
|
|
3649
|
+
let comment = false;
|
|
3650
|
+
for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
|
3651
|
+
let line = "";
|
|
3652
|
+
let offset = 0;
|
|
3653
|
+
while (offset < original.length) {
|
|
3654
|
+
if (comment) {
|
|
3655
|
+
const end = original.indexOf("-->", offset);
|
|
3656
|
+
if (end < 0)
|
|
3657
|
+
break;
|
|
3658
|
+
comment = false;
|
|
3659
|
+
offset = end + 3;
|
|
3660
|
+
} else {
|
|
3661
|
+
const start = original.indexOf("<!--", offset);
|
|
3662
|
+
if (start < 0) {
|
|
3663
|
+
line += original.slice(offset);
|
|
3664
|
+
break;
|
|
3665
|
+
}
|
|
3666
|
+
line += original.slice(offset, start);
|
|
3667
|
+
comment = true;
|
|
3668
|
+
offset = start + 4;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
const trimmed = line.trimStart();
|
|
3672
|
+
const indentation = line.length - trimmed.length;
|
|
3673
|
+
const marker = trimmed.charAt(0);
|
|
3674
|
+
let markerSize = 0;
|
|
3675
|
+
if (marker === "~" || marker.charCodeAt(0) === 96) {
|
|
3676
|
+
while (trimmed.charAt(markerSize) === marker)
|
|
3677
|
+
markerSize++;
|
|
3678
|
+
}
|
|
3679
|
+
if (fence) {
|
|
3680
|
+
if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim())
|
|
3681
|
+
fence = null;
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3684
|
+
if (indentation <= 3 && markerSize >= 3) {
|
|
3685
|
+
fence = { marker, length: markerSize };
|
|
3686
|
+
continue;
|
|
3687
|
+
}
|
|
3688
|
+
if (/^\s*>/.test(line))
|
|
3689
|
+
continue;
|
|
3690
|
+
const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/);
|
|
3691
|
+
if (!match)
|
|
3692
|
+
continue;
|
|
3693
|
+
total++;
|
|
3694
|
+
if (match[1] === " ")
|
|
3695
|
+
pending++;
|
|
3696
|
+
}
|
|
3697
|
+
return {
|
|
3698
|
+
status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
|
|
3699
|
+
reason: total === 0 ? "no-task-checkboxes" : "",
|
|
3700
|
+
total,
|
|
3701
|
+
pending
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
async function inspectTaskChecklist(directory, progressFile) {
|
|
3705
|
+
const name = String(progressFile || "").trim();
|
|
3706
|
+
if (!name || name.includes("\x00"))
|
|
3707
|
+
return unavailable("missing-progress-file");
|
|
3708
|
+
if (path9.isAbsolute(name) || path9.win32.isAbsolute(name))
|
|
3709
|
+
return unavailable("absolute-path");
|
|
3710
|
+
const root = path9.resolve(directory);
|
|
3711
|
+
const target = path9.resolve(root, name);
|
|
3712
|
+
const relative = path9.relative(root, target);
|
|
3713
|
+
if (!relative || relative === ".." || relative.startsWith(".." + path9.sep) || path9.isAbsolute(relative)) {
|
|
3714
|
+
return unavailable("outside-project");
|
|
3715
|
+
}
|
|
3716
|
+
const parts = relative.split(path9.sep);
|
|
3717
|
+
if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase())))
|
|
3718
|
+
return unavailable("control-plane-path");
|
|
3719
|
+
try {
|
|
3720
|
+
const rootInfo = await fs8.lstat(root);
|
|
3721
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
3722
|
+
return unavailable("unsafe-project-root");
|
|
3723
|
+
let current = root;
|
|
3724
|
+
for (let index = 0;index < parts.length; index++) {
|
|
3725
|
+
current = path9.join(current, parts[index]);
|
|
3726
|
+
const info = await fs8.lstat(current);
|
|
3727
|
+
if (info.isSymbolicLink())
|
|
3728
|
+
return unavailable("symlink-path");
|
|
3729
|
+
if (index < parts.length - 1 && !info.isDirectory())
|
|
3730
|
+
return unavailable("invalid-parent");
|
|
3731
|
+
if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
|
|
3732
|
+
return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
const handle = await fs8.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
3736
|
+
try {
|
|
3737
|
+
const info = await handle.stat();
|
|
3738
|
+
if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
|
|
3739
|
+
return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
|
|
3740
|
+
}
|
|
3741
|
+
const content = await handle.readFile({ encoding: "utf8" });
|
|
3742
|
+
const after = await fs8.lstat(target);
|
|
3743
|
+
if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
|
|
3744
|
+
return unavailable("file-changed-during-read");
|
|
3745
|
+
}
|
|
3746
|
+
return { ...summarizeTaskChecklist(content), digest: createHash("sha256").update(content).digest("hex") };
|
|
3747
|
+
} finally {
|
|
3748
|
+
await handle.close();
|
|
3749
|
+
}
|
|
3750
|
+
} catch (error) {
|
|
3751
|
+
return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable");
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3628
3755
|
// src/source/runtime/run-finalization.js
|
|
3629
3756
|
function requireFunction8(value, label) {
|
|
3630
3757
|
if (typeof value !== "function")
|
|
@@ -3659,8 +3786,16 @@ function createRunFinalizationRuntime(options = {}) {
|
|
|
3659
3786
|
if (verify.code === 0) {
|
|
3660
3787
|
job.failureCount = 0;
|
|
3661
3788
|
job.lastVerifyFailure = "";
|
|
3789
|
+
if (job.untilChecklistComplete) {
|
|
3790
|
+
const inspected = await inspectTaskChecklist(directory, job.progressFile);
|
|
3791
|
+
if (inspected.status !== "unavailable")
|
|
3792
|
+
job.lastVerifyChecklistDigest = inspected.digest;
|
|
3793
|
+
else
|
|
3794
|
+
delete job.lastVerifyChecklistDigest;
|
|
3795
|
+
}
|
|
3662
3796
|
await toast2(client, "Loop verify passed: " + job.verifyCommand, "success");
|
|
3663
3797
|
} else {
|
|
3798
|
+
delete job.lastVerifyChecklistDigest;
|
|
3664
3799
|
job.failureCount = (job.failureCount || 0) + 1;
|
|
3665
3800
|
job.lastVerifyFailure = (job.verifyCommand + `
|
|
3666
3801
|
exit=` + verify.code + `
|
|
@@ -3748,7 +3883,7 @@ exit=` + postrun.code + `
|
|
|
3748
3883
|
}
|
|
3749
3884
|
|
|
3750
3885
|
// src/source/runtime/run-admission.js
|
|
3751
|
-
import
|
|
3886
|
+
import path10 from "path";
|
|
3752
3887
|
function requireFunction9(value, label) {
|
|
3753
3888
|
if (typeof value !== "function")
|
|
3754
3889
|
throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
|
|
@@ -3786,12 +3921,30 @@ function createRunAdmissionRuntime(options = {}) {
|
|
|
3786
3921
|
if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
3787
3922
|
return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
|
|
3788
3923
|
}
|
|
3789
|
-
if (job.stopFile && await pathExists2(
|
|
3924
|
+
if (job.stopFile && await pathExists2(path10.resolve(directory, job.stopFile))) {
|
|
3790
3925
|
return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
|
|
3791
3926
|
}
|
|
3792
3927
|
if (await untilReached(directory, job)) {
|
|
3793
3928
|
return await stopAndRemove(directory, client, sessionID, state, job, "until_reached", `Loop stopped by --until: ${job.until}`);
|
|
3794
3929
|
}
|
|
3930
|
+
if (job.untilChecklistComplete) {
|
|
3931
|
+
const checklist = await inspectTaskChecklist(directory, job.progressFile);
|
|
3932
|
+
job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending };
|
|
3933
|
+
if (checklist.status === "unavailable") {
|
|
3934
|
+
job.paused = true;
|
|
3935
|
+
job.pauseReason = "checklist-unavailable";
|
|
3936
|
+
job.lastChecklistError = checklist.reason;
|
|
3937
|
+
await writeState2(directory, sessionID, state);
|
|
3938
|
+
await notifyJob2(directory, job, "checklist_unavailable");
|
|
3939
|
+
await toast2(client, "Loop paused: checklist " + job.progressFile + " could not be validated (" + checklist.reason + ").", "warning");
|
|
3940
|
+
await reschedule(directory, client, sessionID);
|
|
3941
|
+
return { admitted: false, reason: "checklist_unavailable" };
|
|
3942
|
+
}
|
|
3943
|
+
delete job.lastChecklistError;
|
|
3944
|
+
if (checklist.status === "complete" && (!job.verifyCommand || job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest)) {
|
|
3945
|
+
return await stopAndRemove(directory, client, sessionID, state, job, "checklist_complete", "Loop stopped: " + job.progressFile + " checklist completed.", "checklist-complete");
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3795
3948
|
if (job.preflightCommand) {
|
|
3796
3949
|
if (job.safe && dangerousShell2(job.preflightCommand)) {
|
|
3797
3950
|
if (runNowRequested)
|