@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/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)
@@ -1357,7 +1365,7 @@ var init_job_workspace = __esm(() => {
1357
1365
  });
1358
1366
 
1359
1367
  // src/source/opencode2/native-plugin.js
1360
- import path9 from "path";
1368
+ import path10 from "path";
1361
1369
 
1362
1370
  // src/source/opencode2/session-location.js
1363
1371
  import path from "path";
@@ -1774,7 +1782,11 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1774
1782
  job.askNever ? "ask-never" : undefined,
1775
1783
  job.noOverlap ? "no-overlap" : undefined,
1776
1784
  job.checkpointOnly ? "checkpoint-only" : undefined,
1777
- 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
1778
1790
  ].filter(Boolean).join(",");
1779
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}`;
1780
1792
  }) : ["No active loop jobs."];
@@ -1782,7 +1794,8 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1782
1794
  jobs,
1783
1795
  text: `OpenCode loop status:
1784
1796
  ${lines.join(`
1785
- `)}`
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.` : ""}`
1786
1799
  });
1787
1800
  }
1788
1801
 
@@ -1790,7 +1803,128 @@ ${lines.join(`
1790
1803
  init_process();
1791
1804
  init_job_workspace();
1792
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";
1793
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
1794
1928
  function createNativeJobPolicy(options = {}) {
1795
1929
  const now = options.now || Date.now;
1796
1930
  const run = options.runShellCommand || runShellCommand;
@@ -1824,7 +1958,7 @@ function createNativeJobPolicy(options = {}) {
1824
1958
  }
1825
1959
  if (job.stopFile) {
1826
1960
  try {
1827
- await stat(path7.resolve(scope.directory, job.stopFile));
1961
+ await stat(path8.resolve(scope.directory, job.stopFile));
1828
1962
  job.enabled = false;
1829
1963
  return pause(job, "stop-file");
1830
1964
  } catch (error) {
@@ -1836,6 +1970,20 @@ function createNativeJobPolicy(options = {}) {
1836
1970
  job.enabled = false;
1837
1971
  return pause(job, "until-reached");
1838
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
+ }
1839
1987
  return true;
1840
1988
  }
1841
1989
  async function prepare(scope, job, isCurrent = () => true) {
@@ -1868,12 +2016,20 @@ function createNativeJobPolicy(options = {}) {
1868
2016
  job.lastVerifyOutput = `${result.stdout || ""}
1869
2017
  ${result.stderr || ""}`.trim().slice(-8000);
1870
2018
  if (result.code !== 0) {
2019
+ delete job.lastVerifyChecklistDigest;
1871
2020
  job.failureCount = (job.failureCount || 0) + 1;
1872
2021
  if (result.blocked || job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures)
1873
2022
  pause(job, "verification-failed");
1874
2023
  return;
1875
2024
  }
1876
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
+ }
1877
2033
  }
1878
2034
  if (!isCurrent())
1879
2035
  return;
@@ -2001,6 +2157,8 @@ function createNativeLoopRuntime(options = {}) {
2001
2157
  continue;
2002
2158
  if (job.watchPaths?.length || job.stopFile || job.until)
2003
2159
  delay = Math.min(delay, 1000);
2160
+ if (job.untilChecklistComplete)
2161
+ delay = Math.min(delay, 5000);
2004
2162
  const due = dueAt(job);
2005
2163
  if (due > now())
2006
2164
  delay = Math.min(delay, due - now());
@@ -3144,8 +3302,8 @@ init_process();
3144
3302
  init_state();
3145
3303
 
3146
3304
  // src/source/core/progress.js
3147
- import { promises as fs4 } from "fs";
3148
- import path8 from "path";
3305
+ import { promises as fs5 } from "fs";
3306
+ import path9 from "path";
3149
3307
  var DEFAULT_PROGRESS_MD = `# Progress
3150
3308
 
3151
3309
  ## Current Goal
@@ -3173,24 +3331,24 @@ Describe the current project goal here.
3173
3331
  - None.
3174
3332
  `;
3175
3333
  function inside(root, candidate) {
3176
- const relative = path8.relative(root, candidate);
3177
- 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);
3178
3336
  }
3179
3337
  async function initializeProgressFile(directory, argumentsText = "") {
3180
- const root = await fs4.realpath(directory);
3338
+ const root = await fs5.realpath(directory);
3181
3339
  const target = String(argumentsText || "").trim() || "progress.md";
3182
- const file = path8.resolve(root, target);
3183
- const relative = path8.relative(root, file);
3340
+ const file = path9.resolve(root, target);
3341
+ const relative = path9.relative(root, file);
3184
3342
  if (!relative || !inside(root, file))
3185
3343
  throw new Error("Progress file must be inside the project");
3186
- if ([".git", ".opencode"].includes(relative.split(path8.sep)[0].toLowerCase())) {
3344
+ if ([".git", ".opencode"].includes(relative.split(path9.sep)[0].toLowerCase())) {
3187
3345
  throw new Error("Progress file cannot replace a control-plane path");
3188
3346
  }
3189
- const parent = await fs4.realpath(path8.dirname(file));
3347
+ const parent = await fs5.realpath(path9.dirname(file));
3190
3348
  if (!inside(root, parent))
3191
3349
  throw new Error("Progress file must remain inside the project; symlink escape refused");
3192
3350
  try {
3193
- await fs4.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3351
+ await fs5.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3194
3352
  return { created: true, file: relative };
3195
3353
  } catch (error) {
3196
3354
  if (error?.code === "EEXIST")
@@ -3205,6 +3363,7 @@ var HELP = `OpenCode Loop: native OpenCode 2 runtime
3205
3363
  /loop 5m <task> --verify "npm test" --pause-on-verify-fail
3206
3364
  /loop --watch progress.md <task>
3207
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"
3208
3367
  /loop 0s <task> --compact-every 3
3209
3368
  /loop 5m --shell npm test
3210
3369
  /loop 15m --compact /compact
@@ -3212,6 +3371,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
3212
3371
  /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
3213
3372
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
3214
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.
3215
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.
3216
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.
3217
3378
  An unfinished dedicated Goal reserves its session; Loop will not override it.
@@ -3318,7 +3479,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
3318
3479
  return { handled: true, accepted: true, ...output };
3319
3480
  }
3320
3481
  if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
3321
- 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(`
3322
3483
  `).slice(-60).join(`
3323
3484
  `) || "No Loop log entries." : name === "loop-doctor" ? `Native OpenCode Loop
3324
3485
  Host: ${ctx.app?.version || "unknown"}
package/src/server.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)
@@ -1357,7 +1365,7 @@ var init_job_workspace = __esm(() => {
1357
1365
  });
1358
1366
 
1359
1367
  // src/source/opencode2/native-plugin.js
1360
- import path9 from "path";
1368
+ import path10 from "path";
1361
1369
 
1362
1370
  // src/source/opencode2/session-location.js
1363
1371
  import path from "path";
@@ -1774,7 +1782,11 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1774
1782
  job.askNever ? "ask-never" : undefined,
1775
1783
  job.noOverlap ? "no-overlap" : undefined,
1776
1784
  job.checkpointOnly ? "checkpoint-only" : undefined,
1777
- 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
1778
1790
  ].filter(Boolean).join(",");
1779
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}`;
1780
1792
  }) : ["No active loop jobs."];
@@ -1782,7 +1794,8 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1782
1794
  jobs,
1783
1795
  text: `OpenCode loop status:
1784
1796
  ${lines.join(`
1785
- `)}`
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.` : ""}`
1786
1799
  });
1787
1800
  }
1788
1801
 
@@ -1790,7 +1803,128 @@ ${lines.join(`
1790
1803
  init_process();
1791
1804
  init_job_workspace();
1792
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";
1793
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
1794
1928
  function createNativeJobPolicy(options = {}) {
1795
1929
  const now = options.now || Date.now;
1796
1930
  const run = options.runShellCommand || runShellCommand;
@@ -1824,7 +1958,7 @@ function createNativeJobPolicy(options = {}) {
1824
1958
  }
1825
1959
  if (job.stopFile) {
1826
1960
  try {
1827
- await stat(path7.resolve(scope.directory, job.stopFile));
1961
+ await stat(path8.resolve(scope.directory, job.stopFile));
1828
1962
  job.enabled = false;
1829
1963
  return pause(job, "stop-file");
1830
1964
  } catch (error) {
@@ -1836,6 +1970,20 @@ function createNativeJobPolicy(options = {}) {
1836
1970
  job.enabled = false;
1837
1971
  return pause(job, "until-reached");
1838
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
+ }
1839
1987
  return true;
1840
1988
  }
1841
1989
  async function prepare(scope, job, isCurrent = () => true) {
@@ -1868,12 +2016,20 @@ function createNativeJobPolicy(options = {}) {
1868
2016
  job.lastVerifyOutput = `${result.stdout || ""}
1869
2017
  ${result.stderr || ""}`.trim().slice(-8000);
1870
2018
  if (result.code !== 0) {
2019
+ delete job.lastVerifyChecklistDigest;
1871
2020
  job.failureCount = (job.failureCount || 0) + 1;
1872
2021
  if (result.blocked || job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures)
1873
2022
  pause(job, "verification-failed");
1874
2023
  return;
1875
2024
  }
1876
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
+ }
1877
2033
  }
1878
2034
  if (!isCurrent())
1879
2035
  return;
@@ -2001,6 +2157,8 @@ function createNativeLoopRuntime(options = {}) {
2001
2157
  continue;
2002
2158
  if (job.watchPaths?.length || job.stopFile || job.until)
2003
2159
  delay = Math.min(delay, 1000);
2160
+ if (job.untilChecklistComplete)
2161
+ delay = Math.min(delay, 5000);
2004
2162
  const due = dueAt(job);
2005
2163
  if (due > now())
2006
2164
  delay = Math.min(delay, due - now());
@@ -3144,8 +3302,8 @@ init_process();
3144
3302
  init_state();
3145
3303
 
3146
3304
  // src/source/core/progress.js
3147
- import { promises as fs4 } from "fs";
3148
- import path8 from "path";
3305
+ import { promises as fs5 } from "fs";
3306
+ import path9 from "path";
3149
3307
  var DEFAULT_PROGRESS_MD = `# Progress
3150
3308
 
3151
3309
  ## Current Goal
@@ -3173,24 +3331,24 @@ Describe the current project goal here.
3173
3331
  - None.
3174
3332
  `;
3175
3333
  function inside(root, candidate) {
3176
- const relative = path8.relative(root, candidate);
3177
- 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);
3178
3336
  }
3179
3337
  async function initializeProgressFile(directory, argumentsText = "") {
3180
- const root = await fs4.realpath(directory);
3338
+ const root = await fs5.realpath(directory);
3181
3339
  const target = String(argumentsText || "").trim() || "progress.md";
3182
- const file = path8.resolve(root, target);
3183
- const relative = path8.relative(root, file);
3340
+ const file = path9.resolve(root, target);
3341
+ const relative = path9.relative(root, file);
3184
3342
  if (!relative || !inside(root, file))
3185
3343
  throw new Error("Progress file must be inside the project");
3186
- if ([".git", ".opencode"].includes(relative.split(path8.sep)[0].toLowerCase())) {
3344
+ if ([".git", ".opencode"].includes(relative.split(path9.sep)[0].toLowerCase())) {
3187
3345
  throw new Error("Progress file cannot replace a control-plane path");
3188
3346
  }
3189
- const parent = await fs4.realpath(path8.dirname(file));
3347
+ const parent = await fs5.realpath(path9.dirname(file));
3190
3348
  if (!inside(root, parent))
3191
3349
  throw new Error("Progress file must remain inside the project; symlink escape refused");
3192
3350
  try {
3193
- await fs4.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3351
+ await fs5.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3194
3352
  return { created: true, file: relative };
3195
3353
  } catch (error) {
3196
3354
  if (error?.code === "EEXIST")
@@ -3205,6 +3363,7 @@ var HELP = `OpenCode Loop: native OpenCode 2 runtime
3205
3363
  /loop 5m <task> --verify "npm test" --pause-on-verify-fail
3206
3364
  /loop --watch progress.md <task>
3207
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"
3208
3367
  /loop 0s <task> --compact-every 3
3209
3368
  /loop 5m --shell npm test
3210
3369
  /loop 15m --compact /compact
@@ -3212,6 +3371,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
3212
3371
  /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
3213
3372
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
3214
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.
3215
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.
3216
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.
3217
3378
  An unfinished dedicated Goal reserves its session; Loop will not override it.
@@ -3318,7 +3479,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
3318
3479
  return { handled: true, accepted: true, ...output };
3319
3480
  }
3320
3481
  if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
3321
- 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(`
3322
3483
  `).slice(-60).join(`
3323
3484
  `) || "No Loop log entries." : name === "loop-doctor" ? `Native OpenCode Loop
3324
3485
  Host: ${ctx.app?.version || "unknown"}
@@ -3410,8 +3571,8 @@ function inspectOpenCode2Context(ctx) {
3410
3571
 
3411
3572
  // src/source/opencode2/diagnostics.js
3412
3573
  init_state();
3413
- import { promises as fs5 } from "fs";
3414
- import path10 from "path";
3574
+ import { promises as fs6 } from "fs";
3575
+ import path11 from "path";
3415
3576
  var OPENCODE_LOOP_V2_HELP_TEXT = [
3416
3577
  "OpenCode Loop V2 experimental help:",
3417
3578
  "/loop 0s --max-runs 2 <prompt> autonomous prompt loop",
@@ -3440,7 +3601,7 @@ function createOpenCode2DiagnosticsRuntime(options = {}) {
3440
3601
  if (typeof options.prompt !== "function")
3441
3602
  throw new TypeError("V2 diagnostics runtime requires prompt()");
3442
3603
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
3443
- const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs5.readFile(...args);
3604
+ const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
3444
3605
  const runtimeVersion = options.runtimeVersion || process.version;
3445
3606
  const runtimePlatform = options.runtimePlatform || process.platform;
3446
3607
  async function exportState(event) {
@@ -3491,7 +3652,7 @@ ${JSON.stringify(state, null, 2)}
3491
3652
  return { handled: false, reason: "missing-scope" };
3492
3653
  let text = "No OpenCode 2 Loop log found.";
3493
3654
  try {
3494
- const raw = await readFile(path10.join(stateDir(scope.directory), "loop.log"), "utf8");
3655
+ const raw = await readFile(path11.join(stateDir(scope.directory), "loop.log"), "utf8");
3495
3656
  const lines = String(raw || "").trim().split(/\r?\n/).filter((line) => line.includes('"v2":true')).slice(-80);
3496
3657
  if (lines.length)
3497
3658
  text = lines.join(`