Skip to content
37 changes: 37 additions & 0 deletions .changeset/agent-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@fission-ai/openspec": minor
---

### Added

- **`openspec resolve-change [name] [--auto] [--json]`** — agent helper that
resolves which active change to operate on. Lists every active change,
validates a supplied name, or auto-selects when exactly one exists. Distinct
exit codes (`0` ok, `1` none, `2` not found, `3` ambiguous) let skills branch
without parsing stderr.
- **`openspec next-artifact --change <name>`** — agent helper that returns the
next ready artifact bundled with its full `instructions` payload. Replaces
the two-call `status --json` + `instructions <artifact> --json` pattern with
one call. JSON by default; pass `--no-json` for a human summary. Emits
`{ "done": true }` once every artifact is complete.
- **`openspec mark-task-done <task-id> --change <name>`** — agent helper that
flips a single checkbox in the change's tracking file (typically `tasks.md`)
from `- [ ]` to `- [x]`. Idempotent on already-checked lines, preserves
CRLF/LF endings, and uses anchored matching so `1.1` does not match `1.10`.

### Changed

- `openspec instructions apply --change <n> --json` payload is now richer:
- Each task in the `tasks` array carries an optional `numericId` field
(`"1.1"`, `"2.3.4"`) extracted from the start of the task description when
the tracking file uses numbered tasks. Existing positional `id` is
unchanged.
- The top-level payload includes a new `nextPendingId` field: the first
unchecked task that has a `numericId`, or `null`. Pair with
`openspec mark-task-done` to drive an apply loop without re-parsing
`tasks.md`.
- Skill workflow templates for `openspec-propose` and `openspec-apply-change`
now use the three new commands instead of inlining multi-step CLI
orchestration. Run `openspec update` against an existing OpenSpec project to
pick up the slimmer skill bodies. Net effect: fewer tokens per propose/apply
cycle and fewer round-trips between the agent and the CLI.
167 changes: 167 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ These commands support `--json` output for programmatic use by AI agents and scr
| `openspec validate` | Check for issues | `--all --json` for bulk validation |
| `openspec status` | See artifact progress | `--json` for structured status |
| `openspec instructions` | Get next steps | `--json` for agent instructions |
| `openspec resolve-change` | Locate active change | `--auto`/`--json` to drive skills without prompts |
| `openspec next-artifact` | One-call propose loop | JSON by default; bundles status + instructions |
| `openspec mark-task-done` | Tick off an applied task | Anchored, idempotent flipping by `numericId` |
| `openspec templates` | Find template paths | `--json` for path resolution |
| `openspec schemas` | List available schemas | `--json` for schema discovery |
| `openspec workspace setup --no-interactive` | Create a workspace with explicit inputs | `--json` for structured setup output |
Expand Down Expand Up @@ -858,6 +861,170 @@ openspec instructions design --change add-dark-mode --json
- Content from dependency artifacts
- Per-artifact rules from config

When invoked as `instructions apply --change <name> --json`, each task in the
returned `tasks` array also carries a `numericId` field whenever the tracking
file uses numbered tasks (e.g. `- [ ] 1.1 Wire foo` → `numericId: "1.1"`). The
top-level payload also includes `nextPendingId`: the first unchecked task with a
`numericId`, or `null` when no such task exists. Use this with
`openspec mark-task-done` to drive an apply loop without re-parsing tasks.md.

---

### `openspec resolve-change`

Agent helper: resolve which active change to operate on. Lists every active
change, validates a supplied name, or auto-selects when exactly one change
exists. Useful at the top of every workflow skill where the change identity is
the first question to answer.

```
openspec resolve-change [name] [options]
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

**Arguments:**

| Argument | Required | Description |
|----------|----------|-------------|
| `name` | No | Validate that this change is active; echo it on success |

**Options:**

| Option | Description |
|--------|-------------|
| `--auto` | Succeed only when exactly one active change exists |
| `--json` | Output as JSON |

**Examples:**

```bash
# List every active change as JSON (default when no name and no --auto)
openspec resolve-change

# Validate a specific name (exit 2 on miss)
openspec resolve-change add-dark-mode

# Auto-pick the only active change
openspec resolve-change --auto
```

**Exit codes:**

| Code | Meaning |
|------|---------|
| 0 | Success (name echoed, or JSON list emitted) |
| 1 | `--auto` and no active changes exist |
| 2 | Named change not active (or invalid name) |
| 3 | `--auto` and multiple active changes exist |

---

### `openspec next-artifact`

Agent helper: return the next ready artifact for a change, bundled with the
full instructions payload for that artifact. Replaces the
`status --json` + `instructions <artifact> --json` two-call dance with a single
call. JSON is the default since agents are the primary consumers; pass
`--no-json` for a human summary.

```
openspec next-artifact --change <name> [options]
```

**Options:**

| Option | Description |
|--------|-------------|
| `--change <id>` | Change name (required) |
| `--schema <name>` | Schema override (auto-detected from change config) |
| `--no-json` | Print a human-readable summary instead of JSON |

**Examples:**

```bash
# Get the next ready artifact and its instructions (JSON, default)
openspec next-artifact --change add-dark-mode

# Compact human summary
openspec next-artifact --change add-dark-mode --no-json
```

**Output (JSON):**

```json
// When work remains:
{
"done": false,
"artifactId": "design",
"resolvedOutputPath": "/abs/path/openspec/changes/add-dark-mode/design.md",
"template": "## Overview\n...",
"instruction": "Create the design document...",
"context": "...", // optional
"rules": ["..."], // optional
"dependencies": [ // completed artifacts to read first
{ "id": "proposal", "path": "proposal.md", "done": true, "description": "..." }
],
"unlocks": ["specs"]
}

// When every artifact is complete:
{ "done": true }
```

**Exit codes:**

| Code | Meaning |
|------|---------|
| 0 | Success (next artifact emitted, or `{done: true}`) |
| 1 | Missing `--change` or change not found |
| 3 | No runnable artifact — every pending artifact is dependency-blocked |

---

### `openspec mark-task-done`

Agent helper: flip a single checkbox in the change's tracking file (usually
`tasks.md`) from `- [ ]` to `- [x]`. The target line is identified by the
leading numeric task id (`1`, `1.1`, `2.3.4`) captured by
`instructions apply --json`. Idempotent on already-checked lines; preserves
CRLF vs LF line endings; anchored matching ensures `1.1` does not match
`1.10`.

```
openspec mark-task-done <task-id> --change <name> [options]
```

**Arguments:**

| Argument | Required | Description |
|----------|----------|-------------|
| `task-id` | Yes | The hierarchical task id (matches `numericId` from `instructions apply`) |

**Options:**

| Option | Description |
|--------|-------------|
| `--change <id>` | Change name (required) |
| `--schema <name>` | Schema override (auto-detected from change config) |
| `--json` | Output as JSON |

**Examples:**

```bash
# Mark task 1.1 done
openspec mark-task-done --change add-dark-mode 1.1

# Inside an apply loop, driven by nextPendingId from `instructions apply --json`
TASK=$(openspec instructions apply --change add-dark-mode --json | jq -r .nextPendingId)
openspec mark-task-done --change add-dark-mode "$TASK"
```

**Exit codes:**

| Code | Meaning |
|------|---------|
| 0 | Success (flipped, or already-done — both are no-op safe) |
| 2 | Bad input — missing change, schema lacks `apply.tracks`, tracking file missing, or no matching unchecked task line |

---

### `openspec templates`
Expand Down
60 changes: 60 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ import {
schemasCommand,
newChangeCommand,
setChangeCommand,
resolveChangeCommand,
nextArtifactCommand,
markTaskDoneCommand,
DEFAULT_SCHEMA,
type StatusOptions,
type InstructionsOptions,
type TemplatesOptions,
type SchemasOptions,
type NewChangeOptions,
type SetChangeOptions,
type ResolveChangeOptions,
type NextArtifactOptions,
type MarkTaskDoneOptions,
} from '../commands/workflow/index.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';

Expand Down Expand Up @@ -532,6 +538,60 @@ newCmd
}
});

// Resolve-change command (agent helper: locate active change by name or
// auto-select when unambiguous)
program
.command('resolve-change [name]')
.description('Resolve an active change by name, list active changes, or auto-select the only one')
.option('--auto', 'Succeed only when exactly one active change exists')
.option('--json', 'Output as JSON')
.action(async (name: string | undefined, options: ResolveChangeOptions) => {
try {
await resolveChangeCommand(name, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});

// Next-artifact command (agent helper: bundle "what is the next ready
// artifact?" with its full instructions payload). JSON is the default since
// agents are the primary consumers; pass --no-json for the human summary.
program
.command('next-artifact')
.description('Return the next ready artifact for a change, bundled with its instructions (JSON by default)')
.option('--change <id>', 'Change name')
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
.option('--no-json', 'Print a human-readable summary instead of JSON')
.action(async (options: NextArtifactOptions) => {
try {
await nextArtifactCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});

// Mark-task-done command (agent helper: flip a tracked tasks.md checkbox by
// numeric task id)
program
.command('mark-task-done <task-id>')
.description('Mark a task complete in the change\'s tracking file (idempotent)')
.option('--change <id>', 'Change name')
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
.option('--json', 'Output as JSON')
.action(async (taskId: string, options: MarkTaskDoneOptions) => {
try {
await markTaskDoneCommand(taskId, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});

// Set command group
const setCmd = program.command('set').description('Set checked-in OpenSpec metadata');

Expand Down
9 changes: 9 additions & 0 deletions src/commands/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,13 @@ export type { NewChangeOptions } from './new-change.js';
export { setChangeCommand } from './set-change.js';
export type { SetChangeOptions } from './set-change.js';

export { resolveChangeCommand } from './resolve-change.js';
export type { ResolveChangeOptions } from './resolve-change.js';

export { nextArtifactCommand } from './next-artifact.js';
export type { NextArtifactOptions } from './next-artifact.js';

export { markTaskDoneCommand } from './mark-task-done.js';
export type { MarkTaskDoneOptions } from './mark-task-done.js';

export { DEFAULT_SCHEMA } from './shared.js';
37 changes: 33 additions & 4 deletions src/commands/workflow/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,30 +229,58 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc

/**
* Parses tasks.md content and extracts task items with their completion status.
*
* When a line begins with a `N` or `N.N(.N)*` token (e.g. `1`, `2.3`, `1.10.4`),
* that token is captured as `numericId` and the leading whitespace/punctuation
* after it is stripped from `description`. Lines without such a token still
* parse, and only get a positional `id`. The positional `id` is always set so
* existing callers keep working.
*/
function parseTasksFile(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split('\n');
let taskIndex = 0;

// Capture leading task numbers like `1`, `1.1`, `1.10.4`. Followed by a
// separator (space, dot, colon, paren, dash) to anchor against descriptions
// that just happen to start with a digit.
const numericIdRe = /^(\d+(?:\.\d+)*)(?:[.:)\]-]?\s+|\s+)(.*)$/;

for (const line of lines) {
// Match checkbox patterns: - [ ] or - [x] or - [X]
const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/);
if (checkboxMatch) {
taskIndex++;
const done = checkboxMatch[1].toLowerCase() === 'x';
const description = checkboxMatch[2].trim();
tasks.push({
const rawDescription = checkboxMatch[2].trim();
const numericMatch = rawDescription.match(numericIdRe);
const task: TaskItem = {
id: `${taskIndex}`,
description,
description: numericMatch ? numericMatch[2].trim() : rawDescription,
done,
});
};
if (numericMatch) {
task.numericId = numericMatch[1];
}
tasks.push(task);
}
}

return tasks;
}

/**
* Picks the first unchecked task with a `numericId` (document order). Returns
* `null` if no task qualifies. Mirrors what agent skills need to drive
* `openspec mark-task-done` without re-parsing the list.
*/
function pickNextPendingId(tasks: TaskItem[]): string | null {
for (const t of tasks) {
if (!t.done && t.numericId) return t.numericId;
}
return null;
}

/**
* Generates apply instructions for implementing tasks from a change.
* Schema-aware: reads apply phase configuration from schema to determine
Expand Down Expand Up @@ -356,6 +384,7 @@ export async function generateApplyInstructions(
state,
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
instruction,
nextPendingId: pickNextPendingId(tasks),
};
}

Expand Down
Loading