diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3121e..2db4bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Make `/goal sequence` the canonical ordered multi-goal command, retain the previous command and mode spelling as input-only compatibility aliases, correct the public auditor snapshot mode type to `"normal" | "ordered"`, and align README archive and compatibility claims with verified behavior. + ## 0.6.4 — 2026-07-12 - Re-check session status and recent messages after an auto-continue cooldown, pause immediately for human intervention, Plan-agent switches, permission rejection, aborts, and provider errors, and abort an accepted continuation when the user takes control. diff --git a/README.md b/README.md index c2758f1..2da6e29 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,16 @@ surface and versioning expectations. ### OpenCode version compatibility -Manually tested via the OpenCode TUI (`tmux` + real provider credentials, no mocks), verified against the plugin's own persisted state rather than terminal display alone: +Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose: | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Hook Output Display | |---|---|---|---|---|---| +| 1.17.15 | opencode (`deepseek-v4-flash-free`) | ✅ Canonical tool | ✅ Checkpoint + idle continuation | ✅ Structured `goal_complete` claim | ⚠️ Command text routed to model; mutation guard verified | | 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed | | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed | | 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed | -`/goal status` and auto-continue are graded on **state correctness** (verified directly against the plugin's persisted state file: correct limits parsed, correct turn/stop accounting, correct completion detection) — not on what's rendered in the terminal, since that's tracked separately as Hook Output Display. +`/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence. **Note:** Hook output display depends on OpenCode version — on 1.17.15, `command.execute.before`'s `output.parts` text is not rendered in the TUI for any provider tested; the raw command argument is instead routed to the model as a normal chat turn (see [Limitations](#limitations)). State mutations always work regardless of display: goal creation, flag parsing, auto-continue, limit enforcement, and evidence-gated completion detection were all verified correct via the persisted state file in every combination above. Re-test against your own OpenCode build before relying on unattended runs, and see [`docs/providers.md`](docs/providers.md) for the full per-model marker-compliance notes. @@ -89,7 +90,7 @@ Add success criteria, constraints / non-goals, and a mode: /goal ship the release --success "tests pass and changelog updated" --constraints "do not touch the public API" --mode ordered ``` -`--success` (alias `--success-criteria`) and `--constraints` (alias `--non-goals`) take quoted text and are injected alongside the objective so the assistant keeps them in view. `--mode` is `normal` (default) or `ordered` (alias `sisyphus`); `ordered` asks the assistant to work through the objective as a strict sequence. Multi-word values must be quoted. +`--success` (alias `--success-criteria`) and `--constraints` (alias `--non-goals`) take quoted text and are injected alongside the objective so the assistant keeps them in view. `--mode` is `normal` (default) or `ordered`; `ordered` tells the assistant to preserve step order inside one objective. To queue distinct objectives that auto-promote one at a time, use `/goal sequence`. Multi-word values must be quoted. Flags accept either `--flag value` or `--flag=value`. If a flag is unknown, missing a value, given a non-positive integer, or (for `--mode`) an unrecognized mode, the plugin rejects the command with a helpful error instead of silently folding the bad flag into the goal text. @@ -125,7 +126,7 @@ Pause without clearing the active goal: /goal pause ``` -Clear the active goal: +Clear all live goals in the current session and discard their saved status: ``` /goal clear @@ -155,14 +156,14 @@ A session can hold more than one goal. `/goal ` replaces the focused /goal focus 1 ``` -`/goal list` shows the numbered live goals (which is focused, which are backgrounded) and a per-session archive of completed/cleared goals so they stay readable. `/goal focus ` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart. +`/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus ` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart. -#### Ordered (sisyphus) sequences +#### Ordered sequences -`/goal sisyphus` sets up a strict execution sequence: separate the objectives with `;` or newlines, and the plugin runs them one at a time, auto-focusing the next as soon as the current one completes. +`/goal sequence` sets up a strict execution queue: separate objectives with `;` or newlines, and the plugin runs them one at a time, focusing the next as soon as the current one completes. ``` -/goal sisyphus build the parser; write the tests; ship the release +/goal sequence build the parser; write the tests; ship the release ``` The first goal is focused and the rest are queued. `/goal list` marks the session as ordered. Auto-promotion stops when the sequence is exhausted; `/goal clear` ends the sequence. @@ -187,7 +188,7 @@ With success criteria, constraints, and a token budget shorthand: An ordered sequence, run as a strict pipeline: ``` -/goal sisyphus build the parser; write the tests; ship the release +/goal sequence build the parser; write the tests; ship the release ``` ## How it works @@ -272,7 +273,7 @@ Override any limit for a single goal: | `--no-progress-turns ` | Consecutive stalled low-output turns before pausing | | `--success ` | Success criteria that define when the goal is satisfied (quote multi-word text) | | `--constraints ` | Constraints / non-goals to respect (alias `--non-goals`) | -| `--mode ` | Execution mode; `ordered` (alias `sisyphus`) asks for a strict sequence | +| `--mode ` | Prompt mode for one goal; `ordered` preserves step order inside its objective | | `--no-tool-turns ` | Consecutive tool-free continuation turns before pausing | Examples: @@ -343,7 +344,7 @@ Registered tools: `goal_set` and `set_goal` are explicitly constrained to user-requested goals. `goal_complete` accepts a structured claim: a required non-empty `summary`, plus optional criterion/evidence pairs, checks (`passed`, `failed`, or `not-run`), changed files, and known limitations. Failed checks and empty criterion evidence are rejected before archival; accepted claims are serialized deterministically for the configured completion auditor. The legacy `update_goal` tool retains its string `evidence` field for compatibility. -These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered (sisyphus) sequence auto-promotes the next. +These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered sequence auto-promotes the next. > Integration note: the tool execute-context shape (`ctx.sessionID`) and the `tool.schema` surface follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but the live registration should be confirmed against a real OpenCode run (see the smoke-test checklist). diff --git a/index.d.ts b/index.d.ts index 51e9055..824fa7b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -48,7 +48,7 @@ export interface GoalAuditSnapshot { condition: string successCriteria: string constraints: string - mode: "normal" | "sisyphus" + mode: "normal" | "ordered" sessionID: string turnCount: number startedAt: number diff --git a/scripts/type-contract.mjs b/scripts/type-contract.mjs index 1f91f83..0481530 100644 --- a/scripts/type-contract.mjs +++ b/scripts/type-contract.mjs @@ -55,10 +55,13 @@ const options = { auditorOptions: { timeoutMs: 5_000, failurePolicy: "reject" }, auditMessages: true, auditMessenger: (_sessionID, _text) => {}, - auditor: async ({ goal, sessionID, latestText }: CompletionAuditContext) => ({ - approved: goal.sessionID === sessionID && latestText.length > 0, - reason: goal.lastCheckpoint?.summary, - }), + auditor: async ({ goal, sessionID, latestText }: CompletionAuditContext) => { + const mode: "normal" | "ordered" = goal.mode + return { + approved: goal.sessionID === sessionID && latestText.length > 0, + reason: goal.lastCheckpoint?.summary || mode, + } + }, } satisfies GoalPluginOptions const hooks: GoalPluginHooks = await GoalPlugin({ client: {}, directory: "/tmp" }, options) diff --git a/src/goal-plugin.js b/src/goal-plugin.js index c6f7a67..5c67c3c 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -74,7 +74,7 @@ const DEFAULT_OPTIONS = { // handler drives and that the system-prompt transform injects. `sessionGoals` // is the full registry of live goals per session (focused + backgrounded); // the focused goal is the same object reference held in both. `sessionArchive` -// keeps a capped list of completed/cleared goals so they stay readable. +// keeps a capped list of achieved goals so completed work stays readable. function createRuntimeState() { return { goalStates: new Map(), @@ -129,7 +129,7 @@ function runtimeCollection(name) { const goalStates = runtimeCollection("goalStates") const sessionGoals = runtimeCollection("sessionGoals") const sessionArchive = runtimeCollection("sessionArchive") -// Sessions running an ordered (sisyphus) sequence: when the focused goal +// Sessions running an ordered sequence: when the focused goal // completes, the next live goal (in creation order) is auto-promoted to focus // so the sequence advances on its own. const sessionOrdered = runtimeCollection("sessionOrdered") @@ -147,6 +147,9 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens") const activeContinues = runtimeCollection("activeContinues") const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"]) const PAUSE_COMMANDS = new Set(["pause"]) +// `sequence` is canonical. The former public spelling remains accepted at +// the parser boundary so existing scripts do not break. +const SEQUENCE_COMMANDS = ["sequence", "sisyphus"] const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"]) const GOAL_FLAG_SPECS = { "--max-turns": { @@ -211,8 +214,8 @@ function messageHasToolCall(message) { const GOAL_MODES = new Set(["normal", "ordered"]) -// Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered` -// signals a strict execution sequence; `sisyphus` is accepted as an alias. +// Goal mode: normal vs ordered. The former public spelling remains accepted +// as an input alias, while stored state and output always use `ordered`. // Returns the canonical mode or null when unrecognized. function normalizeMode(value) { const normalized = String(value || "").trim().toLowerCase() @@ -727,7 +730,7 @@ function archiveSessionResult(sessionID, result) { sessionArchive.set(sessionID, list.slice(-MAX_ARCHIVED_PER_SESSION)) } -// Advance an ordered (sisyphus) sequence: focus the next live goal in creation +// Advance an ordered sequence: focus the next live goal in creation // order, clearing any backgrounded state so the idle handler drives it. Returns // the promoted goal, or null when the sequence is exhausted (which also clears // the session's ordered flag). @@ -743,7 +746,7 @@ function promoteNextOrderedGoal(sessionID) { resumeGoalClock(next) next.skipNextTerminalCheck = true next.lastStatus = "Promoted as the next ordered goal." - pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered (sisyphus) sequence.") + pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered sequence.") focusGoal(sessionID, next) return next } @@ -2463,7 +2466,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt const ordered = sessionOrdered.has(sessionID) rememberGoalResult(sessionID, goal, "achieved", "", evidence) cleanupGoal(sessionID) - // Advance an ordered (sisyphus) sequence just like the marker path does. + // Advance an ordered sequence just like the marker path does. if (ordered) promoteNextOrderedGoal(sessionID) const durable = await persistFinal("completion", ledgerDurable) if (durable === false) { @@ -2707,7 +2710,7 @@ function formatGoalList(sessionID, commandName = "goal") { const lines = [] if (goals.length) { - lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered (sisyphus)" : ""}:`) + lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`) goals.forEach((goal, index) => { const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle" const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : "" @@ -3347,8 +3350,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) return } - if (args === "sisyphus" || args.toLowerCase().startsWith("sisyphus ")) { - const rest = args.slice("sisyphus".length).trim() + const sequenceCommand = SEQUENCE_COMMANDS.find( + (command) => args.toLowerCase() === command || args.toLowerCase().startsWith(`${command} `), + ) + if (sequenceCommand) { + const rest = args.slice(sequenceCommand.length).trim() const objectives = rest .split(/\n|;/) .map((part) => stripWrappingQuotes(part.trim())) @@ -3356,7 +3362,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (!objectives.length) { output.parts = [ makeTextPart( - `No objectives provided. Use \`/${commandName} sisyphus ; ; …\` (separate with \`;\` or newlines).`, + `No objectives provided. Use \`/${commandName} sequence ; ; …\` (separate with \`;\` or newlines).`, ), ] return @@ -3400,7 +3406,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) pushHistory( created, "set", - `Ordered goal ${index + 1}/${objectives.length} created (sisyphus sequence).`, + `Ordered goal ${index + 1}/${objectives.length} created.`, ) registerSessionGoal(created) }) @@ -3410,7 +3416,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) output.parts = [ makeTextPart( [ - `Started an ordered sequence of ${objectives.length} goal(s) (sisyphus mode):`, + `Started an ordered sequence of ${objectives.length} goal(s):`, ...objectives.map((objective, index) => `${index + 1}. ${objective}`), "", `Focused goal 1: ${firstGoal.condition}`, @@ -3560,7 +3566,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // Replace the focused goal (cleanupGoal discards it); backgrounded goals // for this session are preserved. Use `/goal add` to keep the current // goal and add another. Clear any ordered-sequence flag so the new - // standalone goal does not trigger sisyphus auto-promotion of old sequence + // standalone goal does not trigger auto-promotion of the old sequence // goals that may still be in the registry (matches the agent setGoal path). sessionOrdered.delete(sessionID) cleanupGoal(sessionID) @@ -3852,7 +3858,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const ordered = sessionOrdered.has(sessionID) rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence) cleanupGoal(sessionID) - // Ordered (sisyphus) sequence: auto-promote the next goal so the + // Ordered sequence: auto-promote the next goal so the // session keeps working through the sequence without manual /goal focus. if (ordered) { promoteNextOrderedGoal(sessionID) diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 72da41f..1de8b38 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -341,7 +341,7 @@ test("parses success criteria, constraints, and mode into goal meta", () => { assert.deepEqual(parsed.errors, []) }) -test("--non-goals aliases constraints and sisyphus aliases ordered mode", () => { +test("the legacy mode spelling remains an input-only alias for ordered", () => { const parsed = parseGoalArguments('ship it --non-goals "no refactors" --mode=sisyphus', normalizeOptions()) assert.equal(parsed.condition, "ship it") assert.equal(parsed.meta.constraints, "no refactors") @@ -4231,7 +4231,7 @@ test("createChildSessionAuditor requires an explicit policy to approve operation ) }) -// ── Sisyphus ordered goals ───────────────────────────────────────────────── +// ── Ordered goal sequences ────────────────────────────────────────────────── async function idleOnce(hooks, sessionID) { await hooks.event({ @@ -4239,10 +4239,10 @@ async function idleOnce(hooks, sessionID) { }) } -test("/goal sisyphus sets up an ordered sequence with the first goal focused", async () => { +test("/goal sequence sets up an ordered sequence with the first goal focused", async () => { const { hooks } = await createHooks({ options: { minDelayMs: 1 } }) - const sid = "sis-s1" - const text = await runGoal(hooks, sid, "sisyphus build the parser; write the tests; ship it") + const sid = "sequence-s1" + const text = await runGoal(hooks, sid, "sequence build the parser; write the tests; ship it") assert.match(text, /ordered sequence of 3 goal\(s\)/) assert.match(text, /Focused goal 1: build the parser/) @@ -4253,7 +4253,17 @@ test("/goal sisyphus sets up an ordered sequence with the first goal focused", a assert.equal(goals[1].stopped, true) assert.equal(goals[1].stopReason, "queued") - assert.match(await runGoal(hooks, sid, "list"), /ordered \(sisyphus\)/) + assert.match(await runGoal(hooks, sid, "list"), /ordered sequence/) + assert.doesNotMatch(text, /sisyphus/i) +}) + +test("the former sequence command remains an input-only compatibility alias", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1 } }) + const sid = "sequence-compat" + const text = await runGoal(hooks, sid, "sisyphus first; second") + assert.match(text, /Started an ordered sequence of 2 goal\(s\)/) + assert.doesNotMatch(text, /sisyphus/i) + assert.match(await runGoal(hooks, sid, "list"), /ordered sequence/) }) test("completing the focused ordered goal auto-promotes the next, then ends the sequence", async () => { @@ -4269,8 +4279,8 @@ test("completing the focused ordered goal auto-promotes the next, then ends the messages: async () => ({ data: [completions[Math.min(messageCall++, completions.length - 1)]] }), options: { minDelayMs: 1 }, }) - const sid = "sis-s2" - await runGoal(hooks, sid, "sisyphus alpha; beta; gamma") + const sid = "sequence-s2" + await runGoal(hooks, sid, "sequence alpha; beta; gamma") assert.equal(currentGoal(sid).condition, "alpha") await idleOnce(hooks, sid) // completes alpha → promotes beta @@ -4296,21 +4306,21 @@ test("completing the focused ordered goal auto-promotes the next, then ends the test("promoteNextOrderedGoal focuses the next goal or ends the sequence", async () => { const { hooks } = await createHooks({ options: { minDelayMs: 1 } }) - const sid = "sis-s3" - await runGoal(hooks, sid, "sisyphus one; two") + const sid = "sequence-s3" + await runGoal(hooks, sid, "sequence one; two") // Drop the focused goal manually, then promote. await runGoal(hooks, sid, "clear") // clears focused "one" and the ordered flag // Re-establish a small ordered set and exercise the helper directly. - await runGoal(hooks, sid, "sisyphus solo") + await runGoal(hooks, sid, "sequence solo") assert.equal(currentGoal(sid).condition, "solo") // Remove it and promote → nothing left. await runGoal(hooks, sid, "clear") assert.equal(promoteNextOrderedGoal(sid), null) }) -test("ordered (sisyphus) flag survives a persistence round-trip", async () => { - const dir = await mkdtemp(join(tmpdir(), "goal-plugin-sis-")) +test("ordered sequence state survives a persistence round-trip", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-sequence-")) const stateFilePath = join(dir, "state.json") const client = { app: { log: async () => {} }, @@ -4318,17 +4328,17 @@ test("ordered (sisyphus) flag survives a persistence round-trip", async () => { } try { const hooks = await GoalPlugin({ client }, { persistState: true, stateFilePath, minDelayMs: 1 }) - await runGoal(hooks, "sis-persist", "sisyphus first; second") + await runGoal(hooks, "sequence-persist", "sequence first; second") await hooks.dispose() const recoveredHooks = await GoalPlugin({ client }, { persistState: true, stateFilePath, minDelayMs: 1 }) const reloaded = { parts: [] } await recoveredHooks["command.execute.before"]( - { command: "goal", sessionID: "sis-persist", arguments: "list" }, + { command: "goal", sessionID: "sequence-persist", arguments: "list" }, reloaded, ) - assert.match(reloaded.parts[0].text, /ordered \(sisyphus\)/) - assert.equal(listSessionGoals("sis-persist").length, 2) + assert.match(reloaded.parts[0].text, /ordered sequence/) + assert.equal(listSessionGoals("sequence-persist").length, 2) } finally { await rm(dir, { recursive: true, force: true }) } @@ -5081,7 +5091,7 @@ test("ordered completion storage failure rolls back premature successor promotio } const hooks = await GoalPlugin({ client }, { persistState: false }) await hooks["command.execute.before"]( - { command: "goal", sessionID: "ordered-storage-failure", arguments: "sisyphus first; second" }, + { command: "goal", sessionID: "ordered-storage-failure", arguments: "sequence first; second" }, { parts: [] }, ) const handlers = buildAgentToolHandlers({ @@ -5164,7 +5174,7 @@ test("ledger-only ordered completion promotes the queued successor during restar try { first = await GoalPlugin({ client }, { stateFilePath, minDelayMs: 1 }) await first["command.execute.before"]( - { command: "goal", sessionID: "ordered-ledger-restart", arguments: "sisyphus first; second" }, + { command: "goal", sessionID: "ordered-ledger-restart", arguments: "sequence first; second" }, { parts: [] }, ) const firstGoal = currentGoal("ordered-ledger-restart") @@ -5460,12 +5470,12 @@ test("/goal edit resets formatFailures so the revised objective starts with a cl assert.equal(currentGoal(sid).condition, "revised objective") }) -test("/goal replace clears sessionOrdered so old sisyphus sequence does not auto-promote", async () => { +test("/goal replace clears sessionOrdered so an old sequence does not auto-promote", async () => { const { hooks } = await createHooks() const sid = "replace-ordered-s1" - // Start an ordered sequence via sisyphus. - await runGoal(hooks, sid, "sisyphus 'alpha'; 'beta'") + // Start an ordered sequence. + await runGoal(hooks, sid, "sequence 'alpha'; 'beta'") // sessionOrdered should now be set for this session. // Replace with a standalone goal. await runGoal(hooks, sid, "standalone goal")