diff --git a/.changeset/agent-helpers.md b/.changeset/agent-helpers.md new file mode 100644 index 000000000..2c14c6193 --- /dev/null +++ b/.changeset/agent-helpers.md @@ -0,0 +1,39 @@ +--- +"@fission-ai/openspec": minor +--- + +### Added + +- **`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 + +- `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..3c3e4aa30 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 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 | @@ -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 agent mark-task-done` to drive an apply loop without re-parsing tasks.md. + +--- + +### `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 +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] +``` + +**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 agent resolve-change + +# Validate a specific name (exit 2 on miss) +openspec agent resolve-change add-dark-mode + +# Auto-pick the only active change +openspec agent 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 agent 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. + +```bash +openspec agent 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 agent next-artifact --change add-dark-mode + +# Compact human summary +openspec agent 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 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 +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`. + +```bash +openspec agent 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 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 agent 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/cli/index.ts b/src/cli/index.ts index d06fdddc5..6c5dfbf0a 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,63 @@ newCmd } }); +// 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') + .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); + } + }); + +// 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') + .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); + } + }); + +agentCmd + .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; } // ----------------------------------------------------------------------------- diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 85c05d08b..7d64caeed 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -256,6 +256,69 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'agent', + description: 'Helpers for AI skills and scripts (not intended for direct human use)', + flags: [], + subcommands: [ + { + 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..e1f1682e5 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 agent 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 agent 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 the \`openspec-continue-change\` skill), \`"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 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. **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 agent 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 agent 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 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. **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..cf9da31cd 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 agent 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 agent 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 "" \`\`\` @@ -96,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 @@ -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 agent 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 agent 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 "" \`\`\` @@ -208,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/commands/agent-helpers.test.ts b/test/commands/agent-helpers.test.ts new file mode 100644 index 000000000..8be7f0ee2 --- /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 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). + * + * 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(['agent', '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(['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']); + }); + + it('echoes the name when a valid change is supplied', async () => { + await makeChange('alpha'); + 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(['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(['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(['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(['agent', '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(['agent', '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(['agent', '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(['agent', '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(['agent', '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(['agent', '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(['agent', '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(['agent', '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( + ['agent', '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( + ['agent', '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( + ['agent', '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( + ['agent', '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( + ['agent', '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( + ['agent', '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( + ['agent', '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..974bf38d3 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: '6ca19d19d3598a9740eb5fa6852c96869aa56310a13531712328cb867ebc6385', getFfChangeSkillTemplate: '50e68fbb49b76d2690b614bffa9e6210e45539fb74419fc2e4311158b6d38485', getSyncSpecsSkillTemplate: '9f02b41227db70875b89eefeb275c769142607dc5b2593f4e606794aed2fdbad', getOnboardSkillTemplate: '4f4b60fea6e3fc7d2185815b2808fad51535fdd00cd4401b32d1536f32fa2b6d', getOpsxExploreCommandTemplate: '4d5e64e3ede6703113cf2fd23b797371ef2407b702478b4f7240fc81cbf2d3a5', getOpsxNewCommandTemplate: '757f72e2d9a1a6794b2188704fd39dd2ab65428899b4b361c76cc15a5e4f2ccc', getOpsxContinueCommandTemplate: '62f8863edda2bfe4e210f8bc3095fd4369aaaaf7772a5cba9602d0f0bca1d0c9', - getOpsxApplyCommandTemplate: '812feefd32a4d9d468e03e456d06e3d2d08d1118d29cce4911f0be59cdd30bfc', + getOpsxApplyCommandTemplate: '52f74615a4593708428c3ddfcec217238cea01bfedd9e1e9008e2537d2940385', 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: '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': 'd849442efd925b9247651e254a5cd696945321610cca5a9432ad420430554548', + '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': '56aa526fe1e9fac956ad3ad570a3a259d27f54b05086940d85af136a62069292', + 'openspec-propose': 'c945e3bb576a63a967e030637a2197396dac6a7b599e5e905676b64824d14b8f', }; function stableStringify(value: unknown): string {