diff --git a/README.md b/README.md index a4e8c78..82ff03d 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,15 @@ Re-run `npm run build` after editing `src/`, then restart OpenCode to load the r /loop 2h look for failing CI runs /loop 2m --jitter=false check the latest package version /loop 30s --once remind me to stretch # one-shot: fires once, then auto-cancels +/loop check the deploy every 20m # trailing "every" clause ≡ /loop 20m check the deploy +/loop check CI every 5 minutes # word units work too: seconds/minutes/hours/days ``` +A trailing `every ` clause is extracted deterministically (Claude +Code rule 2): the interval applies and the rest is the prompt. `check every PR` +— no time expression after "every" — is not treated as a schedule and stays +Adaptive. + A recurring fixed task **runs immediately on creation** (Claude Code behavior): the creation turn is its first execution, then it repeats on schedule with the next fire anchored to the creation time. A `--once` task instead becomes due @@ -162,6 +169,11 @@ diagnose, and push a minimal fix. If new review comments have arrived, address each one. If everything is green, say so in one line. ``` +The file is **re-read on every run** (Claude Code behavior): editing loop.md +takes effect on the next fire with the full new content; when the content is +unchanged, only a short reminder is injected (prompt-cache friendly); if the +file is deleted, that run is skipped and the task stays armed. + ### Subcommands All subcommands are **scoped to the current session** — tasks created in other sessions are invisible to them, exactly like Claude Code's per-session `/loop` jobs. @@ -185,6 +197,7 @@ Trying to manage a task owned by another session reports "No task `` in this | Claude Code `/loop` | opencode-plugin-loop | |---|---| | `/loop 5m ` | identical — runs immediately on creation, then repeats | +| trailing "every" clause (`... every 20m`) | identical — deterministically extracted as a fixed interval | | `/loop ` (self-paced) | Adaptive: runs now, model picks the next check (fallback 1m–1h) | | `/proactive` | alias: `/proactive` works exactly like `/loop` | | cancel/list via cron tools | `/loop cancel `, `/loop list` | diff --git a/package.json b/package.json index 0547804..bc23b3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-loop", - "version": "0.8.0", + "version": "0.8.1", "description": "/loop command for opencode — run prompts on a schedule (fixed, adaptive, or maintenance), modeled after Claude Code's /loop", "type": "module", "main": "./dist/index.js", diff --git a/src/cron-parser.ts b/src/cron-parser.ts index efe262e..9024651 100644 --- a/src/cron-parser.ts +++ b/src/cron-parser.ts @@ -25,6 +25,14 @@ const UNIT_TO_MS: Record = { } const MIN_INTERVAL_MS = 1_000 +/** Word units accepted in trailing "every" clauses, mapped to s/m/h/d. */ +const EVERY_UNIT: Record = { + s: "s", sec: "s", secs: "s", second: "s", seconds: "s", + m: "m", min: "m", mins: "m", minute: "m", minutes: "m", + h: "h", hr: "h", hrs: "h", hour: "h", hours: "h", + d: "d", day: "d", days: "d", +} + /** * Single validator shared by the slash parser, the LLM tool `create`, and * `set_fixed` — all entries must accept/reject the same fixed intervals. @@ -80,7 +88,10 @@ export function CronParser(this: unknown): CronParserInstance { /** Try to extract an interval from a user command like "5m check deploy". * `rest` is the ORIGINAL substring after the interval token (not a token - * re-join), so prompt whitespace and newlines are preserved verbatim. */ + * re-join), so prompt whitespace and newlines are preserved verbatim. + * When the first token is not an interval, a trailing "every" clause is + * extracted instead (Claude Code rule 2): "check deploy every 20m" → + * fixed 20m + "check deploy". "check every PR" does not match. */ extractInterval(text: string) { const trimmed = text.trim() if (!trimmed) return { interval: null, rest: text } @@ -93,6 +104,17 @@ export function CronParser(this: unknown): CronParserInstance { rest: trimmed.slice(first.length).replace(/^\s+/, ""), } } + const every = /\bevery\s+(\d+(?:\.\d+)?)\s*([smhd]|seconds?|minutes?|hours?|days?)\s*$/i.exec(trimmed) + if (every) { + const unit = EVERY_UNIT[every[2].toLowerCase()] + const parsedTail = unit ? inst.parse(`${every[1]}${unit}`) : null + if (parsedTail) { + return { + interval: parsedTail, + rest: trimmed.slice(0, every.index).trim(), + } + } + } return { interval: null, rest: text } }, diff --git a/src/runtime-feedback.ts b/src/runtime-feedback.ts index 9c6d984..ef79c48 100644 --- a/src/runtime-feedback.ts +++ b/src/runtime-feedback.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { Part } from "@opencode-ai/sdk" +import { createHash } from "node:crypto" const SERVICE = "opencode-plugin-loop" const HANDLED_COMMAND_PROMPT = @@ -72,6 +73,34 @@ export function buildFixedFirstRunPrompt(input: { ].join("\n") } +/** Stable short content hash for loop.md change detection. */ +export function hashContent(content: string): string { + return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16) +} + +/** + * Maintenance execution for file-backed tasks (loop.md is re-read on every + * fire, Claude Code style): fresh/changed content is injected in full; + * unchanged content gets a short cache-friendly reminder. + */ +export function buildMaintenanceExecutionPrompt( + task: { id: string }, + freshContent: string | null +): string { + if (freshContent === null) { + return [ + `This is the scheduled execution of /loop maintenance task ${task.id}. The maintenance instructions from loop.md are unchanged since the previous run earlier in this conversation — refer to them above.`, + "If there is pending maintenance work, perform it now and report concisely. If nothing is pending, reply with one line saying so and call loop_schedule(action=\"cancel\", taskId=\"" + task.id + "\") to end the loop.", + ].join("\n") + } + return [ + `This is the scheduled execution of /loop maintenance task ${task.id}. The maintenance instructions from loop.md (re-read at fire time; they may have been edited since the last run) follow below — perform them now, then report concisely.`, + `When the work is complete and no further checks are needed, call loop_schedule(action="cancel", taskId="${task.id}") to end the loop.`, + "", + freshContent, + ].join("\n") +} + export type LoopLogLevel = "debug" | "info" | "warn" | "error" export type LoopLogger = ( level: LoopLogLevel, diff --git a/src/scheduler.ts b/src/scheduler.ts index d29860e..f617e97 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -17,7 +17,7 @@ import type { LoopTask } from "./types.js" import type { LoopStoreInstance as LoopStore } from "./store.js" import type { CronParserInstance as CronParser } from "./cron-parser.js" import type { JitterInstance as Jitter } from "./jitter.js" -import { buildFixedExecutionPrompt, buildFixedFirstRunPrompt, buildLoopCreatedPrompt, errorMessage, type LoopLogger } from "./runtime-feedback.js" +import { buildFixedExecutionPrompt, buildFixedFirstRunPrompt, buildLoopCreatedPrompt, buildMaintenanceExecutionPrompt, errorMessage, hashContent, type LoopLogger } from "./runtime-feedback.js" import { buildAdaptiveExecutionPrompt, clampAdaptiveNextDueAt as clampAdaptivePolicyNextDueAt, @@ -53,6 +53,7 @@ interface SchedulerInstance { handleResume(id: string): Promise formatTaskList(tasks: LoopTask[]): string loadDefaultPrompt(directory: string): string + loadDefaultPromptSource(directory: string): { path: string | null; content: string } getDueTasks(now?: number): Promise getDueTasksForSession(sessionID: string, now?: number): Promise nextDueAt(task: LoopTask, now?: number): Promise @@ -138,7 +139,8 @@ export const LOOP_HELP = `/loop — run prompts on a schedule Usage: /loop Adaptive: runs now, the model picks the next check (fallback 1m–1h) /loop Fixed interval: 30s, 5m, 2h, 1d (min 1s); runs immediately, then repeats - /loop Maintenance mode (uses .opencode/loop.md or ~/.opencode/loop.md when present) + /loop every Same as above (e.g. "check the deploy every 20m", "every 5 minutes") + /loop Maintenance mode (loop.md is re-read on every run — edits apply next fire) /loop help Show this help /proactive ... Full alias of /loop @@ -229,7 +231,8 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta } if (!trimmed) { - const prompt = inst.loadDefaultPrompt(directory) + const source = inst.loadDefaultPromptSource(directory) + const prompt = source.content const task = await inst.opts.store.create({ prompt, mode: "maintenance", @@ -237,6 +240,11 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta directory, source: "default", sessionID, + // File-backed maintenance re-reads loop.md on every fire, so edits + // take effect on the next trigger (Claude Code behavior). + ...(source.path + ? { loopFilePath: source.path, lastContentHash: hashContent(prompt) } + : {}), }) // Run the maintenance prompt immediately in this turn (matching // Adaptive's run-now behavior), then re-arm on the slow cycle. @@ -406,9 +414,10 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta return lines.join("\n") }, - loadDefaultPrompt(directory) { - // Priority: project > user > built-in default (mirrors Claude Code's - // project `.claude/loop.md` vs user `~/.claude/loop.md`). + /** Resolve the maintenance prompt AND its backing file (when any). + * Priority: project > user > built-in default (mirrors Claude Code's + * project `.claude/loop.md` vs user `~/.claude/loop.md`). */ + loadDefaultPromptSource(directory) { const candidates = [ join(directory, ".opencode", "loop.md"), join(directory, "loop.md"), @@ -418,13 +427,17 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta if (existsSync(p)) { try { const content = readFileSync(p, "utf-8").trim() - if (content) return content + if (content) return { path: p, content } } catch { // ignore } } } - return DEFAULT_MAINTENANCE_PROMPT + return { path: null, content: DEFAULT_MAINTENANCE_PROMPT } + }, + + loadDefaultPrompt(directory) { + return inst.loadDefaultPromptSource(directory).content }, async getDueTasks(now: number = Date.now()) { @@ -483,13 +496,44 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta inst.inflight.add(task.id) try { const sessionID = task.sessionID - const text = - task.mode === "adaptive" - ? buildAdaptiveExecutionPrompt(task, { - minMs: inst.opts.adaptiveMinMs, - maxMs: inst.opts.adaptiveMaxMs, - }) - : buildFixedExecutionPrompt(task) + let text: string + if (task.mode === "adaptive") { + text = buildAdaptiveExecutionPrompt(task, { + minMs: inst.opts.adaptiveMinMs, + maxMs: inst.opts.adaptiveMaxMs, + }) + } else if (task.mode === "maintenance" && task.loopFilePath) { + // File-backed maintenance re-reads loop.md at fire time (Claude + // Code behavior): edited file → inject the full new content; + // unchanged → short cache-friendly reminder; deleted → no-op tick. + let content: string | null = null + try { + if (existsSync(task.loopFilePath)) { + content = readFileSync(task.loopFilePath, "utf-8").trim() || null + } + } catch { + content = null + } + if (content === null) { + await logger("info", "loop.md missing or empty; skipping maintenance tick", { + taskId: task.id, + path: task.loopFilePath, + }) + await inst.opts.store.logFire(task, false) + return false + } + const hash = hashContent(content) + if (hash === task.lastContentHash) { + text = buildMaintenanceExecutionPrompt(task, null) + } else { + text = buildMaintenanceExecutionPrompt(task, content) + task.lastContentHash = hash + task.prompt = content + await inst.opts.store.touch(task.id) + } + } else { + text = buildFixedExecutionPrompt(task) + } const directory = task.directory || ctx?.directory || process.cwd() const client = ctx?.client diff --git a/src/store.ts b/src/store.ts index 7778ca3..444e7d0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -73,6 +73,8 @@ interface LoopStoreInstance { getDueTasksForSession(sessionID: string, now?: number): Promise getOrphanedTasks(): LoopTask[] markFired(id: string, nextDueAt?: number): Promise + /** Persist in-place mutations made by the caller (e.g. lastContentHash). */ + touch(id: string): Promise reschedule(id: string, nextDueAt: number): Promise setFixed( id: string, @@ -300,6 +302,8 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI // Only present on one-shot tasks — keeps persisted JSON stable for // tasks that never use the field. ...(input.once ? { once: true as const } : {}), + ...(input.loopFilePath ? { loopFilePath: input.loopFilePath } : {}), + ...(input.lastContentHash ? { lastContentHash: input.lastContentHash } : {}), ownerPid: identity.pid, ownerStartedAt: identity.startedAt, } @@ -365,6 +369,13 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI await inst.persist() return task }, + touch: async (id) => { + const task = inst.get(id) + if (!task) return null + dirtyIds.add(id) + await inst.persist() + return task + }, reschedule: async (id, nextDueAt) => { const task = inst.get(id) if (!task) return null diff --git a/src/types.ts b/src/types.ts index 70082de..357d253 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,12 @@ export interface LoopTask { ownerPid?: number /** Owner process start time (epoch ms), guards against pid reuse. */ ownerStartedAt?: number + /** Maintenance tasks only: the loop.md file this task re-reads on every + * fire. When absent, the task fires its stored prompt snapshot. */ + loopFilePath?: string + /** Hash of the loop.md content last injected — unchanged content fires a + * short cache-friendly reminder instead of the full text. */ + lastContentHash?: string } export interface LoopConfig { @@ -89,6 +95,10 @@ export interface CreateTaskInput { sessionID: string /** One-shot task (fixed mode only): auto-cancel after the first successful fire. */ once?: boolean + /** Maintenance tasks only: re-read this loop.md file on every fire. */ + loopFilePath?: string + /** Hash of the loop.md content captured at creation. */ + lastContentHash?: string } export interface FireResult { diff --git a/tests/cron-parser.test.mjs b/tests/cron-parser.test.mjs index 9143da1..6072fa0 100644 --- a/tests/cron-parser.test.mjs +++ b/tests/cron-parser.test.mjs @@ -66,4 +66,46 @@ test("format ms back to readable", () => { assert.equal(p.format(3_600_000), "1h") assert.equal(p.format(7_200_000), "2h") assert.equal(p.format(86_400_000), "1d") -}) \ No newline at end of file +}) +// --- trailing "every" clause extraction (Claude Code rule 2) --- + +test("extractInterval: trailing every clause becomes a fixed interval", async () => { + const cron = new CronParser() + const cases = [ + ["check the deploy every 20m", 20 * 60_000, "check the deploy"], + ["check the deploy every 30s", 30_000, "check the deploy"], + ["check CI every 5 minutes", 5 * 60_000, "check CI"], + ["look for failures every 2 hours", 2 * 3_600_000, "look for failures"], + ["daily report every 1 day", 86_400_000, "daily report"], + ["ping every 1.5h", 5_400_000, "ping"], + ["CHECK EVERY 10M", 600_000, "CHECK"], + ] + for (const [input, ms, rest] of cases) { + const r = cron.extractInterval(input) + assert.ok(r.interval, `expected interval for: ${input}`) + assert.equal(r.interval.ms, ms, input) + assert.equal(r.rest, rest, input) + } +}) + +test("extractInterval: trailing every without a time expression does not match", async () => { + const cron = new CronParser() + for (const input of ["check every PR", "review every merge request", "every 20m ago check"]) { + const r = cron.extractInterval(input) + assert.equal(r.interval, null, input) + } + // every in the middle is prompt text, not a schedule + const mid = cron.extractInterval("check every 2m worth of logs") + assert.equal(mid.interval, null) + // empty prompt before the clause: interval extracted, rest empty + const bare = cron.extractInterval("every 5m") + assert.equal(bare.interval.ms, 300_000) + assert.equal(bare.rest, "") +}) + +test("extractInterval: leading token wins over a trailing every clause", async () => { + const cron = new CronParser() + const r = cron.extractInterval("5m check the deploy every 2m") + assert.equal(r.interval.ms, 300_000) + assert.equal(r.rest, "check the deploy every 2m", "trailing clause stays in the prompt verbatim") +}) diff --git a/tests/package-exports.test.mjs b/tests/package-exports.test.mjs index 04008bc..832b8b4 100644 --- a/tests/package-exports.test.mjs +++ b/tests/package-exports.test.mjs @@ -6,8 +6,8 @@ const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ) -test("publishes the 0.8.0 release", () => { - assert.equal(packageJson.version, "0.8.0") +test("publishes the 0.8.1 release", () => { + assert.equal(packageJson.version, "0.8.1") }) test("publishes explicit server and TUI plugin entrypoints", () => { diff --git a/tests/scheduler.test.mjs b/tests/scheduler.test.mjs index bcd3a8a..8c1db0f 100644 --- a/tests/scheduler.test.mjs +++ b/tests/scheduler.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { LoopStore } from "../dist/store.js" @@ -815,3 +815,114 @@ test("bare /loop falls back to the user-level loop.md, project file wins", async rmSync(project, { recursive: true, force: true }) } }) + +// --- 0.8.1: trailing "every" clause creates a fixed task --- + +test("/loop check deploy every 2m → fixed, runs now (first-run prompt)", async () => { + const { sched, store, dir } = makeScheduler() + try { + const r = await sched.handleUserCommand("check deploy every 2m", "/tmp", "s1") + assert.equal(r.task.mode, "fixed") + assert.equal(r.task.intervalMs, 120_000) + assert.equal(r.task.prompt, "check deploy") + assert.match(r.modelPrompt, /first execution/) + assert.ok(store.get(r.task.id).lastFiredAt > 0) + } finally { + rmSync(dir, { recursive: true }) + } +}) + +test("/loop --once check every 30s → one-shot fixed due immediately", async () => { + const { sched, dir } = makeScheduler() + try { + const r = await sched.handleUserCommand("--once check every 30s", "/tmp", "s1") + assert.equal(r.task.mode, "fixed") + assert.equal(r.task.once, true) + assert.equal(r.task.intervalMs, 30_000) + assert.equal(r.task.prompt, "check") + assert.ok(r.task.nextDueAt <= Date.now()) + } finally { + rmSync(dir, { recursive: true }) + } +}) + +test("/loop check every PR stays adaptive (no time expression)", async () => { + const { sched, dir } = makeScheduler() + try { + const r = await sched.handleUserCommand("check every PR", "/tmp", "s1") + assert.equal(r.task.mode, "adaptive") + assert.equal(r.task.prompt, "check every PR") + } finally { + rmSync(dir, { recursive: true }) + } +}) + +// --- 0.8.1: file-backed maintenance re-reads loop.md on every fire --- + +test("maintenance fire re-reads loop.md: unchanged → reminder, edited → full text, deleted → no-op", async () => { + const dir = mkdtempSync(join(tmpdir(), "loop-sched-")) + const project = mkdtempSync(join(tmpdir(), "loop-proj-")) + try { + const store = new LoopStore({ storageDir: dir }) + const sched = new Scheduler({ + store, + cron: new CronParser(), + jitter: new Jitter(0.1), + adaptiveMinMs: 60_000, + adaptiveMaxMs: 3_600_000, + }) + const loopFile = join(project, ".opencode", "loop.md") + mkdirSync(join(project, ".opencode"), { recursive: true }) + writeFileSync(loopFile, "maintenance v1") + + const r = await sched.handleUserCommand("", project, "s1") + assert.equal(r.task.mode, "maintenance") + assert.equal(r.task.loopFilePath, loopFile) + assert.ok(r.task.lastContentHash, "content hash recorded at creation") + + const promptCalls = [] + const ctx = { client: { session: { async prompt(a) { promptCalls.push(a); return true } } }, directory: project } + + // Unchanged content → short reminder, no full text + await sched.fireTask(store.get(r.task.id), ctx) + assert.match(promptCalls[0].body.parts[0].text, /unchanged/) + assert.ok(!promptCalls[0].body.parts[0].text.includes("maintenance v1")) + + // Edited content → full text injected, hash updated and persisted + writeFileSync(loopFile, "maintenance v2 changed") + await sched.fireTask(store.get(r.task.id), ctx) + assert.match(promptCalls[1].body.parts[0].text, /maintenance v2 changed/) + const persisted = store.get(r.task.id) + assert.equal(persisted.prompt, "maintenance v2 changed") + const disk = JSON.parse(readFileSync(join(dir, "tasks.json"), "utf-8")) + assert.equal(disk.tasks[0].prompt, "maintenance v2 changed", "hash/content persisted") + + // Deleted file → no-op tick (no injection) + rmSync(loopFile) + const fired = await sched.fireTask(store.get(r.task.id), ctx) + assert.equal(fired, false) + assert.equal(promptCalls.length, 2, "no new injection after file deletion") + } finally { + rmSync(dir, { recursive: true, force: true }) + rmSync(project, { recursive: true, force: true }) + } +}) + +test("snapshot maintenance tasks (no loopFilePath) keep firing the stored prompt", async () => { + const { sched, store, dir } = makeScheduler() + try { + const t = await store.create({ + prompt: "legacy snapshot", + mode: "maintenance", + adaptiveMaxMs: 3_600_000, + directory: "/tmp", + sessionID: "s1", + }) + const promptCalls = [] + const ctx = { client: { session: { async prompt(a) { promptCalls.push(a); return true } } }, directory: "/tmp" } + await sched.fireTask(store.get(t.id), ctx) + assert.match(promptCalls[0].body.parts[0].text, /legacy snapshot/) + } finally { + rmSync(dir, { recursive: true }) + } +})