Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -155,14 +156,14 @@ A session can hold more than one goal. `/goal <condition>` 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 <number>` 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 <number>` 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.
Expand All @@ -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
Expand Down Expand Up @@ -272,7 +273,7 @@ Override any limit for a single goal:
| `--no-progress-turns <n>` | Consecutive stalled low-output turns before pausing |
| `--success <text>` | Success criteria that define when the goal is satisfied (quote multi-word text) |
| `--constraints <text>` | Constraints / non-goals to respect (alias `--non-goals`) |
| `--mode <normal\|ordered>` | Execution mode; `ordered` (alias `sisyphus`) asks for a strict sequence |
| `--mode <normal\|ordered>` | Prompt mode for one goal; `ordered` preserves step order inside its objective |
| `--no-tool-turns <n>` | Consecutive tool-free continuation turns before pausing |

Examples:
Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions scripts/type-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 21 additions & 15 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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")
Expand All @@ -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": {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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).
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"}` : ""
Expand Down Expand Up @@ -3347,16 +3350,19 @@ 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()))
.filter(Boolean)
if (!objectives.length) {
output.parts = [
makeTextPart(
`No objectives provided. Use \`/${commandName} sisyphus <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
`No objectives provided. Use \`/${commandName} sequence <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
),
]
return
Expand Down Expand Up @@ -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)
})
Expand All @@ -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}`,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading