From 804769c350576ec38555969ca995becb829d8dbb Mon Sep 17 00:00:00 2001 From: Jussi Rantala Date: Wed, 27 May 2026 12:20:30 +0300 Subject: [PATCH 1/4] feat(cli): add resolve-change, next-artifact, mark-task-done Three new verb-first agent helper commands that let AI skills drive the OpenSpec workflow without re-implementing CLI orchestration. Also enriches the existing 'instructions apply --json' payload with numericId and nextPendingId fields so downstream callers can target tasks by author- supplied hierarchical id (1.1, 2.3.4) rather than position. - openspec resolve-change [name] [--auto] [--json]: list active changes, validate a named change, or auto-select when exactly one is active. - openspec next-artifact --change : bundle status next-ready artifact lookup with full instructions in one JSON payload, so skills can run a propose loop with one call per artifact instead of two. - openspec mark-task-done --change : flip a tracked tasks.md checkbox by hierarchical task id with anchored matching (1.1 does not match 1.10), EOL preservation, idempotent on already-done lines. Existing parseTasksFile() now also captures the leading N(.N)* token into a new optional 'numericId' field on TaskItem; legacy positional 'id' remains unchanged. ApplyInstructions gains a 'nextPendingId' field (first unchecked task that has a numericId, or null). --- src/cli/index.ts | 60 ++++++++ src/commands/workflow/index.ts | 9 ++ src/commands/workflow/instructions.ts | 37 ++++- src/commands/workflow/mark-task-done.ts | 189 ++++++++++++++++++++++++ src/commands/workflow/next-artifact.ts | 116 +++++++++++++++ src/commands/workflow/resolve-change.ts | 120 +++++++++++++++ src/commands/workflow/shared.ts | 14 ++ 7 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 src/commands/workflow/mark-task-done.ts create mode 100644 src/commands/workflow/next-artifact.ts create mode 100644 src/commands/workflow/resolve-change.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index d06fdddc5..a50785428 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -32,6 +32,9 @@ import { schemasCommand, newChangeCommand, setChangeCommand, + resolveChangeCommand, + nextArtifactCommand, + markTaskDoneCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, @@ -39,6 +42,9 @@ import { 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'; @@ -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 ', 'Change name') + .option('--schema ', '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 ') + .description('Mark a task complete in the change\'s tracking file (idempotent)') + .option('--change ', 'Change name') + .option('--schema ', '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'); diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 67b413a69..b6a943e18 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -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'; diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 71f6918a2..493c927a8 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -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 @@ -356,6 +384,7 @@ export async function generateApplyInstructions( state, missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined, instruction, + nextPendingId: pickNextPendingId(tasks), }; } diff --git a/src/commands/workflow/mark-task-done.ts b/src/commands/workflow/mark-task-done.ts new file mode 100644 index 000000000..696776a0d --- /dev/null +++ b/src/commands/workflow/mark-task-done.ts @@ -0,0 +1,189 @@ +/** + * Mark-Task-Done Command + * + * Flips a single checkbox in a change's tracking file (typically `tasks.md`) + * from `- [ ]` to `- [x]`. The target line is matched by the leading numeric + * task id (`1`, `1.1`, `2.3.4`) emitted by `parseTasksFile` — agents and + * scripts thus identify tasks by the same handle the CLI shows them, without + * having to know where the tracking file lives. + * + * Behavior: + * - Idempotent: if the matching line is already `- [x]`, exit 0. + * - Preserves CRLF vs LF line endings. + * - Uses an anchored regex so `1.1` does not match `1.10`. + * + * Exit codes: + * 0 success (flipped or already-done) + * 2 bad input — change not found, schema lacks `apply.tracks`, tracking + * file missing, or no matching unchecked task line. + */ + +import * as fs from 'fs'; +import path from 'path'; +import { resolveSchema } from '../../core/artifact-graph/index.js'; +import { getChangeDir, resolveCurrentPlanningHomeSync } from '../../core/planning-home.js'; +import { validateChangeExists } from './shared.js'; + +export interface MarkTaskDoneOptions { + change?: string; + schema?: string; + json?: boolean; +} + +interface MarkResult { + change: string; + taskId: string; + tracksPath: string; + status: 'flipped' | 'already-done'; +} + +function fail(message: string, code: number): never { + console.error(message); + process.exit(code); +} + +export async function markTaskDoneCommand( + taskId: string | undefined, + options: MarkTaskDoneOptions +): Promise { + if (!taskId) { + fail('Missing required argument .', 2); + } + + const planningHome = resolveCurrentPlanningHomeSync(); + const projectRoot = planningHome.root; + + const changeName = await validateChangeExists( + options.change, + projectRoot, + planningHome.changesDir + ); + + const changeDir = getChangeDir(planningHome, changeName); + + // Resolve schema (auto-detected from change metadata when not explicit) and + // pull the `apply.tracks` setting. Without a tracks file there's nothing to + // mark. + // Schema metadata lookup mirrors loadChangeContext; reuse if it becomes + // public. + const schemaName = options.schema ?? readSchemaFromMetadata(changeDir) ?? 'spec-driven'; + const schema = resolveSchema(schemaName, projectRoot); + const tracksRelative = schema.apply?.tracks ?? null; + if (!tracksRelative) { + fail( + `Schema '${schemaName}' does not configure 'apply.tracks'; nothing to mark.`, + 2 + ); + } + + const tracksPath = path.join(changeDir, tracksRelative); + if (!fs.existsSync(tracksPath)) { + fail(`Tracking file not found at '${tracksPath}' for change '${changeName}'.`, 2); + } + + let body: string; + try { + body = fs.readFileSync(tracksPath, 'utf8'); + } catch (err) { + fail( + `Failed to read tracking file '${tracksPath}': ${(err as Error).message}`, + 2 + ); + } + + const result = applyTaskFlip(body, taskId); + + if (result.kind === 'no-match') { + fail( + `No unchecked task line with id '${taskId}' found in '${tracksPath}'.`, + 2 + ); + } + + if (result.kind === 'flipped') { + fs.writeFileSync(tracksPath, result.next); + } + + const payload: MarkResult = { + change: changeName, + taskId, + tracksPath, + status: result.kind === 'already-done' ? 'already-done' : 'flipped', + }; + + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log( + result.kind === 'already-done' + ? `Task '${taskId}' in '${tracksPath}' was already complete; no change.` + : `Marked '${taskId}' done in '${tracksPath}'.` + ); + } +} + +// ----------------------------------------------------------------------------- +// Internals +// ----------------------------------------------------------------------------- + +/** + * Reads `.openspec.yaml` schemaName if present. Lightweight stand-in for the + * private helper in `instruction-loader.ts`; mark-task-done only needs the + * name, not the full metadata blob. + */ +function readSchemaFromMetadata(changeDir: string): string | null { + const metaPath = path.join(changeDir, '.openspec.yaml'); + if (!fs.existsSync(metaPath)) return null; + const raw = fs.readFileSync(metaPath, 'utf8'); + const match = raw.match(/^\s*schema\s*:\s*(['"]?)([^\s'"]+)\1/m); + return match ? match[2] : null; +} + +type FlipResult = + | { kind: 'flipped'; next: string } + | { kind: 'already-done' } + | { kind: 'no-match' }; + +/** + * Performs the actual line-flip. Exported indirectly via the command surface; + * factored out so tests can pin behavior without touching the filesystem. + */ +export function applyTaskFlip(body: string, taskId: string): FlipResult { + const usesCrlf = /\r\n/.test(body); + const eol = usesCrlf ? '\r\n' : '\n'; + const lines = body.split(/\r?\n/); + + const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Anchored: optional indent, "- [ ]" (or "* [ ]"), required whitespace, + // task-id, then a word boundary so 1.1 does not match 1.10. + const uncheckedRe = new RegExp( + String.raw`^(\s*)[-*]\s*\[\s\]\s+${escapedId}(?!\d|\.\d)\b` + ); + const checkedRe = new RegExp( + String.raw`^(\s*)[-*]\s*\[[xX]\]\s+${escapedId}(?!\d|\.\d)\b` + ); + + let foundIdx = -1; + let alreadyDoneIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (uncheckedRe.test(lines[i])) { + foundIdx = i; + break; + } + if (alreadyDoneIdx === -1 && checkedRe.test(lines[i])) { + alreadyDoneIdx = i; + } + } + + if (foundIdx === -1) { + if (alreadyDoneIdx !== -1) { + return { kind: 'already-done' }; + } + return { kind: 'no-match' }; + } + + // Replace the checkbox in the matched line. Replace only the first + // occurrence of "[ ]" to avoid touching nested content on the same line. + lines[foundIdx] = lines[foundIdx].replace(/\[\s\]/, '[x]'); + return { kind: 'flipped', next: lines.join(eol) }; +} diff --git a/src/commands/workflow/next-artifact.ts b/src/commands/workflow/next-artifact.ts new file mode 100644 index 000000000..9dac18cc1 --- /dev/null +++ b/src/commands/workflow/next-artifact.ts @@ -0,0 +1,116 @@ +/** + * Next-Artifact Command + * + * Returns the next artifact an agent should produce for a change, bundled with + * the full instructions for that artifact. Combines `openspec status` and + * `openspec instructions ` into one call so a skill can drive the + * propose loop without re-reading state between every artifact. + * + * Output: + * { "done": true } - all artifacts complete + * { "done": false, "artifactId": "...", - next ready artifact + * "outputPath": ..., "resolvedOutputPath": ..., "instruction": ..., + * "rules": ..., "context": ..., "template": ..., "dependencies": [...] } + * + * Exit codes: + * 0 success (including done=true) + * 2 missing --change / change not found + * 3 no runnable artifact (every pending artifact is dependency-blocked) + */ + +import ora from 'ora'; +import { + loadChangeContext, + formatChangeStatus, + generateInstructions, + type ArtifactInstructions, +} from '../../core/artifact-graph/index.js'; +import { getChangeDir, resolveCurrentPlanningHomeSync } from '../../core/planning-home.js'; +import { validateChangeExists, validateSchemaExists } from './shared.js'; + +export interface NextArtifactOptions { + change?: string; + schema?: string; + json?: boolean; +} + +export type NextArtifactPayload = + | { done: true } + | (ArtifactInstructions & { done: false }); + +export async function nextArtifactCommand(options: NextArtifactOptions): Promise { + const spinner = options.json ? undefined : ora('Resolving next artifact...').start(); + + try { + const planningHome = resolveCurrentPlanningHomeSync(); + const projectRoot = planningHome.root; + const changeName = await validateChangeExists( + options.change, + projectRoot, + planningHome.changesDir + ); + + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + const context = loadChangeContext(projectRoot, changeName, options.schema, { + changeDir: getChangeDir(planningHome, changeName), + planningHome, + }); + + const status = formatChangeStatus(context); + + if (status.isComplete) { + spinner?.stop(); + emit({ done: true }, options); + return; + } + + // status.artifacts is already sorted by build order, so the first 'ready' + // entry is the artifact the agent should generate next. + const nextReady = status.artifacts.find((a) => a.status === 'ready'); + + if (!nextReady) { + spinner?.stop(); + // Not complete, but nothing is ready — every pending artifact is blocked + // by a missing dependency. Surface this clearly; let the caller decide. + const blocked = status.artifacts + .filter((a) => a.status === 'blocked') + .map((a) => `${a.id} (missing: ${a.missingDeps?.join(', ') ?? '?'})`) + .join('; '); + console.error( + `No runnable artifact for change '${changeName}'. Pending but blocked: ${blocked}` + ); + process.exit(3); + } + + const instructions = generateInstructions(context, nextReady.id, projectRoot); + spinner?.stop(); + + emit({ done: false, ...instructions }, options); + } catch (error) { + spinner?.stop(); + throw error; + } +} + +function emit(payload: NextArtifactPayload, options: NextArtifactOptions): void { + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + return; + } + + if (payload.done) { + console.log('All artifacts complete.'); + return; + } + + // Non-JSON: print a compact summary. Agents will use --json; humans get this + // as a sanity check. + console.log(`Next artifact: ${payload.artifactId}`); + console.log(`Output: ${payload.resolvedOutputPath}`); + if (payload.description) console.log(`Description: ${payload.description}`); + console.log(); + console.log('Use --json for the full instructions payload.'); +} diff --git a/src/commands/workflow/resolve-change.ts b/src/commands/workflow/resolve-change.ts new file mode 100644 index 000000000..e823673c8 --- /dev/null +++ b/src/commands/workflow/resolve-change.ts @@ -0,0 +1,120 @@ +/** + * Resolve-Change Command + * + * Helper for AI skills and scripts. Resolves the "which change are we working + * on?" question without forcing every caller to reimplement listing + filtering + * + interactive picking. + * + * Modes: + * - No name, no `--auto`: emit JSON listing every active change. + * - `` supplied: verify it exists (active, not archived); echo the name + * on success. + * - `--auto`: succeed iff exactly one active change exists; echo its name. + * Useful in scripts that should run when the workspace is unambiguous. + * + * Exit codes: + * 0 success (echo or list) + * 1 no active changes (only with --auto) + * 2 named change not found + * 3 multiple active changes (only with --auto) + */ + +import * as fs from 'fs'; +import path from 'path'; +import { resolveCurrentPlanningHomeSync } from '../../core/planning-home.js'; +import { validateChangeName } from '../../utils/change-utils.js'; +import { getAvailableChanges } from './shared.js'; + +export interface ResolveChangeOptions { + auto?: boolean; + json?: boolean; +} + +interface ResolvedChange { + name: string; + path: string; +} + +async function listActiveChanges(projectRoot: string, changesDir: string): Promise { + const names = await getAvailableChanges(projectRoot, changesDir); + const resolved: ResolvedChange[] = []; + for (const name of names) { + const dir = path.join(changesDir, name); + try { + if (fs.statSync(dir).isDirectory()) { + resolved.push({ name, path: dir }); + } + } catch { + // Skip entries that disappeared between readdir and stat. + } + } + return resolved; +} + +/** + * Exits with `code`. Tests and callers can intercept via process.exitCode if + * they prefer; we use process.exit so the CLI matches existing behavior. + */ +function fail(message: string, code: number): never { + console.error(message); + process.exit(code); +} + +export async function resolveChangeCommand( + name: string | undefined, + options: ResolveChangeOptions +): Promise { + const planningHome = resolveCurrentPlanningHomeSync(); + const projectRoot = planningHome.root; + const changesDir = planningHome.changesDir; + + const changes = await listActiveChanges(projectRoot, changesDir); + + // Named lookup: validate the name and confirm it is active. + if (name) { + const nameValidation = validateChangeName(name); + if (!nameValidation.valid) { + fail(`Invalid change name '${name}': ${nameValidation.error}`, 2); + } + + const match = changes.find((c) => c.name === name); + if (!match) { + const available = changes.map((c) => c.name).join(', ') || '(none)'; + fail( + `Change '${name}' is not active. Active changes: ${available}`, + 2 + ); + } + + if (options.json) { + console.log(JSON.stringify({ name: match.name, path: match.path }, null, 2)); + } else { + console.log(match.name); + } + return; + } + + // --auto: succeed only if exactly one active change exists. + if (options.auto) { + if (changes.length === 0) { + fail('No active changes.', 1); + } + if (changes.length > 1) { + const list = changes.map((c) => c.name).join(', '); + fail( + `Multiple active changes (${changes.length}): ${list}. Pass a name or drop --auto.`, + 3 + ); + } + const only = changes[0]; + if (options.json) { + console.log(JSON.stringify({ name: only.name, path: only.path }, null, 2)); + } else { + console.log(only.name); + } + return; + } + + // Default: emit JSON listing. + console.log(JSON.stringify({ changes }, null, 2)); +} diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index b7d2a995c..b242db921 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -20,6 +20,13 @@ export interface TaskItem { id: string; description: string; done: boolean; + /** + * Hierarchical task id captured from the start of the description when the + * tasks file uses numbered tasks (e.g. `- [ ] 1.1 Wire foo`). Useful for + * agents that mark tasks complete by author-supplied id rather than by + * position. Absent when the line has no leading `N(.N)*` token. + */ + numericId?: string; } export interface ApplyInstructions { @@ -37,6 +44,13 @@ export interface ApplyInstructions { state: 'blocked' | 'all_done' | 'ready'; missingArtifacts?: string[]; instruction: string; + /** + * First unchecked task that has a `numericId` (in document order). Lets + * agents drive `openspec mark-task-done` without parsing the tasks list + * themselves. `null` when no such task exists (all done, or no task uses + * numeric ids). + */ + nextPendingId: string | null; } // ----------------------------------------------------------------------------- From b85d8b47b5bc86252612d0dc1fc075ef4fa1b94b Mon Sep 17 00:00:00 2001 From: Jussi Rantala Date: Wed, 27 May 2026 12:34:56 +0300 Subject: [PATCH 2/4] feat(templates): refactor propose + apply-change to use new agent helpers - propose template: collapse status + instructions loop into a single next-artifact call. Drops one round-trip per artifact and removes ~30 lines of LLM-facing orchestration prose from both the skill and the command variants. - apply-change template: replace 'openspec list --json + AskUserQuestion' flow with 'openspec resolve-change --auto'; collapse status + apply instructions into a single 'instructions apply --change --json' call (which now includes schemaName + nextPendingId); swap manual checkbox editing for 'openspec mark-task-done'. - Update completion command registry with the three new commands. - Refresh hash gates in skill-templates-parity test. - Add CLI docs (docs/cli.md) for resolve-change / next-artifact / mark-task-done plus a note on the new numericId / nextPendingId fields on 'instructions apply --json'. - Add agent-helpers changeset (minor). --- .changeset/agent-helpers.md | 37 ++ docs/cli.md | 167 +++++++ src/core/completions/command-registry.ts | 56 +++ src/core/templates/workflows/apply-change.ts | 140 +++--- src/core/templates/workflows/propose.ts | 126 ++---- test/commands/agent-helpers.test.ts | 423 ++++++++++++++++++ .../templates/skill-templates-parity.test.ts | 12 +- 7 files changed, 789 insertions(+), 172 deletions(-) create mode 100644 .changeset/agent-helpers.md create mode 100644 test/commands/agent-helpers.test.ts diff --git a/.changeset/agent-helpers.md b/.changeset/agent-helpers.md new file mode 100644 index 000000000..fdc00d67e --- /dev/null +++ b/.changeset/agent-helpers.md @@ -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 `** — agent helper that returns the + next ready artifact bundled with its full `instructions` payload. Replaces + the two-call `status --json` + `instructions --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 --change `** — 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 --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. diff --git a/docs/cli.md b/docs/cli.md index 73c1b0740..1c59aec03 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 | @@ -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 --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] +``` + +**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 --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 [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--change ` | Change name (required) | +| `--schema ` | 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 --change [options] +``` + +**Arguments:** + +| Argument | Required | Description | +|----------|----------|-------------| +| `task-id` | Yes | The hierarchical task id (matches `numericId` from `instructions apply`) | + +**Options:** + +| Option | Description | +|--------|-------------| +| `--change ` | Change name (required) | +| `--schema ` | 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` diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 85c05d08b..855c9b818 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -256,6 +256,62 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'resolve-change', + description: + 'Resolve an active change by name, list active changes, or auto-select the only one', + acceptsPositional: true, + positionals: [{ name: 'name', optional: true, type: 'change-id' }], + positionalType: 'change-id', + flags: [ + { + name: 'auto', + description: 'Succeed only when exactly one active change exists', + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'next-artifact', + description: + 'Return the next ready artifact for a change, bundled with its instructions (JSON by default)', + flags: [ + { + name: 'change', + description: 'Change name', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + { + name: 'no-json', + description: 'Print a human-readable summary instead of JSON', + }, + ], + }, + { + name: 'mark-task-done', + description: + "Mark a task complete in the change's tracking file (idempotent)", + acceptsPositional: true, + positionals: [{ name: 'task-id' }], + flags: [ + { + name: 'change', + description: 'Change name', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, { name: 'set', description: 'Set checked-in OpenSpec metadata', diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index ec5b59ab1..caa1d5949 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -18,64 +18,48 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { 1. **Select the change** - If a name is provided, use it. Otherwise: - - Infer from conversation context if the user mentioned a change - - Auto-select if only one active change exists - - If ambiguous, run \`openspec list --json\` to get available changes and use the **AskUserQuestion tool** to let the user select - - Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). - -2. **Check status to understand the schema** + If a name is provided, use it. Otherwise resolve via: \`\`\`bash - openspec status --change "" --json + openspec resolve-change --auto \`\`\` - Parse the JSON to understand: - - \`schemaName\`: The workflow being used (e.g., "spec-driven") - - \`planningHome\`, \`changeRoot\`, and \`actionContext\`: planning scope and edit constraints - - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + - Exits 0 with the change name when exactly one active change exists. + - Exits 1 (none) or 3 (ambiguous). On ambiguity, run \`openspec resolve-change --json\` and use the **AskUserQuestion tool** to let the user select. -3. **Get apply instructions** + Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). +2. **Load apply context (status + tasks + instruction in one call)** \`\`\`bash openspec instructions apply --change "" --json \`\`\` - This returns: - - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) - - Progress (total, complete, remaining) - - Task list with status - - Dynamic instruction based on current state - - **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using openspec-continue-change - - If \`state: "all_done"\`: congratulate, suggest archive - - Otherwise: proceed to implementation + Use the returned JSON: + - \`schemaName\`: the workflow being used (e.g., "spec-driven") + - \`contextFiles\`: artifact ID → array of file paths (varies by schema — proposal/specs/design/tasks for spec-driven) + - \`progress\` (total / complete / remaining), \`tasks\` (with \`numericId\` when the file uses numbered tasks), \`nextPendingId\` (the first unchecked task with a \`numericId\`) + - \`state\`: \`"blocked"\` (missing artifacts → suggest openspec-continue-change), \`"all_done"\` (congratulate, suggest archive), or \`"ready"\` (proceed) + - \`instruction\`: dynamic guidance based on current state - **Workspace guard:** If status JSON reports \`actionContext.mode: "workspace-planning"\` and \`allowedEditRoots\` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. + **Workspace guard:** If \`openspec status --change "" --json\` reports \`actionContext.mode: "workspace-planning"\` and \`allowedEditRoots\` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. -4. **Read context files** +3. **Read context files** - Read every file path listed under \`contextFiles\` from the apply instructions output. - The files depend on the schema being used: - - **spec-driven**: proposal, specs, design, tasks - - Other schemas: follow the contextFiles from CLI output + Read every file path listed under \`contextFiles\`. -5. **Show current progress** +4. **Show current progress** - Display: - - Schema being used - - Progress: "N/M tasks complete" - - Remaining tasks overview - - Dynamic instruction from CLI + Display schema, "N/M tasks complete", remaining tasks overview, and the dynamic \`instruction\`. -6. **Implement tasks (loop until done or blocked)** +5. **Implement tasks (loop until done or blocked)** - For each pending task: + For each pending task (drive by \`nextPendingId\`, or by document order for non-numeric tasks): - Show which task is being worked on - - Make the code changes required - - Keep changes minimal and focused - - Mark task complete in the tasks file: \`- [ ]\` → \`- [x]\` - - Continue to next task + - Make the code changes required (keep changes minimal and focused) + - Mark the task complete via the CLI — idempotent, anchored on the task id so \`1.1\` does not match \`1.10\`: + \`\`\`bash + openspec mark-task-done --change "" "" + \`\`\` + where \`\` is the task's \`numericId\` (e.g., \`1.1\`). For tasks without a \`numericId\`, edit the file directly (\`- [ ]\` → \`- [x]\`). + - Re-run \`openspec instructions apply --change "" --json\` to refresh \`nextPendingId\` and continue. **Pause if:** - Task is unclear → ask for clarification @@ -83,7 +67,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - Error or blocker encountered → report and wait for guidance - User interrupts -7. **On completion or pause, show status** +6. **On completion or pause, show status** Display: - Tasks completed this session @@ -178,64 +162,48 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { 1. **Select the change** - If a name is provided, use it. Otherwise: - - Infer from conversation context if the user mentioned a change - - Auto-select if only one active change exists - - If ambiguous, run \`openspec list --json\` to get available changes and use the **AskUserQuestion tool** to let the user select - - Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). - -2. **Check status to understand the schema** + If a name is provided, use it. Otherwise resolve via: \`\`\`bash - openspec status --change "" --json + openspec resolve-change --auto \`\`\` - Parse the JSON to understand: - - \`schemaName\`: The workflow being used (e.g., "spec-driven") - - \`planningHome\`, \`changeRoot\`, and \`actionContext\`: planning scope and edit constraints - - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + - Exits 0 with the change name when exactly one active change exists. + - Exits 1 (none) or 3 (ambiguous). On ambiguity, run \`openspec resolve-change --json\` and use the **AskUserQuestion tool** to let the user select. -3. **Get apply instructions** + Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). +2. **Load apply context (status + tasks + instruction in one call)** \`\`\`bash openspec instructions apply --change "" --json \`\`\` - This returns: - - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema) - - Progress (total, complete, remaining) - - Task list with status - - Dynamic instruction based on current state - - **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using \`/opsx:continue\` - - If \`state: "all_done"\`: congratulate, suggest archive - - Otherwise: proceed to implementation + Use the returned JSON: + - \`schemaName\`: the workflow being used (e.g., "spec-driven") + - \`contextFiles\`: artifact ID → array of file paths (varies by schema) + - \`progress\` (total / complete / remaining), \`tasks\` (with \`numericId\` when the file uses numbered tasks), \`nextPendingId\` (the first unchecked task with a \`numericId\`) + - \`state\`: \`"blocked"\` (missing artifacts → suggest \`/opsx:continue\`), \`"all_done"\` (congratulate, suggest archive), or \`"ready"\` (proceed) + - \`instruction\`: dynamic guidance based on current state - **Workspace guard:** If status JSON reports \`actionContext.mode: "workspace-planning"\` and \`allowedEditRoots\` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. + **Workspace guard:** If \`openspec status --change "" --json\` reports \`actionContext.mode: "workspace-planning"\` and \`allowedEditRoots\` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. -4. **Read context files** +3. **Read context files** - Read every file path listed under \`contextFiles\` from the apply instructions output. - The files depend on the schema being used: - - **spec-driven**: proposal, specs, design, tasks - - Other schemas: follow the contextFiles from CLI output + Read every file path listed under \`contextFiles\`. -5. **Show current progress** +4. **Show current progress** - Display: - - Schema being used - - Progress: "N/M tasks complete" - - Remaining tasks overview - - Dynamic instruction from CLI + Display schema, "N/M tasks complete", remaining tasks overview, and the dynamic \`instruction\`. -6. **Implement tasks (loop until done or blocked)** +5. **Implement tasks (loop until done or blocked)** - For each pending task: + For each pending task (drive by \`nextPendingId\`, or by document order for non-numeric tasks): - Show which task is being worked on - - Make the code changes required - - Keep changes minimal and focused - - Mark task complete in the tasks file: \`- [ ]\` → \`- [x]\` - - Continue to next task + - Make the code changes required (keep changes minimal and focused) + - Mark the task complete via the CLI — idempotent, anchored on the task id so \`1.1\` does not match \`1.10\`: + \`\`\`bash + openspec mark-task-done --change "" "" + \`\`\` + where \`\` is the task's \`numericId\` (e.g., \`1.1\`). For tasks without a \`numericId\`, edit the file directly (\`- [ ]\` → \`- [x]\`). + - Re-run \`openspec instructions apply --change "" --json\` to refresh \`nextPendingId\` and continue. **Pause if:** - Task is unclear → ask for clarification @@ -243,7 +211,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { - Error or blocker encountered → report and wait for guidance - User interrupts -7. **On completion or pause, show status** +6. **On completion or pause, show status** Display: - Tasks completed this session diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index c288cf8d0..6f05ebadd 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -40,48 +40,31 @@ When ready to implement, run /opsx:apply \`\`\` This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. -3. **Get the artifact build order** +3. **Drive the artifact loop with \`next-artifact\`** + + Use the **TodoWrite tool** to track progress. + + Repeat until \`{ "done": true }\`: \`\`\`bash - openspec status --change "" --json + openspec next-artifact --change "" \`\`\` - Parse the JSON to get: - - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - - \`artifacts\`: list of all artifacts with their status and dependencies - - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. - -4. **Create artifacts in sequence until apply-ready** - - Use the **TodoWrite tool** to track progress through the artifacts. - - Loop through artifacts in dependency order (artifacts with no pending dependencies first): - - a. **For each artifact that is \`ready\` (dependencies satisfied)**: - - Get instructions: - \`\`\`bash - openspec instructions --change "" --json - \`\`\` - - The instructions JSON includes: - - \`context\`: Project background (constraints for you - do NOT include in output) - - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - - \`template\`: The structure to use for your output file - - \`instruction\`: Schema-specific guidance for this artifact type - - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - - \`dependencies\`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\` - - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file - - Show brief progress: "Created " - - b. **Continue until all \`applyRequires\` artifacts are complete** - - After creating each artifact, re-run \`openspec status --change "" --json\` - - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - - Stop when all \`applyRequires\` artifacts are done - - c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify - - Then continue with creation - -5. **Show final status** + + Each non-done response returns the JSON instructions payload for exactly one ready artifact: + - \`artifactId\`, \`resolvedOutputPath\`: which file to write and where + - \`template\`: the structure to use for your output (fill in its sections) + - \`instruction\`: schema-specific guidance for this artifact type + - \`context\`, \`rules\`: constraints for YOU — do NOT copy these into the file + - \`dependencies\`: completed artifacts to read for context before writing + + For each response: + 1. Read any \`dependencies\` files for context + 2. Write the artifact file (use \`template\` as the structure, write to \`resolvedOutputPath\`) + 3. Show brief progress: "Created " + 4. Re-invoke \`openspec next-artifact --change ""\` and continue + + If an artifact requires user input (context unclear), use the **AskUserQuestion tool** to clarify, then continue. + +4. **Show final status** \`\`\`bash openspec status --change "" \`\`\` @@ -152,48 +135,31 @@ When ready to implement, run /opsx:apply \`\`\` This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. -3. **Get the artifact build order** +3. **Drive the artifact loop with \`next-artifact\`** + + Use the **TodoWrite tool** to track progress. + + Repeat until \`{ "done": true }\`: \`\`\`bash - openspec status --change "" --json + openspec next-artifact --change "" \`\`\` - Parse the JSON to get: - - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - - \`artifacts\`: list of all artifacts with their status and dependencies - - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. - -4. **Create artifacts in sequence until apply-ready** - - Use the **TodoWrite tool** to track progress through the artifacts. - - Loop through artifacts in dependency order (artifacts with no pending dependencies first): - - a. **For each artifact that is \`ready\` (dependencies satisfied)**: - - Get instructions: - \`\`\`bash - openspec instructions --change "" --json - \`\`\` - - The instructions JSON includes: - - \`context\`: Project background (constraints for you - do NOT include in output) - - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - - \`template\`: The structure to use for your output file - - \`instruction\`: Schema-specific guidance for this artifact type - - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - - \`dependencies\`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\` - - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file - - Show brief progress: "Created " - - b. **Continue until all \`applyRequires\` artifacts are complete** - - After creating each artifact, re-run \`openspec status --change "" --json\` - - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - - Stop when all \`applyRequires\` artifacts are done - - c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify - - Then continue with creation - -5. **Show final status** + + Each non-done response returns the JSON instructions payload for exactly one ready artifact: + - \`artifactId\`, \`resolvedOutputPath\`: which file to write and where + - \`template\`: the structure to use for your output (fill in its sections) + - \`instruction\`: schema-specific guidance for this artifact type + - \`context\`, \`rules\`: constraints for YOU — do NOT copy these into the file + - \`dependencies\`: completed artifacts to read for context before writing + + For each response: + 1. Read any \`dependencies\` files for context + 2. Write the artifact file (use \`template\` as the structure, write to \`resolvedOutputPath\`) + 3. Show brief progress: "Created " + 4. Re-invoke \`openspec next-artifact --change ""\` and continue + + If an artifact requires user input (context unclear), use the **AskUserQuestion tool** to clarify, then continue. + +4. **Show final status** \`\`\`bash openspec status --change "" \`\`\` diff --git a/test/commands/agent-helpers.test.ts b/test/commands/agent-helpers.test.ts new file mode 100644 index 000000000..a202df4fa --- /dev/null +++ b/test/commands/agent-helpers.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { runCLI } from '../helpers/run-cli.js'; + +/** + * End-to-end coverage for the three agent-facing helper commands: + * - openspec resolve-change + * - openspec next-artifact + * - openspec mark-task-done + * + * Plus enrichment cases for the existing `instructions apply --json` + * payload (numericId / nextPendingId on tasks). + * + * Mirrors the fixture style in `artifact-workflow.test.ts`: each test + * gets its own tmpdir with a freshly scaffolded `openspec/changes/` + * tree. We do not call `openspec init` because the workflow commands + * only need the changes directory to exist. + */ +describe('agent helper CLI commands', () => { + let tempDir: string; + let changesDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-agent-helpers-')); + changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + function getOutput(result: { stdout: string; stderr: string }): string { + return result.stdout + result.stderr; + } + + async function makeChange( + changeName: string, + artifacts: ('proposal' | 'design' | 'specs' | 'tasks')[] = [] + ): Promise { + const changeDir = path.join(changesDir, changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // Proposal always present so the change is detected as active. + const proposalContent = artifacts.includes('proposal') + ? '## Why\nReason long enough to satisfy validation.\n\n## What Changes\n- **test:** Something' + : '## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder'; + await fs.writeFile(path.join(changeDir, 'proposal.md'), proposalContent); + + if (artifacts.includes('design')) { + await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\nTechnical design.'); + } + if (artifacts.includes('specs')) { + const specsDir = path.join(changeDir, 'specs'); + await fs.mkdir(specsDir, { recursive: true }); + await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.'); + } + if (artifacts.includes('tasks')) { + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '## Tasks\n- [ ] 1.1 First task\n- [ ] 1.2 Second task\n' + ); + } + + return changeDir; + } + + // --------------------------------------------------------------------------- + // resolve-change + // --------------------------------------------------------------------------- + describe('resolve-change command', () => { + it('prints empty list when no changes exist', async () => { + const result = await runCLI(['resolve-change'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + }); + + it('lists every active change as JSON when no name and no --auto', async () => { + await makeChange('alpha'); + await makeChange('beta'); + + const result = await runCLI(['resolve-change'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.changes.map((c: { name: string }) => c.name).sort()).toEqual(['alpha', 'beta']); + }); + + it('echoes the name when a valid change is supplied', async () => { + await makeChange('alpha'); + const result = await runCLI(['resolve-change', 'alpha'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('alpha'); + }); + + it('exits 2 when the named change does not exist', async () => { + await makeChange('alpha'); + const result = await runCLI(['resolve-change', 'bogus'], { cwd: tempDir }); + expect(result.exitCode).toBe(2); + expect(getOutput(result)).toContain("Change 'bogus' is not active"); + expect(getOutput(result)).toContain('alpha'); + }); + + it('exits 2 with an invalid change name (path traversal)', async () => { + const result = await runCLI(['resolve-change', '../etc'], { cwd: tempDir }); + expect(result.exitCode).toBe(2); + expect(getOutput(result)).toContain('Invalid change name'); + }); + + it('--auto exits 1 when no changes exist', async () => { + const result = await runCLI(['resolve-change', '--auto'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + expect(getOutput(result)).toContain('No active changes'); + }); + + it('--auto echoes the only active change', async () => { + await makeChange('only-one'); + const result = await runCLI(['resolve-change', '--auto'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('only-one'); + }); + + it('--auto exits 3 with a distinct message when multiple changes are active', async () => { + await makeChange('alpha'); + await makeChange('beta'); + const result = await runCLI(['resolve-change', '--auto'], { cwd: tempDir }); + expect(result.exitCode).toBe(3); + const out = getOutput(result); + expect(out).toContain('Multiple active changes'); + expect(out).toContain('alpha'); + expect(out).toContain('beta'); + }); + + it('--json with a named change emits structured payload', async () => { + await makeChange('alpha'); + const result = await runCLI(['resolve-change', 'alpha', '--json'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.name).toBe('alpha'); + expect(typeof json.path).toBe('string'); + expect(json.path.endsWith('alpha')).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // next-artifact + // --------------------------------------------------------------------------- + describe('next-artifact command', () => { + it('returns proposal as the first ready artifact on a scaffolded change', async () => { + const changeDir = path.join(changesDir, 'scaffolded'); + await fs.mkdir(changeDir, { recursive: true }); + + const result = await runCLI(['next-artifact', '--change', 'scaffolded'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.done).toBe(false); + expect(json.artifactId).toBe('proposal'); + expect(json.template).toBeTruthy(); + expect(Array.isArray(json.dependencies)).toBe(true); + }); + + it('skips completed artifacts and surfaces the next ready one', async () => { + // proposal + design done; next ready should be specs (no longer blocked + // by design). + await makeChange('mid-stream', ['proposal', 'design']); + + const result = await runCLI(['next-artifact', '--change', 'mid-stream'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.done).toBe(false); + // spec-driven order: proposal → design → specs → tasks. With proposal+design + // done, "specs" is the next ready node. + expect(json.artifactId).toBe('specs'); + }); + + it('emits { done: true } when every artifact is complete', async () => { + await makeChange('all-done', ['proposal', 'design', 'specs', 'tasks']); + const result = await runCLI(['next-artifact', '--change', 'all-done'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ done: true }); + }); + + it('--no-json prints a compact human summary', async () => { + const changeDir = path.join(changesDir, 'humanmode'); + await fs.mkdir(changeDir, { recursive: true }); + + const result = await runCLI(['next-artifact', '--change', 'humanmode', '--no-json'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Next artifact: proposal'); + expect(result.stdout).toContain('Use --json'); + }); + + it('errors with a helpful message when --change is omitted', async () => { + await makeChange('something'); + const result = await runCLI(['next-artifact'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + expect(getOutput(result)).toContain('Missing required option --change'); + }); + }); + + // --------------------------------------------------------------------------- + // mark-task-done + parser/nextPendingId enrichment + // --------------------------------------------------------------------------- + describe('mark-task-done command', () => { + async function writeTasks(changeName: string, body: string): Promise { + const tasksPath = path.join(changesDir, changeName, 'tasks.md'); + await fs.writeFile(tasksPath, body); + return tasksPath; + } + + it('flips a single unchecked task and reports flipped status', async () => { + await makeChange('flipme', ['proposal', 'design', 'specs']); + const tasksPath = await writeTasks( + 'flipme', + '## Tasks\n- [ ] 1.1 First\n- [ ] 1.2 Second\n' + ); + + const result = await runCLI( + ['mark-task-done', '--change', 'flipme', '1.1', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.status).toBe('flipped'); + expect(payload.taskId).toBe('1.1'); + + const after = await fs.readFile(tasksPath, 'utf8'); + expect(after).toContain('- [x] 1.1 First'); + expect(after).toContain('- [ ] 1.2 Second'); + }); + + it('is idempotent on an already-checked task', async () => { + await makeChange('already-done', ['proposal', 'design', 'specs']); + await writeTasks( + 'already-done', + '## Tasks\n- [x] 1.1 Done\n- [ ] 1.2 Pending\n' + ); + + const result = await runCLI( + ['mark-task-done', '--change', 'already-done', '1.1', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.status).toBe('already-done'); + }); + + it('distinguishes 1.1 from 1.10 with a hierarchical-id boundary', async () => { + await makeChange('boundary', ['proposal', 'design', 'specs']); + const tasksPath = await writeTasks( + 'boundary', + '## Tasks\n- [ ] 1.1 First\n- [ ] 1.10 Tenth\n' + ); + + // Mark 1.1; 1.10 must stay unchecked. + const r1 = await runCLI( + ['mark-task-done', '--change', 'boundary', '1.1'], + { cwd: tempDir } + ); + expect(r1.exitCode).toBe(0); + + let body = await fs.readFile(tasksPath, 'utf8'); + expect(body).toContain('- [x] 1.1 First'); + expect(body).toContain('- [ ] 1.10 Tenth'); + + // Now mark 1.10; 1.1 stays as it was. + const r2 = await runCLI( + ['mark-task-done', '--change', 'boundary', '1.10'], + { cwd: tempDir } + ); + expect(r2.exitCode).toBe(0); + body = await fs.readFile(tasksPath, 'utf8'); + expect(body).toContain('- [x] 1.10 Tenth'); + }); + + it('exits 2 when the task id has no matching line', async () => { + await makeChange('nomatch', ['proposal', 'design', 'specs']); + await writeTasks('nomatch', '## Tasks\n- [ ] 1.1 Only one\n'); + + const result = await runCLI( + ['mark-task-done', '--change', 'nomatch', '9.9'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(2); + expect(getOutput(result)).toContain("No unchecked task line with id '9.9'"); + }); + + it('preserves CRLF line endings when rewriting the file', async () => { + await makeChange('crlf', ['proposal', 'design', 'specs']); + const tasksPath = path.join(changesDir, 'crlf', 'tasks.md'); + // Explicit CRLF body. + const crlfBody = '## Tasks\r\n- [ ] 1.1 First\r\n- [ ] 1.2 Second\r\n'; + await fs.writeFile(tasksPath, crlfBody); + + const result = await runCLI( + ['mark-task-done', '--change', 'crlf', '1.1'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + + const after = await fs.readFile(tasksPath, 'utf8'); + // Should still use CRLF. + expect(after.includes('\r\n')).toBe(true); + expect(after).toContain('- [x] 1.1 First\r\n'); + expect(after).toContain('- [ ] 1.2 Second\r\n'); + }); + + it('errors when the schema has no apply.tracks configured', async () => { + // Build a custom schema with no tracks setting. + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'no-tracks'); + const templatesDir = path.join(schemaDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + `name: no-tracks +version: 1 +description: Schema without an apply.tracks configuration +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md + requires: [] +apply: + requires: [proposal] + instruction: Proceed. +` + ); + await fs.writeFile(path.join(templatesDir, 'proposal.md'), '# Proposal\n'); + + const changeDir = path.join(changesDir, 'no-tracks-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: no-tracks\n'); + await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Proposal\n'); + + const result = await runCLI( + ['mark-task-done', '--change', 'no-tracks-change', '1.1'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(2); + expect(getOutput(result)).toContain("does not configure 'apply.tracks'"); + }); + }); + + // --------------------------------------------------------------------------- + // parseTasksFile enrichment + nextPendingId (via existing `instructions apply --json`) + // --------------------------------------------------------------------------- + describe('instructions apply --json (enriched payload)', () => { + it('extracts numericId from numbered tasks and exposes nextPendingId', async () => { + await makeChange('enriched', ['proposal', 'design', 'specs']); + await fs.writeFile( + path.join(changesDir, 'enriched', 'tasks.md'), + [ + '## Tasks', + '- [ ] 1.1 First', + '- [x] 1.2 Already done', + '- [ ] 1.10 Tenth', + '- [ ] 2.1 Section two', + ].join('\n') + '\n' + ); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'enriched', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + + const numericIds = json.tasks.map((t: { numericId?: string }) => t.numericId); + expect(numericIds).toEqual(['1.1', '1.2', '1.10', '2.1']); + + // Description should no longer contain the numeric prefix. + const first = json.tasks[0]; + expect(first.description).toBe('First'); + expect(first.done).toBe(false); + + // First unchecked task with a numericId, in document order. + expect(json.nextPendingId).toBe('1.1'); + }); + + it('leaves tasks without numeric prefix unaffected and falls back to null nextPendingId', async () => { + await makeChange('unnumbered', ['proposal', 'design', 'specs']); + await fs.writeFile( + path.join(changesDir, 'unnumbered', 'tasks.md'), + '## Tasks\n- [ ] Plain unnumbered task\n- [x] Another plain task\n' + ); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'unnumbered', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + + expect(json.tasks).toHaveLength(2); + expect(json.tasks[0].numericId).toBeUndefined(); + expect(json.tasks[0].description).toBe('Plain unnumbered task'); + expect(json.nextPendingId).toBeNull(); + }); + + it('returns null nextPendingId when every numeric task is done', async () => { + await makeChange('alldone', ['proposal', 'design', 'specs']); + await fs.writeFile( + path.join(changesDir, 'alldone', 'tasks.md'), + '## Tasks\n- [x] 1.1 First\n- [x] 1.2 Second\n' + ); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'alldone', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.nextPendingId).toBeNull(); + }); + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index f851082e5..9dbe3db42 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -33,14 +33,14 @@ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: 'e2765fae6c2e960f4ce07058cfdaa547ff3435d454eacd5e924e38139e97ad52', getNewChangeSkillTemplate: 'b0c26f0b65380062e586505c08c72230e59dccea89e6acca7b673f01cba70d5a', getContinueChangeSkillTemplate: 'fbc6c379ed3dd39f59f52b10584b8df5b1dc08b5422bcf1c6d6255a944d22a11', - getApplyChangeSkillTemplate: 'e746f230c2513a5fd40842bde494bb3cdb3c5f7c1bcece101f92090983d4ff55', + getApplyChangeSkillTemplate: '85373afd0a0252ec2c51c91a6ced48221b9d3deecb6f7c6eadd65be975090da1', getFfChangeSkillTemplate: '50e68fbb49b76d2690b614bffa9e6210e45539fb74419fc2e4311158b6d38485', getSyncSpecsSkillTemplate: '9f02b41227db70875b89eefeb275c769142607dc5b2593f4e606794aed2fdbad', getOnboardSkillTemplate: '4f4b60fea6e3fc7d2185815b2808fad51535fdd00cd4401b32d1536f32fa2b6d', getOpsxExploreCommandTemplate: '4d5e64e3ede6703113cf2fd23b797371ef2407b702478b4f7240fc81cbf2d3a5', getOpsxNewCommandTemplate: '757f72e2d9a1a6794b2188704fd39dd2ab65428899b4b361c76cc15a5e4f2ccc', getOpsxContinueCommandTemplate: '62f8863edda2bfe4e210f8bc3095fd4369aaaaf7772a5cba9602d0f0bca1d0c9', - getOpsxApplyCommandTemplate: '812feefd32a4d9d468e03e456d06e3d2d08d1118d29cce4911f0be59cdd30bfc', + getOpsxApplyCommandTemplate: 'e10d0f752f72af28a9825e65d9ba87e6b91f576fb05bfdb57ad319ddfe35147b', getOpsxFfCommandTemplate: 'f775b242bcfd56594c431c7f31a0129208a1bacfdb2427074d412543072ef7ca', getArchiveChangeSkillTemplate: 'bdf022ae2cdef1feef4d641a068bef3a7fc5d98a323f7ce9f77ac578fe8d20c6', getBulkArchiveChangeSkillTemplate: 'fdb1715804e86de85be96222b8efeb9d5b350c6d5c19e343e244655deff8e62b', @@ -50,8 +50,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxOnboardCommandTemplate: '57c1f3e2590bda8f47818bab1d528456c1b8a9a7501f63ab9e2115e0cfaf6f35', getOpsxBulkArchiveCommandTemplate: 'b76c421023ccb5a12867c349f27cdb186234b692c1811980fb94127567bdabda', getOpsxVerifyCommandTemplate: '9a7a3f9e5bc3d0c0878b1a4493efbbb38729597d9b9be78f63284cc2da7c20c3', - getOpsxProposeSkillTemplate: 'bae22279f8c7f711a8d5c5289551551d48197ddf5a99b695d96fff5339e08a49', - getOpsxProposeCommandTemplate: '870ab824c2aeb825fe3fe161a1f223633b4fff308ecaeb8197cbf309db2ddf02', + getOpsxProposeSkillTemplate: 'ce7fa88b55ca4bbf31c3c4faf36e6a3f73df16545d2ca40199ca45682b5be08b', + getOpsxProposeCommandTemplate: 'cf40c87bbb327f3262df232c2db175a908bfcea72f84f8aaf9a236abbf750130', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; @@ -59,14 +59,14 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': '28d900ef82b325beb65e69ee6435949adcfdf14a4314638e7006e6dc359b92d4', 'openspec-new-change': 'c99989810f982d72eefc74a35f2282b71f1956f23f61b83aaa58fa3dd921716f', 'openspec-continue-change': 'c00e2a60f79cd60197094cc59762babe5ee6a2dc1e859a0ede3f436a775ccecf', - 'openspec-apply-change': 'd849442efd925b9247651e254a5cd696945321610cca5a9432ad420430554548', + 'openspec-apply-change': '82a2e8242af2a13ad18794225faedb6cbb38f4ba87145615d85b85575e1fb855', 'openspec-ff-change': '9d9b1995b6f4adb3da570676f7d11fee4cd1cf6c5df8ec83c033e02783a544df', 'openspec-sync-specs': '2e0f67ec6fadffc6107b4b1a28eef23a99a6649e5fae706897ea1dd9deb852a8', 'openspec-archive-change': '8d14af2c8b2e4358308ac9fc14f75db42a4b41a07e175825035852a82479793e', 'openspec-bulk-archive-change': '16207683996b1952559cd4e33463f28fb097761f2c5d912107733d01a90d3f2f', 'openspec-verify-change': 'a2acecd0c2b4e57080a314e5e7a093e0688293c37e446eb45d378f5050058550', 'openspec-onboard': 'b924ea3c97543ebb7ee82c5f194afe7ce87a521c32b85616f445240ab33a02ab', - 'openspec-propose': '56aa526fe1e9fac956ad3ad570a3a259d27f54b05086940d85af136a62069292', + 'openspec-propose': '144673bab646799fe21b7a19a57bb8416e190d48bbe821e916bc0a1737d7b363', }; function stableStringify(value: unknown): string { From 23d2a1b3687925404af2ebe8a8b154f69fa20ca8 Mon Sep 17 00:00:00 2001 From: Jussi Rantala Date: Wed, 27 May 2026 12:41:41 +0300 Subject: [PATCH 3/4] refactor(cli): group helpers under `openspec agent` namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three commands are explicitly skill-facing — keeping them as top-level verbs polluted `openspec --help` for human users with three lines they never need. Move them under a dedicated `openspec agent` subcommand group instead, so the top-level surface stays focused on human workflows and agents call `openspec agent ` explicitly. - src/cli/index.ts: nest under `agent` subcommand group. - src/core/completions/command-registry.ts: register `agent` as a subcommand group entry. - src/core/templates/workflows/{propose,apply-change}.ts: update skill + command template invocations to the new namespace. - docs/cli.md: rename section headers + Agent-Compatible table entries. - test/commands/agent-helpers.test.ts: prefix every runCLI call with `agent`. - test/core/templates/skill-templates-parity.test.ts: refresh function payload + generated content hashes for the two refactored templates. - .changeset/agent-helpers.md: re-describe surface as a command group. --- .changeset/agent-helpers.md | 30 +++--- docs/cli.md | 34 +++--- src/cli/index.ts | 23 ++-- src/core/completions/command-registry.ts | 101 ++++++++++-------- src/core/templates/workflows/apply-change.ts | 12 +-- src/core/templates/workflows/propose.ts | 8 +- test/commands/agent-helpers.test.ts | 48 ++++----- .../templates/skill-templates-parity.test.ts | 12 +-- 8 files changed, 140 insertions(+), 128 deletions(-) diff --git a/.changeset/agent-helpers.md b/.changeset/agent-helpers.md index fdc00d67e..2c14c6193 100644 --- a/.changeset/agent-helpers.md +++ b/.changeset/agent-helpers.md @@ -4,20 +4,22 @@ ### 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 `** — agent helper that returns the - next ready artifact bundled with its full `instructions` payload. Replaces - the two-call `status --json` + `instructions --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 --change `** — 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`. +- **`openspec agent` command group** — namespaced helpers for AI skills and + scripts, kept separate from the human-facing top-level surface. + - **`openspec agent resolve-change [name] [--auto] [--json]`** — 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 agent next-artifact --change `** — returns the next + ready artifact bundled with its full `instructions` payload. Replaces the + two-call `status --json` + `instructions --json` pattern with + one call. JSON by default; pass `--no-json` for a human summary. Emits + `{ "done": true }` once every artifact is complete. + - **`openspec agent mark-task-done --change `** — 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 diff --git a/docs/cli.md b/docs/cli.md index 1c59aec03..18654032d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,9 +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 agent resolve-change` | Locate active change | `--auto`/`--json` to drive skills without prompts | +| `openspec agent next-artifact` | One-call propose loop | JSON by default; bundles status + instructions | +| `openspec agent 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 | @@ -866,11 +866,11 @@ 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 agent mark-task-done` to drive an apply loop without re-parsing tasks.md. --- -### `openspec resolve-change` +### `openspec agent 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 @@ -878,7 +878,7 @@ exists. Useful at the top of every workflow skill where the change identity is the first question to answer. ``` -openspec resolve-change [name] [options] +openspec agent resolve-change [name] [options] ``` **Arguments:** @@ -898,13 +898,13 @@ openspec resolve-change [name] [options] ```bash # List every active change as JSON (default when no name and no --auto) -openspec resolve-change +openspec agent resolve-change # Validate a specific name (exit 2 on miss) -openspec resolve-change add-dark-mode +openspec agent resolve-change add-dark-mode # Auto-pick the only active change -openspec resolve-change --auto +openspec agent resolve-change --auto ``` **Exit codes:** @@ -918,7 +918,7 @@ openspec resolve-change --auto --- -### `openspec next-artifact` +### `openspec agent next-artifact` Agent helper: return the next ready artifact for a change, bundled with the full instructions payload for that artifact. Replaces the @@ -927,7 +927,7 @@ call. JSON is the default since agents are the primary consumers; pass `--no-json` for a human summary. ``` -openspec next-artifact --change [options] +openspec agent next-artifact --change [options] ``` **Options:** @@ -942,10 +942,10 @@ openspec next-artifact --change [options] ```bash # Get the next ready artifact and its instructions (JSON, default) -openspec next-artifact --change add-dark-mode +openspec agent next-artifact --change add-dark-mode # Compact human summary -openspec next-artifact --change add-dark-mode --no-json +openspec agent next-artifact --change add-dark-mode --no-json ``` **Output (JSON):** @@ -980,7 +980,7 @@ openspec next-artifact --change add-dark-mode --no-json --- -### `openspec mark-task-done` +### `openspec agent 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 @@ -990,7 +990,7 @@ CRLF vs LF line endings; anchored matching ensures `1.1` does not match `1.10`. ``` -openspec mark-task-done --change [options] +openspec agent mark-task-done --change [options] ``` **Arguments:** @@ -1011,11 +1011,11 @@ openspec mark-task-done --change [options] ```bash # Mark task 1.1 done -openspec mark-task-done --change add-dark-mode 1.1 +openspec agent 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" +openspec agent mark-task-done --change add-dark-mode "$TASK" ``` **Exit codes:** diff --git a/src/cli/index.ts b/src/cli/index.ts index a50785428..6c5dfbf0a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -538,9 +538,15 @@ newCmd } }); -// Resolve-change command (agent helper: locate active change by name or -// auto-select when unambiguous) -program +// Agent command group (subcommands intended for AI skills and scripts, not +// direct human use). Grouping them under a dedicated namespace keeps the +// top-level CLI surface focused on human workflows; agents call +// `openspec agent ` explicitly. +const agentCmd = program + .command('agent') + .description('Helpers for AI skills and scripts (not intended for direct human use)'); + +agentCmd .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') @@ -555,10 +561,9 @@ program } }); -// 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 +// JSON is the default for next-artifact since agents are the primary +// consumers; pass --no-json for the human summary. +agentCmd .command('next-artifact') .description('Return the next ready artifact for a change, bundled with its instructions (JSON by default)') .option('--change ', 'Change name') @@ -574,9 +579,7 @@ program } }); -// Mark-task-done command (agent helper: flip a tracked tasks.md checkbox by -// numeric task id) -program +agentCmd .command('mark-task-done ') .description('Mark a task complete in the change\'s tracking file (idempotent)') .option('--change ', 'Change name') diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 855c9b818..7d64caeed 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -257,59 +257,66 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ ], }, { - name: 'resolve-change', - description: - 'Resolve an active change by name, list active changes, or auto-select the only one', - acceptsPositional: true, - positionals: [{ name: 'name', optional: true, type: 'change-id' }], - positionalType: 'change-id', - flags: [ - { - name: 'auto', - description: 'Succeed only when exactly one active change exists', - }, - COMMON_FLAGS.json, - ], - }, - { - name: 'next-artifact', - description: - 'Return the next ready artifact for a change, bundled with its instructions (JSON by default)', - flags: [ - { - name: 'change', - description: 'Change name', - takesValue: true, - }, + name: 'agent', + description: 'Helpers for AI skills and scripts (not intended for direct human use)', + flags: [], + subcommands: [ { - name: 'schema', - description: 'Schema override', - takesValue: true, + name: 'resolve-change', + description: + 'Resolve an active change by name, list active changes, or auto-select the only one', + acceptsPositional: true, + positionals: [{ name: 'name', optional: true, type: 'change-id' }], + positionalType: 'change-id', + flags: [ + { + name: 'auto', + description: 'Succeed only when exactly one active change exists', + }, + COMMON_FLAGS.json, + ], }, { - name: 'no-json', - description: 'Print a human-readable summary instead of JSON', - }, - ], - }, - { - name: 'mark-task-done', - description: - "Mark a task complete in the change's tracking file (idempotent)", - acceptsPositional: true, - positionals: [{ name: 'task-id' }], - flags: [ - { - name: 'change', - description: 'Change name', - takesValue: true, + name: 'next-artifact', + description: + 'Return the next ready artifact for a change, bundled with its instructions (JSON by default)', + flags: [ + { + name: 'change', + description: 'Change name', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + { + name: 'no-json', + description: 'Print a human-readable summary instead of JSON', + }, + ], }, { - name: 'schema', - description: 'Schema override', - takesValue: true, + name: 'mark-task-done', + description: + "Mark a task complete in the change's tracking file (idempotent)", + acceptsPositional: true, + positionals: [{ name: 'task-id' }], + flags: [ + { + name: 'change', + description: 'Change name', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + COMMON_FLAGS.json, + ], }, - COMMON_FLAGS.json, ], }, { diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index caa1d5949..0b4e7da38 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -20,10 +20,10 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { If a name is provided, use it. Otherwise resolve via: \`\`\`bash - openspec resolve-change --auto + openspec agent resolve-change --auto \`\`\` - Exits 0 with the change name when exactly one active change exists. - - Exits 1 (none) or 3 (ambiguous). On ambiguity, run \`openspec resolve-change --json\` and use the **AskUserQuestion tool** to let the user select. + - Exits 1 (none) or 3 (ambiguous). On ambiguity, run \`openspec agent resolve-change --json\` and use the **AskUserQuestion tool** to let the user select. Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). @@ -56,7 +56,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - Make the code changes required (keep changes minimal and focused) - Mark the task complete via the CLI — idempotent, anchored on the task id so \`1.1\` does not match \`1.10\`: \`\`\`bash - openspec mark-task-done --change "" "" + openspec agent mark-task-done --change "" "" \`\`\` where \`\` is the task's \`numericId\` (e.g., \`1.1\`). For tasks without a \`numericId\`, edit the file directly (\`- [ ]\` → \`- [x]\`). - Re-run \`openspec instructions apply --change "" --json\` to refresh \`nextPendingId\` and continue. @@ -164,10 +164,10 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { If a name is provided, use it. Otherwise resolve via: \`\`\`bash - openspec resolve-change --auto + openspec agent resolve-change --auto \`\`\` - Exits 0 with the change name when exactly one active change exists. - - Exits 1 (none) or 3 (ambiguous). On ambiguity, run \`openspec resolve-change --json\` and use the **AskUserQuestion tool** to let the user select. + - Exits 1 (none) or 3 (ambiguous). On ambiguity, run \`openspec agent resolve-change --json\` and use the **AskUserQuestion tool** to let the user select. Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). @@ -200,7 +200,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { - Make the code changes required (keep changes minimal and focused) - Mark the task complete via the CLI — idempotent, anchored on the task id so \`1.1\` does not match \`1.10\`: \`\`\`bash - openspec mark-task-done --change "" "" + openspec agent mark-task-done --change "" "" \`\`\` where \`\` is the task's \`numericId\` (e.g., \`1.1\`). For tasks without a \`numericId\`, edit the file directly (\`- [ ]\` → \`- [x]\`). - Re-run \`openspec instructions apply --change "" --json\` to refresh \`nextPendingId\` and continue. diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index 6f05ebadd..ac9ad4e6b 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -46,7 +46,7 @@ When ready to implement, run /opsx:apply Repeat until \`{ "done": true }\`: \`\`\`bash - openspec next-artifact --change "" + openspec agent next-artifact --change "" \`\`\` Each non-done response returns the JSON instructions payload for exactly one ready artifact: @@ -60,7 +60,7 @@ When ready to implement, run /opsx:apply 1. Read any \`dependencies\` files for context 2. Write the artifact file (use \`template\` as the structure, write to \`resolvedOutputPath\`) 3. Show brief progress: "Created " - 4. Re-invoke \`openspec next-artifact --change ""\` and continue + 4. Re-invoke \`openspec agent next-artifact --change ""\` and continue If an artifact requires user input (context unclear), use the **AskUserQuestion tool** to clarify, then continue. @@ -141,7 +141,7 @@ When ready to implement, run /opsx:apply Repeat until \`{ "done": true }\`: \`\`\`bash - openspec next-artifact --change "" + openspec agent next-artifact --change "" \`\`\` Each non-done response returns the JSON instructions payload for exactly one ready artifact: @@ -155,7 +155,7 @@ When ready to implement, run /opsx:apply 1. Read any \`dependencies\` files for context 2. Write the artifact file (use \`template\` as the structure, write to \`resolvedOutputPath\`) 3. Show brief progress: "Created " - 4. Re-invoke \`openspec next-artifact --change ""\` and continue + 4. Re-invoke \`openspec agent next-artifact --change ""\` and continue If an artifact requires user input (context unclear), use the **AskUserQuestion tool** to clarify, then continue. diff --git a/test/commands/agent-helpers.test.ts b/test/commands/agent-helpers.test.ts index a202df4fa..8be7f0ee2 100644 --- a/test/commands/agent-helpers.test.ts +++ b/test/commands/agent-helpers.test.ts @@ -6,9 +6,9 @@ import { runCLI } from '../helpers/run-cli.js'; /** * End-to-end coverage for the three agent-facing helper commands: - * - openspec resolve-change - * - openspec next-artifact - * - openspec mark-task-done + * - openspec agent resolve-change + * - openspec agent next-artifact + * - openspec agent mark-task-done * * Plus enrichment cases for the existing `instructions apply --json` * payload (numericId / nextPendingId on tasks). @@ -74,7 +74,7 @@ describe('agent helper CLI commands', () => { // --------------------------------------------------------------------------- describe('resolve-change command', () => { it('prints empty list when no changes exist', async () => { - const result = await runCLI(['resolve-change'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change'], { cwd: tempDir }); expect(result.exitCode).toBe(0); const json = JSON.parse(result.stdout); expect(json.changes).toEqual([]); @@ -84,7 +84,7 @@ describe('agent helper CLI commands', () => { await makeChange('alpha'); await makeChange('beta'); - const result = await runCLI(['resolve-change'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change'], { cwd: tempDir }); expect(result.exitCode).toBe(0); const json = JSON.parse(result.stdout); expect(json.changes.map((c: { name: string }) => c.name).sort()).toEqual(['alpha', 'beta']); @@ -92,34 +92,34 @@ describe('agent helper CLI commands', () => { it('echoes the name when a valid change is supplied', async () => { await makeChange('alpha'); - const result = await runCLI(['resolve-change', 'alpha'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', 'alpha'], { cwd: tempDir }); expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe('alpha'); }); it('exits 2 when the named change does not exist', async () => { await makeChange('alpha'); - const result = await runCLI(['resolve-change', 'bogus'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', 'bogus'], { cwd: tempDir }); expect(result.exitCode).toBe(2); expect(getOutput(result)).toContain("Change 'bogus' is not active"); expect(getOutput(result)).toContain('alpha'); }); it('exits 2 with an invalid change name (path traversal)', async () => { - const result = await runCLI(['resolve-change', '../etc'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', '../etc'], { cwd: tempDir }); expect(result.exitCode).toBe(2); expect(getOutput(result)).toContain('Invalid change name'); }); it('--auto exits 1 when no changes exist', async () => { - const result = await runCLI(['resolve-change', '--auto'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', '--auto'], { cwd: tempDir }); expect(result.exitCode).toBe(1); expect(getOutput(result)).toContain('No active changes'); }); it('--auto echoes the only active change', async () => { await makeChange('only-one'); - const result = await runCLI(['resolve-change', '--auto'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', '--auto'], { cwd: tempDir }); expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe('only-one'); }); @@ -127,7 +127,7 @@ describe('agent helper CLI commands', () => { it('--auto exits 3 with a distinct message when multiple changes are active', async () => { await makeChange('alpha'); await makeChange('beta'); - const result = await runCLI(['resolve-change', '--auto'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', '--auto'], { cwd: tempDir }); expect(result.exitCode).toBe(3); const out = getOutput(result); expect(out).toContain('Multiple active changes'); @@ -137,7 +137,7 @@ describe('agent helper CLI commands', () => { it('--json with a named change emits structured payload', async () => { await makeChange('alpha'); - const result = await runCLI(['resolve-change', 'alpha', '--json'], { cwd: tempDir }); + const result = await runCLI(['agent', 'resolve-change', 'alpha', '--json'], { cwd: tempDir }); expect(result.exitCode).toBe(0); const json = JSON.parse(result.stdout); expect(json.name).toBe('alpha'); @@ -154,7 +154,7 @@ describe('agent helper CLI commands', () => { const changeDir = path.join(changesDir, 'scaffolded'); await fs.mkdir(changeDir, { recursive: true }); - const result = await runCLI(['next-artifact', '--change', 'scaffolded'], { cwd: tempDir }); + const result = await runCLI(['agent', 'next-artifact', '--change', 'scaffolded'], { cwd: tempDir }); expect(result.exitCode).toBe(0); const json = JSON.parse(result.stdout); expect(json.done).toBe(false); @@ -168,7 +168,7 @@ describe('agent helper CLI commands', () => { // by design). await makeChange('mid-stream', ['proposal', 'design']); - const result = await runCLI(['next-artifact', '--change', 'mid-stream'], { cwd: tempDir }); + const result = await runCLI(['agent', 'next-artifact', '--change', 'mid-stream'], { cwd: tempDir }); expect(result.exitCode).toBe(0); const json = JSON.parse(result.stdout); expect(json.done).toBe(false); @@ -179,7 +179,7 @@ describe('agent helper CLI commands', () => { it('emits { done: true } when every artifact is complete', async () => { await makeChange('all-done', ['proposal', 'design', 'specs', 'tasks']); - const result = await runCLI(['next-artifact', '--change', 'all-done'], { cwd: tempDir }); + const result = await runCLI(['agent', 'next-artifact', '--change', 'all-done'], { cwd: tempDir }); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ done: true }); }); @@ -188,7 +188,7 @@ describe('agent helper CLI commands', () => { const changeDir = path.join(changesDir, 'humanmode'); await fs.mkdir(changeDir, { recursive: true }); - const result = await runCLI(['next-artifact', '--change', 'humanmode', '--no-json'], { + const result = await runCLI(['agent', 'next-artifact', '--change', 'humanmode', '--no-json'], { cwd: tempDir, }); expect(result.exitCode).toBe(0); @@ -198,7 +198,7 @@ describe('agent helper CLI commands', () => { it('errors with a helpful message when --change is omitted', async () => { await makeChange('something'); - const result = await runCLI(['next-artifact'], { cwd: tempDir }); + const result = await runCLI(['agent', 'next-artifact'], { cwd: tempDir }); expect(result.exitCode).toBe(1); expect(getOutput(result)).toContain('Missing required option --change'); }); @@ -222,7 +222,7 @@ describe('agent helper CLI commands', () => { ); const result = await runCLI( - ['mark-task-done', '--change', 'flipme', '1.1', '--json'], + ['agent', 'mark-task-done', '--change', 'flipme', '1.1', '--json'], { cwd: tempDir } ); expect(result.exitCode).toBe(0); @@ -243,7 +243,7 @@ describe('agent helper CLI commands', () => { ); const result = await runCLI( - ['mark-task-done', '--change', 'already-done', '1.1', '--json'], + ['agent', 'mark-task-done', '--change', 'already-done', '1.1', '--json'], { cwd: tempDir } ); expect(result.exitCode).toBe(0); @@ -260,7 +260,7 @@ describe('agent helper CLI commands', () => { // Mark 1.1; 1.10 must stay unchecked. const r1 = await runCLI( - ['mark-task-done', '--change', 'boundary', '1.1'], + ['agent', 'mark-task-done', '--change', 'boundary', '1.1'], { cwd: tempDir } ); expect(r1.exitCode).toBe(0); @@ -271,7 +271,7 @@ describe('agent helper CLI commands', () => { // Now mark 1.10; 1.1 stays as it was. const r2 = await runCLI( - ['mark-task-done', '--change', 'boundary', '1.10'], + ['agent', 'mark-task-done', '--change', 'boundary', '1.10'], { cwd: tempDir } ); expect(r2.exitCode).toBe(0); @@ -284,7 +284,7 @@ describe('agent helper CLI commands', () => { await writeTasks('nomatch', '## Tasks\n- [ ] 1.1 Only one\n'); const result = await runCLI( - ['mark-task-done', '--change', 'nomatch', '9.9'], + ['agent', 'mark-task-done', '--change', 'nomatch', '9.9'], { cwd: tempDir } ); expect(result.exitCode).toBe(2); @@ -299,7 +299,7 @@ describe('agent helper CLI commands', () => { await fs.writeFile(tasksPath, crlfBody); const result = await runCLI( - ['mark-task-done', '--change', 'crlf', '1.1'], + ['agent', 'mark-task-done', '--change', 'crlf', '1.1'], { cwd: tempDir } ); expect(result.exitCode).toBe(0); @@ -340,7 +340,7 @@ apply: await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Proposal\n'); const result = await runCLI( - ['mark-task-done', '--change', 'no-tracks-change', '1.1'], + ['agent', 'mark-task-done', '--change', 'no-tracks-change', '1.1'], { cwd: tempDir } ); expect(result.exitCode).toBe(2); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 9dbe3db42..93712396b 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -33,14 +33,14 @@ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: 'e2765fae6c2e960f4ce07058cfdaa547ff3435d454eacd5e924e38139e97ad52', getNewChangeSkillTemplate: 'b0c26f0b65380062e586505c08c72230e59dccea89e6acca7b673f01cba70d5a', getContinueChangeSkillTemplate: 'fbc6c379ed3dd39f59f52b10584b8df5b1dc08b5422bcf1c6d6255a944d22a11', - getApplyChangeSkillTemplate: '85373afd0a0252ec2c51c91a6ced48221b9d3deecb6f7c6eadd65be975090da1', + getApplyChangeSkillTemplate: '6eea8bcae4b506497e606e6885fc24ff2be92c9ab38de0e594a194993d741362', getFfChangeSkillTemplate: '50e68fbb49b76d2690b614bffa9e6210e45539fb74419fc2e4311158b6d38485', getSyncSpecsSkillTemplate: '9f02b41227db70875b89eefeb275c769142607dc5b2593f4e606794aed2fdbad', getOnboardSkillTemplate: '4f4b60fea6e3fc7d2185815b2808fad51535fdd00cd4401b32d1536f32fa2b6d', getOpsxExploreCommandTemplate: '4d5e64e3ede6703113cf2fd23b797371ef2407b702478b4f7240fc81cbf2d3a5', getOpsxNewCommandTemplate: '757f72e2d9a1a6794b2188704fd39dd2ab65428899b4b361c76cc15a5e4f2ccc', getOpsxContinueCommandTemplate: '62f8863edda2bfe4e210f8bc3095fd4369aaaaf7772a5cba9602d0f0bca1d0c9', - getOpsxApplyCommandTemplate: 'e10d0f752f72af28a9825e65d9ba87e6b91f576fb05bfdb57ad319ddfe35147b', + getOpsxApplyCommandTemplate: '52f74615a4593708428c3ddfcec217238cea01bfedd9e1e9008e2537d2940385', getOpsxFfCommandTemplate: 'f775b242bcfd56594c431c7f31a0129208a1bacfdb2427074d412543072ef7ca', getArchiveChangeSkillTemplate: 'bdf022ae2cdef1feef4d641a068bef3a7fc5d98a323f7ce9f77ac578fe8d20c6', getBulkArchiveChangeSkillTemplate: 'fdb1715804e86de85be96222b8efeb9d5b350c6d5c19e343e244655deff8e62b', @@ -50,8 +50,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxOnboardCommandTemplate: '57c1f3e2590bda8f47818bab1d528456c1b8a9a7501f63ab9e2115e0cfaf6f35', getOpsxBulkArchiveCommandTemplate: 'b76c421023ccb5a12867c349f27cdb186234b692c1811980fb94127567bdabda', getOpsxVerifyCommandTemplate: '9a7a3f9e5bc3d0c0878b1a4493efbbb38729597d9b9be78f63284cc2da7c20c3', - getOpsxProposeSkillTemplate: 'ce7fa88b55ca4bbf31c3c4faf36e6a3f73df16545d2ca40199ca45682b5be08b', - getOpsxProposeCommandTemplate: 'cf40c87bbb327f3262df232c2db175a908bfcea72f84f8aaf9a236abbf750130', + getOpsxProposeSkillTemplate: '8af192d385c9470887b53b330169828ebd133a0958ba98290a3d4e7a53de9a2d', + getOpsxProposeCommandTemplate: '67a9c4881a5602d915375dfc1c9f73e8815b05b9cf84d2bf0babbe70a1da1f41', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; @@ -59,14 +59,14 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': '28d900ef82b325beb65e69ee6435949adcfdf14a4314638e7006e6dc359b92d4', 'openspec-new-change': 'c99989810f982d72eefc74a35f2282b71f1956f23f61b83aaa58fa3dd921716f', 'openspec-continue-change': 'c00e2a60f79cd60197094cc59762babe5ee6a2dc1e859a0ede3f436a775ccecf', - 'openspec-apply-change': '82a2e8242af2a13ad18794225faedb6cbb38f4ba87145615d85b85575e1fb855', + 'openspec-apply-change': '92988c93603f89e433f23394902dbcb82e756c40393053c989f661183ae937ea', 'openspec-ff-change': '9d9b1995b6f4adb3da570676f7d11fee4cd1cf6c5df8ec83c033e02783a544df', 'openspec-sync-specs': '2e0f67ec6fadffc6107b4b1a28eef23a99a6649e5fae706897ea1dd9deb852a8', 'openspec-archive-change': '8d14af2c8b2e4358308ac9fc14f75db42a4b41a07e175825035852a82479793e', 'openspec-bulk-archive-change': '16207683996b1952559cd4e33463f28fb097761f2c5d912107733d01a90d3f2f', 'openspec-verify-change': 'a2acecd0c2b4e57080a314e5e7a093e0688293c37e446eb45d378f5050058550', 'openspec-onboard': 'b924ea3c97543ebb7ee82c5f194afe7ce87a521c32b85616f445240ab33a02ab', - 'openspec-propose': '144673bab646799fe21b7a19a57bb8416e190d48bbe821e916bc0a1737d7b363', + 'openspec-propose': '5158482fdc1b42577e24554d90adf4dda258cab7a4d86564c0fa651f6dd083ee', }; function stableStringify(value: unknown): string { From 00ef520635e8146bc1c30194930166d624a5126a Mon Sep 17 00:00:00 2001 From: Jussi Rantala Date: Wed, 27 May 2026 12:47:53 +0300 Subject: [PATCH 4/4] fix(coderabbit): address 3 PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/cli.md: add 'bash' language tag to the three new agent subcommand synopsis fences (markdownlint MD040). Lines 880, 929, 992. - src/core/templates/workflows/propose.ts: replace stale 'openspec instructions' guideline reference with 'openspec agent next-artifact' to match the refactored loop. Both skill + command variants. - src/core/templates/workflows/apply-change.ts: disambiguate the blocked-state message — 'suggest openspec-continue-change' → 'suggest the `openspec-continue-change` skill'. Skill name, not CLI command form. - Refresh template parity hashes for the three affected functions. --- docs/cli.md | 6 +++--- src/core/templates/workflows/apply-change.ts | 2 +- src/core/templates/workflows/propose.ts | 4 ++-- test/core/templates/skill-templates-parity.test.ts | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 18654032d..3c3e4aa30 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -877,7 +877,7 @@ 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. -``` +```bash openspec agent resolve-change [name] [options] ``` @@ -926,7 +926,7 @@ full instructions payload for that artifact. Replaces the call. JSON is the default since agents are the primary consumers; pass `--no-json` for a human summary. -``` +```bash openspec agent next-artifact --change [options] ``` @@ -989,7 +989,7 @@ leading numeric task id (`1`, `1.1`, `2.3.4`) captured by CRLF vs LF line endings; anchored matching ensures `1.1` does not match `1.10`. -``` +```bash openspec agent mark-task-done --change [options] ``` diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index 0b4e7da38..e1f1682e5 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -36,7 +36,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - \`schemaName\`: the workflow being used (e.g., "spec-driven") - \`contextFiles\`: artifact ID → array of file paths (varies by schema — proposal/specs/design/tasks for spec-driven) - \`progress\` (total / complete / remaining), \`tasks\` (with \`numericId\` when the file uses numbered tasks), \`nextPendingId\` (the first unchecked task with a \`numericId\`) - - \`state\`: \`"blocked"\` (missing artifacts → suggest openspec-continue-change), \`"all_done"\` (congratulate, suggest archive), or \`"ready"\` (proceed) + - \`state\`: \`"blocked"\` (missing artifacts → suggest the \`openspec-continue-change\` skill), \`"all_done"\` (congratulate, suggest archive), or \`"ready"\` (proceed) - \`instruction\`: dynamic guidance based on current state **Workspace guard:** If \`openspec status --change "" --json\` reports \`actionContext.mode: "workspace-planning"\` and \`allowedEditRoots\` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index ac9ad4e6b..cf9da31cd 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -79,7 +79,7 @@ After completing all artifacts, summarize: **Artifact Creation Guidelines** -- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- Follow the \`instruction\` field from each \`openspec agent next-artifact\` response for the matching artifact type - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use \`template\` as the structure for your output file - fill in its sections @@ -174,7 +174,7 @@ After completing all artifacts, summarize: **Artifact Creation Guidelines** -- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- Follow the \`instruction\` field from each \`openspec agent next-artifact\` response for the matching artifact type - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use \`template\` as the structure for your output file - fill in its sections diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 93712396b..974bf38d3 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -33,7 +33,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: 'e2765fae6c2e960f4ce07058cfdaa547ff3435d454eacd5e924e38139e97ad52', getNewChangeSkillTemplate: 'b0c26f0b65380062e586505c08c72230e59dccea89e6acca7b673f01cba70d5a', getContinueChangeSkillTemplate: 'fbc6c379ed3dd39f59f52b10584b8df5b1dc08b5422bcf1c6d6255a944d22a11', - getApplyChangeSkillTemplate: '6eea8bcae4b506497e606e6885fc24ff2be92c9ab38de0e594a194993d741362', + getApplyChangeSkillTemplate: '6ca19d19d3598a9740eb5fa6852c96869aa56310a13531712328cb867ebc6385', getFfChangeSkillTemplate: '50e68fbb49b76d2690b614bffa9e6210e45539fb74419fc2e4311158b6d38485', getSyncSpecsSkillTemplate: '9f02b41227db70875b89eefeb275c769142607dc5b2593f4e606794aed2fdbad', getOnboardSkillTemplate: '4f4b60fea6e3fc7d2185815b2808fad51535fdd00cd4401b32d1536f32fa2b6d', @@ -50,8 +50,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxOnboardCommandTemplate: '57c1f3e2590bda8f47818bab1d528456c1b8a9a7501f63ab9e2115e0cfaf6f35', getOpsxBulkArchiveCommandTemplate: 'b76c421023ccb5a12867c349f27cdb186234b692c1811980fb94127567bdabda', getOpsxVerifyCommandTemplate: '9a7a3f9e5bc3d0c0878b1a4493efbbb38729597d9b9be78f63284cc2da7c20c3', - getOpsxProposeSkillTemplate: '8af192d385c9470887b53b330169828ebd133a0958ba98290a3d4e7a53de9a2d', - getOpsxProposeCommandTemplate: '67a9c4881a5602d915375dfc1c9f73e8815b05b9cf84d2bf0babbe70a1da1f41', + getOpsxProposeSkillTemplate: '9091e6152e115407cc5de214b4cafbcc52c980e9614c1cdb6d20ca4dc80935a0', + getOpsxProposeCommandTemplate: '946adc8ed51a9e3c110799ced6b5c3e8a84996c140e53fa04d1c0b067943c732', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; @@ -59,14 +59,14 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': '28d900ef82b325beb65e69ee6435949adcfdf14a4314638e7006e6dc359b92d4', 'openspec-new-change': 'c99989810f982d72eefc74a35f2282b71f1956f23f61b83aaa58fa3dd921716f', 'openspec-continue-change': 'c00e2a60f79cd60197094cc59762babe5ee6a2dc1e859a0ede3f436a775ccecf', - 'openspec-apply-change': '92988c93603f89e433f23394902dbcb82e756c40393053c989f661183ae937ea', + 'openspec-apply-change': '48951bbbe390ed7821463f3345a1049667a10e58a696c3f1d8b9312f1acd12eb', 'openspec-ff-change': '9d9b1995b6f4adb3da570676f7d11fee4cd1cf6c5df8ec83c033e02783a544df', 'openspec-sync-specs': '2e0f67ec6fadffc6107b4b1a28eef23a99a6649e5fae706897ea1dd9deb852a8', 'openspec-archive-change': '8d14af2c8b2e4358308ac9fc14f75db42a4b41a07e175825035852a82479793e', 'openspec-bulk-archive-change': '16207683996b1952559cd4e33463f28fb097761f2c5d912107733d01a90d3f2f', 'openspec-verify-change': 'a2acecd0c2b4e57080a314e5e7a093e0688293c37e446eb45d378f5050058550', 'openspec-onboard': 'b924ea3c97543ebb7ee82c5f194afe7ce87a521c32b85616f445240ab33a02ab', - 'openspec-propose': '5158482fdc1b42577e24554d90adf4dda258cab7a4d86564c0fa651f6dd083ee', + 'openspec-propose': 'c945e3bb576a63a967e030637a2197396dac6a7b599e5e905676b64824d14b8f', }; function stableStringify(value: unknown): string {