@bybrawe/opencode-loop 0.6.7 → 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/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 };
@@ -459,6 +465,18 @@ async function readStateProcessOwner(file) {
459
465
  await delay(10 * (attempt + 1));
460
466
  }
461
467
  }
468
+ if (raw.length === 0) {
469
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
470
+ await delay(10 * (attempt + 1));
471
+ try {
472
+ raw = await fs.readFile(file, "utf8");
473
+ } catch (error) {
474
+ if (error?.code === "ENOENT")
475
+ return null;
476
+ throw error;
477
+ }
478
+ }
479
+ }
462
480
  let owner;
463
481
  try {
464
482
  owner = JSON.parse(raw);
@@ -499,6 +517,26 @@ async function claimStateProcessLock(candidate, canonical) {
499
517
  } catch (error) {
500
518
  if (error?.code === "EEXIST")
501
519
  return false;
520
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
521
+ const contents = await fs.readFile(candidate);
522
+ let handle;
523
+ try {
524
+ handle = await fs.open(canonical, "wx", 384);
525
+ } catch (failure) {
526
+ if (failure?.code === "EEXIST")
527
+ return false;
528
+ throw failure;
529
+ }
530
+ try {
531
+ await handle.writeFile(contents);
532
+ } catch (failure) {
533
+ await handle.close().catch(() => {});
534
+ await removeStateProcessFile(canonical).catch(() => {});
535
+ throw failure;
536
+ }
537
+ await handle.close();
538
+ return true;
539
+ }
502
540
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
503
541
  throw error;
504
542
  await delay(10 * (attempt + 1));
@@ -1413,6 +1451,8 @@ function decoratePrompt(job) {
1413
1451
  additions.push(continuation);
1414
1452
  if (job.progressFile)
1415
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.");
1416
1456
  if (job.lastVerifyFailure)
1417
1457
  additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
1418
1458
  if (job.askNever)
@@ -3593,6 +3633,125 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3593
3633
  return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
3594
3634
  }
3595
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
+
3596
3755
  // src/source/runtime/run-finalization.js
3597
3756
  function requireFunction8(value, label) {
3598
3757
  if (typeof value !== "function")
@@ -3627,8 +3786,16 @@ function createRunFinalizationRuntime(options = {}) {
3627
3786
  if (verify.code === 0) {
3628
3787
  job.failureCount = 0;
3629
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
+ }
3630
3796
  await toast2(client, "Loop verify passed: " + job.verifyCommand, "success");
3631
3797
  } else {
3798
+ delete job.lastVerifyChecklistDigest;
3632
3799
  job.failureCount = (job.failureCount || 0) + 1;
3633
3800
  job.lastVerifyFailure = (job.verifyCommand + `
3634
3801
  exit=` + verify.code + `
@@ -3716,7 +3883,7 @@ exit=` + postrun.code + `
3716
3883
  }
3717
3884
 
3718
3885
  // src/source/runtime/run-admission.js
3719
- import path9 from "path";
3886
+ import path10 from "path";
3720
3887
  function requireFunction9(value, label) {
3721
3888
  if (typeof value !== "function")
3722
3889
  throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
@@ -3754,12 +3921,30 @@ function createRunAdmissionRuntime(options = {}) {
3754
3921
  if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
3755
3922
  return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
3756
3923
  }
3757
- if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) {
3924
+ if (job.stopFile && await pathExists2(path10.resolve(directory, job.stopFile))) {
3758
3925
  return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
3759
3926
  }
3760
3927
  if (await untilReached(directory, job)) {
3761
3928
  return await stopAndRemove(directory, client, sessionID, state, job, "until_reached", `Loop stopped by --until: ${job.until}`);
3762
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
+ }
3763
3948
  if (job.preflightCommand) {
3764
3949
  if (job.safe && dangerousShell2(job.preflightCommand)) {
3765
3950
  if (runNowRequested)
package/src/native.js CHANGED
@@ -149,6 +149,7 @@ function parseLoopArgs(raw, defaults = {}) {
149
149
  until: defaults.until,
150
150
  stopFile: defaults.stopFile,
151
151
  progressFile: defaults.progressFile,
152
+ untilChecklistComplete: defaults.untilChecklistComplete ?? false,
152
153
  promptFile: defaults.promptFile,
153
154
  includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
154
155
  watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
@@ -229,6 +230,9 @@ function parseLoopArgs(raw, defaults = {}) {
229
230
  [found, rest] = takeFlag(rest, "--pause-on-verify-fail");
230
231
  if (found)
231
232
  job.pauseOnVerifyFail = true;
233
+ [found, rest] = takeFlag(rest, "--until-checklist-complete");
234
+ if (found)
235
+ job.untilChecklistComplete = true;
232
236
  [found, rest] = takeFlag(rest, "--dry-run");
233
237
  if (found)
234
238
  job.dryRun = true;
@@ -377,6 +381,8 @@ function parseLoopArgs(raw, defaults = {}) {
377
381
  job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS;
378
382
  }
379
383
  job.lastRunAt = job.immediate ? 0 : now();
384
+ if (job.untilChecklistComplete && !job.progressFile)
385
+ return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" };
380
386
  if (!job.action && !job.promptFile && !job.goalFile)
381
387
  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" };
382
388
  return { ok: true, job };
@@ -490,6 +496,8 @@ function decoratePrompt(job) {
490
496
  additions.push(continuation);
491
497
  if (job.progressFile)
492
498
  additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
499
+ if (job.untilChecklistComplete)
500
+ 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.");
493
501
  if (job.lastVerifyFailure)
494
502
  additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
495
503
  if (job.askNever)
@@ -612,6 +620,18 @@ async function readStateProcessOwner(file) {
612
620
  await delay(10 * (attempt + 1));
613
621
  }
614
622
  }
623
+ if (raw.length === 0) {
624
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
625
+ await delay(10 * (attempt + 1));
626
+ try {
627
+ raw = await fs.readFile(file, "utf8");
628
+ } catch (error) {
629
+ if (error?.code === "ENOENT")
630
+ return null;
631
+ throw error;
632
+ }
633
+ }
634
+ }
615
635
  let owner;
616
636
  try {
617
637
  owner = JSON.parse(raw);
@@ -652,6 +672,26 @@ async function claimStateProcessLock(candidate, canonical) {
652
672
  } catch (error) {
653
673
  if (error?.code === "EEXIST")
654
674
  return false;
675
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
676
+ const contents = await fs.readFile(candidate);
677
+ let handle;
678
+ try {
679
+ handle = await fs.open(canonical, "wx", 384);
680
+ } catch (failure) {
681
+ if (failure?.code === "EEXIST")
682
+ return false;
683
+ throw failure;
684
+ }
685
+ try {
686
+ await handle.writeFile(contents);
687
+ } catch (failure) {
688
+ await handle.close().catch(() => {});
689
+ await removeStateProcessFile(canonical).catch(() => {});
690
+ throw failure;
691
+ }
692
+ await handle.close();
693
+ return true;
694
+ }
655
695
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
656
696
  throw error;
657
697
  await delay(10 * (attempt + 1));
@@ -1325,7 +1365,7 @@ var init_job_workspace = __esm(() => {
1325
1365
  });
1326
1366
 
1327
1367
  // src/source/opencode2/native-plugin.js
1328
- import path9 from "path";
1368
+ import path10 from "path";
1329
1369
 
1330
1370
  // src/source/opencode2/session-location.js
1331
1371
  import path from "path";
@@ -1742,7 +1782,11 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1742
1782
  job.askNever ? "ask-never" : undefined,
1743
1783
  job.noOverlap ? "no-overlap" : undefined,
1744
1784
  job.checkpointOnly ? "checkpoint-only" : undefined,
1745
- job.gitCheckpoint ? "git-checkpoint" : undefined
1785
+ job.gitCheckpoint ? "git-checkpoint" : undefined,
1786
+ job.untilChecklistComplete ? "checklist-stop:" + (job.progressFile || "missing-file") : undefined,
1787
+ job.pauseReason ? "reason:" + job.pauseReason : undefined,
1788
+ job.lastChecklistError ? "checklist-error:" + job.lastChecklistError : undefined,
1789
+ !job.maxRuns && !job.maxRuntimeMs && !job.until && !job.stopFile && !job.untilChecklistComplete ? "unlimited" : undefined
1746
1790
  ].filter(Boolean).join(",");
1747
1791
  return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`;
1748
1792
  }) : ["No active loop jobs."];
@@ -1750,7 +1794,8 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1750
1794
  jobs,
1751
1795
  text: `OpenCode loop status:
1752
1796
  ${lines.join(`
1753
- `)}`
1797
+ `)}${jobs.length ? `
1798
+ Host controls: run /loop-stop <name-or-id> or /loop-clear to stop jobs; asking the model to print those commands does not execute them.` : ""}`
1754
1799
  });
1755
1800
  }
1756
1801
 
@@ -1758,7 +1803,128 @@ ${lines.join(`
1758
1803
  init_process();
1759
1804
  init_job_workspace();
1760
1805
  import { stat } from "fs/promises";
1806
+ import path8 from "path";
1807
+
1808
+ // src/source/runtime/checklist-completion.js
1809
+ import { promises as fs4, constants } from "fs";
1761
1810
  import path7 from "path";
1811
+ import { createHash as createHash2 } from "crypto";
1812
+ var MAX_CHECKLIST_BYTES = 256000;
1813
+ var EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"]);
1814
+ function unavailable(reason) {
1815
+ return { status: "unavailable", reason, total: 0, pending: 0 };
1816
+ }
1817
+ function summarizeTaskChecklist(markdown) {
1818
+ let total = 0;
1819
+ let pending = 0;
1820
+ let fence = null;
1821
+ let comment = false;
1822
+ for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
1823
+ let line = "";
1824
+ let offset = 0;
1825
+ while (offset < original.length) {
1826
+ if (comment) {
1827
+ const end = original.indexOf("-->", offset);
1828
+ if (end < 0)
1829
+ break;
1830
+ comment = false;
1831
+ offset = end + 3;
1832
+ } else {
1833
+ const start = original.indexOf("<!--", offset);
1834
+ if (start < 0) {
1835
+ line += original.slice(offset);
1836
+ break;
1837
+ }
1838
+ line += original.slice(offset, start);
1839
+ comment = true;
1840
+ offset = start + 4;
1841
+ }
1842
+ }
1843
+ const trimmed = line.trimStart();
1844
+ const indentation = line.length - trimmed.length;
1845
+ const marker = trimmed.charAt(0);
1846
+ let markerSize = 0;
1847
+ if (marker === "~" || marker.charCodeAt(0) === 96) {
1848
+ while (trimmed.charAt(markerSize) === marker)
1849
+ markerSize++;
1850
+ }
1851
+ if (fence) {
1852
+ if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim())
1853
+ fence = null;
1854
+ continue;
1855
+ }
1856
+ if (indentation <= 3 && markerSize >= 3) {
1857
+ fence = { marker, length: markerSize };
1858
+ continue;
1859
+ }
1860
+ if (/^\s*>/.test(line))
1861
+ continue;
1862
+ const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/);
1863
+ if (!match)
1864
+ continue;
1865
+ total++;
1866
+ if (match[1] === " ")
1867
+ pending++;
1868
+ }
1869
+ return {
1870
+ status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
1871
+ reason: total === 0 ? "no-task-checkboxes" : "",
1872
+ total,
1873
+ pending
1874
+ };
1875
+ }
1876
+ async function inspectTaskChecklist(directory, progressFile) {
1877
+ const name = String(progressFile || "").trim();
1878
+ if (!name || name.includes("\x00"))
1879
+ return unavailable("missing-progress-file");
1880
+ if (path7.isAbsolute(name) || path7.win32.isAbsolute(name))
1881
+ return unavailable("absolute-path");
1882
+ const root = path7.resolve(directory);
1883
+ const target = path7.resolve(root, name);
1884
+ const relative = path7.relative(root, target);
1885
+ if (!relative || relative === ".." || relative.startsWith(".." + path7.sep) || path7.isAbsolute(relative)) {
1886
+ return unavailable("outside-project");
1887
+ }
1888
+ const parts = relative.split(path7.sep);
1889
+ if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase())))
1890
+ return unavailable("control-plane-path");
1891
+ try {
1892
+ const rootInfo = await fs4.lstat(root);
1893
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
1894
+ return unavailable("unsafe-project-root");
1895
+ let current = root;
1896
+ for (let index = 0;index < parts.length; index++) {
1897
+ current = path7.join(current, parts[index]);
1898
+ const info = await fs4.lstat(current);
1899
+ if (info.isSymbolicLink())
1900
+ return unavailable("symlink-path");
1901
+ if (index < parts.length - 1 && !info.isDirectory())
1902
+ return unavailable("invalid-parent");
1903
+ if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
1904
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
1905
+ }
1906
+ }
1907
+ const handle = await fs4.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
1908
+ try {
1909
+ const info = await handle.stat();
1910
+ if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
1911
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
1912
+ }
1913
+ const content = await handle.readFile({ encoding: "utf8" });
1914
+ const after = await fs4.lstat(target);
1915
+ if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
1916
+ return unavailable("file-changed-during-read");
1917
+ }
1918
+ return { ...summarizeTaskChecklist(content), digest: createHash2("sha256").update(content).digest("hex") };
1919
+ } finally {
1920
+ await handle.close();
1921
+ }
1922
+ } catch (error) {
1923
+ return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable");
1924
+ }
1925
+ }
1926
+
1927
+ // src/source/opencode2/native-policy.js
1762
1928
  function createNativeJobPolicy(options = {}) {
1763
1929
  const now = options.now || Date.now;
1764
1930
  const run = options.runShellCommand || runShellCommand;
@@ -1792,7 +1958,7 @@ function createNativeJobPolicy(options = {}) {
1792
1958
  }
1793
1959
  if (job.stopFile) {
1794
1960
  try {
1795
- await stat(path7.resolve(scope.directory, job.stopFile));
1961
+ await stat(path8.resolve(scope.directory, job.stopFile));
1796
1962
  job.enabled = false;
1797
1963
  return pause(job, "stop-file");
1798
1964
  } catch (error) {
@@ -1804,6 +1970,20 @@ function createNativeJobPolicy(options = {}) {
1804
1970
  job.enabled = false;
1805
1971
  return pause(job, "until-reached");
1806
1972
  }
1973
+ if (job.untilChecklistComplete) {
1974
+ const checklist = await inspectTaskChecklist(scope.directory, job.progressFile);
1975
+ job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending };
1976
+ if (checklist.status === "unavailable") {
1977
+ job.lastChecklistError = checklist.reason;
1978
+ return pause(job, "checklist-unavailable");
1979
+ }
1980
+ delete job.lastChecklistError;
1981
+ if (checklist.status === "complete" && (!job.verifyCommand || job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest)) {
1982
+ job.enabled = false;
1983
+ job.checklistCompletedAt = now();
1984
+ return pause(job, "checklist-complete");
1985
+ }
1986
+ }
1807
1987
  return true;
1808
1988
  }
1809
1989
  async function prepare(scope, job, isCurrent = () => true) {
@@ -1836,12 +2016,20 @@ function createNativeJobPolicy(options = {}) {
1836
2016
  job.lastVerifyOutput = `${result.stdout || ""}
1837
2017
  ${result.stderr || ""}`.trim().slice(-8000);
1838
2018
  if (result.code !== 0) {
2019
+ delete job.lastVerifyChecklistDigest;
1839
2020
  job.failureCount = (job.failureCount || 0) + 1;
1840
2021
  if (result.blocked || job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures)
1841
2022
  pause(job, "verification-failed");
1842
2023
  return;
1843
2024
  }
1844
2025
  job.failureCount = 0;
2026
+ if (job.untilChecklistComplete) {
2027
+ const verified = await inspectTaskChecklist(scope.directory, job.progressFile);
2028
+ if (verified.status !== "unavailable")
2029
+ job.lastVerifyChecklistDigest = verified.digest;
2030
+ else
2031
+ delete job.lastVerifyChecklistDigest;
2032
+ }
1845
2033
  }
1846
2034
  if (!isCurrent())
1847
2035
  return;
@@ -1969,6 +2157,8 @@ function createNativeLoopRuntime(options = {}) {
1969
2157
  continue;
1970
2158
  if (job.watchPaths?.length || job.stopFile || job.until)
1971
2159
  delay = Math.min(delay, 1000);
2160
+ if (job.untilChecklistComplete)
2161
+ delay = Math.min(delay, 5000);
1972
2162
  const due = dueAt(job);
1973
2163
  if (due > now())
1974
2164
  delay = Math.min(delay, due - now());
@@ -3112,8 +3302,8 @@ init_process();
3112
3302
  init_state();
3113
3303
 
3114
3304
  // src/source/core/progress.js
3115
- import { promises as fs4 } from "fs";
3116
- import path8 from "path";
3305
+ import { promises as fs5 } from "fs";
3306
+ import path9 from "path";
3117
3307
  var DEFAULT_PROGRESS_MD = `# Progress
3118
3308
 
3119
3309
  ## Current Goal
@@ -3141,24 +3331,24 @@ Describe the current project goal here.
3141
3331
  - None.
3142
3332
  `;
3143
3333
  function inside(root, candidate) {
3144
- const relative = path8.relative(root, candidate);
3145
- return relative !== ".." && !relative.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relative);
3334
+ const relative = path9.relative(root, candidate);
3335
+ return relative !== ".." && !relative.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relative);
3146
3336
  }
3147
3337
  async function initializeProgressFile(directory, argumentsText = "") {
3148
- const root = await fs4.realpath(directory);
3338
+ const root = await fs5.realpath(directory);
3149
3339
  const target = String(argumentsText || "").trim() || "progress.md";
3150
- const file = path8.resolve(root, target);
3151
- const relative = path8.relative(root, file);
3340
+ const file = path9.resolve(root, target);
3341
+ const relative = path9.relative(root, file);
3152
3342
  if (!relative || !inside(root, file))
3153
3343
  throw new Error("Progress file must be inside the project");
3154
- if ([".git", ".opencode"].includes(relative.split(path8.sep)[0].toLowerCase())) {
3344
+ if ([".git", ".opencode"].includes(relative.split(path9.sep)[0].toLowerCase())) {
3155
3345
  throw new Error("Progress file cannot replace a control-plane path");
3156
3346
  }
3157
- const parent = await fs4.realpath(path8.dirname(file));
3347
+ const parent = await fs5.realpath(path9.dirname(file));
3158
3348
  if (!inside(root, parent))
3159
3349
  throw new Error("Progress file must remain inside the project; symlink escape refused");
3160
3350
  try {
3161
- await fs4.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3351
+ await fs5.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3162
3352
  return { created: true, file: relative };
3163
3353
  } catch (error) {
3164
3354
  if (error?.code === "EEXIST")
@@ -3173,6 +3363,7 @@ var HELP = `OpenCode Loop: native OpenCode 2 runtime
3173
3363
  /loop 5m <task> --verify "npm test" --pause-on-verify-fail
3174
3364
  /loop --watch progress.md <task>
3175
3365
  /loop 0s <task> --prompt-file instructions.md --include-file progress.md
3366
+ /loop 0s <task> --progress-file TASK.md --until-checklist-complete --verify "npm test"
3176
3367
  /loop 0s <task> --compact-every 3
3177
3368
  /loop 5m --shell npm test
3178
3369
  /loop 15m --compact /compact
@@ -3180,6 +3371,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
3180
3371
  /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
3181
3372
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
3182
3373
  Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
3374
+ --progress-file alone is context, not a stop condition. --until-checklist-complete is an explicit host-verified stop condition; missing/unsafe/no-task files pause for review, and --verify must have passed before stopping.
3375
+ Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it.
3183
3376
  Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
3184
3377
  Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
3185
3378
  An unfinished dedicated Goal reserves its session; Loop will not override it.
@@ -3286,7 +3479,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
3286
3479
  return { handled: true, accepted: true, ...output };
3287
3480
  }
3288
3481
  if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
3289
- const text = name === "loop-logs" ? (await readSmallTextFile(path9.join(stateDir(directory), "loop.log"), 2000000)).split(`
3482
+ const text = name === "loop-logs" ? (await readSmallTextFile(path10.join(stateDir(directory), "loop.log"), 2000000)).split(`
3290
3483
  `).slice(-60).join(`
3291
3484
  `) || "No Loop log entries." : name === "loop-doctor" ? `Native OpenCode Loop
3292
3485
  Host: ${ctx.app?.version || "unknown"}