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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/archive-non-interactive-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Tell the caller which flag to pass when `openspec archive` cannot ask its confirmation questions. An AI agent (or any script) runs the CLI with stdin closed, so every prompt rejects with `@inquirer`'s `User force closed the prompt with 0 null` — the archive aborted with an error that named neither the question nor the flag, and agents burned a turn guessing (#1479). Each confirmation now reports what it needed and a pasteable rerun that carries the flags you already passed: `openspec archive <name> --skip-specs --yes` stays a `--skip-specs` run, so following the suggestion cannot merge specs you opted out of merging, and a change name that needs quoting gets double quotes, the one form bash, zsh, PowerShell and cmd.exe all read the same way (a name no shell reads literally even quoted — one containing `$`, a backtick, or the `%`/`!` that cmd.exe still expands inside quotes — is left as a `<change-name>` placeholder rather than a command that would target something else). `openspec archive` with no change name used to swallow the same failure, print `No change selected. Aborting.` and exit 0 — success for a run that archived nothing; it now exits 1 asking for a change name, matching how `openspec show` and `openspec validate` already behave without a terminal. The check is reactive — it inspects a prompt that already failed — so answers piped into the command, `--yes`, `--json`, and Ctrl-C all behave exactly as before, and a run that OpenSpec already considers non-interactive (`CI`, `OPEN_SPEC_INTERACTIVE=0`, `--no-interactive`) gets the guidance even when the runner allocated a pty. The onboarding walkthrough, the only generated guidance that tells an agent to run `openspec archive`, now shows `--yes`.
13 changes: 9 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,26 +622,26 @@ openspec archive [change-name] [options]

| Argument | Required | Description |
|----------|----------|-------------|
| `change-name` | No | Change to archive (prompts if omitted) |
| `change-name` | No | Change to archive (prompts if omitted; required when nothing can answer the prompt) |

**Options:**

| Option | Description |
|--------|-------------|
| `-y, --yes` | Skip confirmation prompts |
| `-y, --yes` | Skip confirmation prompts. Required when nothing can answer them — an AI agent, a CI job, or any run with stdin closed |
| `--skip-specs` | Skip spec updates for one archive run. A change that permanently has no spec deltas should declare `skip_specs: true` in its `.openspec.yaml` instead — it archives with no flag |
| `--no-validate` | Skip validation (requires confirmation) |

**Examples:**

```bash
# Interactive archive
# Interactive archive (asks which change, then confirms)
openspec archive

# Archive specific change
openspec archive add-dark-mode

# Archive without prompts (CI/scripts)
# Archive without prompts (agents, CI, scripts)
openspec archive add-dark-mode --yes

# Archive a tooling change that doesn't affect specs
Expand All @@ -655,6 +655,11 @@ openspec archive update-ci-config --skip-specs
3. Merges delta specs into `openspec/specs/`
4. Moves change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`

**Without a terminal:** an AI agent, a CI job, or any run with stdin closed cannot
answer step 2, so archive stops before touching anything, exits 1, and names the
command to rerun — `openspec archive <name> --yes`, carrying whatever other flags
you passed. Pass `--yes` (and the change name) up front to skip the round trip.

---

## Workflow Commands
Expand Down
12 changes: 12 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ The AI didn't have enough context. A few levers help:

Archive won't *block* on incomplete tasks, but it warns you, because archiving usually means the work is done. If tasks remain on purpose (you're filing a partial change), proceed. Otherwise finish the tasks first. Archive will also offer to sync your delta specs into the main specs if you haven't synced yet; say yes unless you have a reason not to.

### "User force closed the prompt with 0 null"

Something ran `openspec archive` where nothing can answer a question — an AI agent calling it from a tool, a CI job, or any shell with stdin closed. Archive asks up to three confirmations, and an unanswerable one used to fail with that raw message.

Pass `--yes` to answer them up front:

```bash
openspec archive <change-name> --yes
```

Keep any flags you were already passing — `--skip-specs` and `--no-validate` change what archive does, so a bare `--yes` rerun is not the same command. Current versions name the flag for you and print a `Fix:` line you can paste. If you meant to pick from a list, pass the change name explicitly: the picker needs an answer too.

## Configuration

### My `config.yaml` isn't being applied
Expand Down
23 changes: 23 additions & 0 deletions openspec/specs/cli-archive/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ The command SHALL support both interactive and direct change selection methods.
- **THEN** use that change directly
- **AND** validate it exists

#### Scenario: No change name and no answer available

- **WHEN** no change-name is provided and the selection prompt cannot be answered
- **THEN** report that a change name is required
- **AND** state that no answer could be read from stdin
- **AND** suggest a rerun naming the change and passing `--yes`
- **AND** exit with a non-zero status code rather than reporting success for a run that archived nothing

### Requirement: Task Completion Check

The command SHALL verify task completion status before archiving to prevent premature archival.
Expand Down Expand Up @@ -170,6 +178,21 @@ The command SHALL handle various error conditions gracefully.
- Change not found
- Archive target already exists
- File system permissions issues
- A confirmation prompt that cannot be answered because no answer can be read from stdin

#### Scenario: Confirmation cannot be answered

- **WHEN** a confirmation prompt fails because no answer can be read from stdin
- **THEN** report which decision needed an answer
- **AND** suggest a rerun that adds `--yes` and reproduces the flags the caller already passed
- **AND** make no filesystem change
- **AND** exit with a non-zero status code

#### Scenario: Cancellation is not treated as a missing answer

- **WHEN** the user cancels a prompt with Ctrl-C
- **THEN** treat it as a cancellation rather than an unanswerable prompt
- **AND** preserve the existing cancellation behavior

### Requirement: Skip Specs Option

Expand Down
4 changes: 2 additions & 2 deletions skills/openspec-onboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,9 +430,9 @@ When a change is complete, we archive it. The archive path is derived from `plan
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
```

**DO:**
**DO:** Archive the change (`--yes` answers the confirmation prompts, which you cannot answer from a tool call):
```bash
openspec archive "<name>"
openspec archive "<name>" --yes
```

**SHOW:**
Expand Down
172 changes: 152 additions & 20 deletions src/core/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from './specs-apply.js';
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
import { readSkipSpecsMarker } from '../utils/change-metadata.js';
import { isNonInteractivePromptError } from '../utils/interactive.js';

function isMissingPathError(error: unknown): boolean {
return (
Expand Down Expand Up @@ -79,9 +80,11 @@ interface ArchiveResult {
}

/**
* JSON mode is non-interactive: any point where the human flow would prompt or
* print prose instead throws this error, which becomes a machine-readable
* status entry with a non-zero exit code.
* A decision point archive cannot get past on its own. Thrown wherever the
* flow needs an answer it has no way to obtain: in JSON mode, which never
* prompts at all, and in human mode when a prompt failed because nothing
* could answer it (#1479). Either way it carries a machine-readable
* diagnostic and exits non-zero.
*/
class ArchiveBlockedError extends Error {
readonly diagnostic: ArchiveDiagnostic;
Expand All @@ -98,6 +101,97 @@ class ArchiveBlockedError extends Error {
}
}

/**
* Quotes a change name for a `Fix:` line the reader is meant to paste.
* Archive resolves a change by stat-ing its directory, so the name is
* whatever the directory is called - including names with spaces or shell
* metacharacters, which pasted unquoted would run as a second command.
*
* Double quotes are the one form bash, zsh, PowerShell and cmd.exe all read
* the same way, so a POSIX-only `'...'` would be wrong on Windows. Characters
* that stay inert inside double quotes in every one of those shells are the
* limit of what can be quoted portably; a name containing anything else has
* no portable spelling, so the placeholder is named instead of emitting a
* command that might expand to something the reader did not intend.
*
* `%` and `!` are unquotable for the same reason even though POSIX shells
* leave them alone inside double quotes: cmd.exe expands `%NAME%` inside
* double quotes, and `!NAME!` expands there too under `setlocal
* enabledelayedexpansion` (as does `!` under bash's interactive history
* expansion). A change directory really can be named `%USERNAME%`, and a
* rerun that silently targets a different change is worse than one the reader
* has to fill in.
*/
function quoteChangeName(name: string): string {
if (/^[A-Za-z0-9._-]+$/.test(name)) return name;
if (!/["\\$`\r\n%!]/.test(name)) return `"${name}"`;
return '<change-name>';
}

/**
* Renders a change name inside a prose message. The name is a directory name,
* so it can hold control characters, and human mode prints the message
* verbatim: a raw CR or LF would let a change directory forge its own `Fix:`
* line, which is worse here than anywhere else because `quoteChangeName`
* degrades the real fix to the `<change-name>` placeholder for exactly those
* names - leaving the forged line as the only pasteable command on screen.
* An ESC could redraw the terminal. Neither survives.
*/
function describeChangeName(name: string): string {
return name.replace(/[\u0000-\u001f\u007f]/g, '?');
}

/**
* Builds the flags a blocked archive's suggested rerun has to reproduce. The
* caller's own flags are carried, because suggesting a bare `--yes` rerun for
* `archive x --skip-specs` would merge deltas into the main specs - the exact
* thing `--skip-specs` was passed to prevent.
*/
function rerunFlags(options: ArchiveOptions): string[] {
return [
...(options.skipSpecs ? ['--skip-specs'] : []),
...(options.validate === false || options.noValidate === true ? ['--no-validate'] : []),
'--yes',
];
}

function rerunCommand(
root: ResolvedOpenSpecRoot,
changeName: string,
options: ArchiveOptions
): string {
const flags = rerunFlags(options).join(' ');
// A name starting with a dash is read as an option wherever it sits, so it
// goes last, behind the `--` that ends option parsing. The store flag has
// to stay in front of that `--` to still be read as an option.
if (changeName.startsWith('-')) {
return `${withStoreFlag(root, `openspec archive ${flags}`)} -- ${quoteChangeName(changeName)}`;
}
return withStoreFlag(root, `openspec archive ${quoteChangeName(changeName)} ${flags}`);
}

/**
* Asks a yes/no question in human mode. When no answer can be read — the
* usual case for an AI agent or a script that runs the command with stdin
* closed — the raw @inquirer failure is replaced with guidance for this
* decision point, so the caller learns which flag to pass instead of reading
* `User force closed the prompt` (#1479).
*/
async function confirmOrBlock(
prompt: { message: string; default: boolean },
blocked: () => ArchiveBlockedError
): Promise<boolean> {
const { confirm } = await import('@inquirer/prompts');
try {
return await confirm(prompt);
} catch (error) {
if (isNonInteractivePromptError(error)) {
throw blocked();
}
throw error;
}
}

function toArchiveDiagnostic(error: unknown): ArchiveDiagnostic {
if (error instanceof ArchiveBlockedError) {
return error.diagnostic;
Expand Down Expand Up @@ -223,7 +317,7 @@ export class ArchiveCommand {
withStoreFlag(root, 'openspec archive <change-name> --json')
);
}
const selectedChange = await this.selectChange(changesDir);
const selectedChange = await this.selectChange(changesDir, root, options);
if (!selectedChange) {
console.log('No change selected. Aborting.');
return null;
Expand Down Expand Up @@ -379,11 +473,18 @@ export class ArchiveCommand {
const timestamp = new Date().toISOString();

if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const proceed = await confirm({
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
default: false
});
const proceed = await confirmOrBlock(
{
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
default: false
},
() =>
new ArchiveBlockedError(
'archive_confirmation_required',
'Skipping validation requires confirmation, and no answer could be read from stdin.',
rerunCommand(root, changeName!, options)
)
);
if (!proceed) {
console.log('Archive cancelled.');
return null;
Expand Down Expand Up @@ -414,11 +515,18 @@ export class ArchiveCommand {
);
}
} else if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const proceed = await confirm({
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
default: false
});
const proceed = await confirmOrBlock(
{
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
default: false
},
() =>
new ArchiveBlockedError(
'archive_tasks_incomplete',
`${incompleteTasks} incomplete task(s) found for change '${describeChangeName(changeName!)}', and no answer could be read from stdin.`,
`Complete the tasks or rerun with ${rerunCommand(root, changeName!, options)}`
)
);
if (!proceed) {
console.log('Archive cancelled.');
return null;
Expand Down Expand Up @@ -459,11 +567,18 @@ export class ArchiveCommand {
withStoreFlag(root, 'openspec archive <change-name> --json --yes')
);
}
const { confirm } = await import('@inquirer/prompts');
shouldUpdateSpecs = await confirm({
message: 'Proceed with spec updates?',
default: true
});
shouldUpdateSpecs = await confirmOrBlock(
{
message: 'Proceed with spec updates?',
default: true
},
() =>
new ArchiveBlockedError(
'archive_confirmation_required',
`Updating ${specUpdates.length} spec(s) requires confirmation, and no answer could be read from stdin.`,
rerunCommand(root, changeName!, options)
)
);
if (!shouldUpdateSpecs) {
console.log('Skipping spec updates. Proceeding with archive.');
}
Expand Down Expand Up @@ -600,7 +715,11 @@ export class ArchiveCommand {
};
}

private async selectChange(changesDir: string): Promise<string | null> {
private async selectChange(
changesDir: string,
root: ResolvedOpenSpecRoot,
options: ArchiveOptions
): Promise<string | null> {
const { select } = await import('@inquirer/prompts');
const changeDirs = await listActiveChangeNames(changesDir);

Expand Down Expand Up @@ -635,6 +754,19 @@ export class ArchiveCommand {
});
return answer;
} catch (error) {
// Nobody to pick from the list: reporting "No change selected" and
// exiting 0 told an agent the archive had succeeded when nothing
// happened (#1479). The suggested rerun carries --yes because the same
// caller cannot answer the confirmations further down either, and the
// caller's own flags because dropping --skip-specs here would suggest a
// rerun that merges the specs it was passed to leave alone.
if (isNonInteractivePromptError(error)) {
throw new ArchiveBlockedError(
'archive_change_name_required',
'A change name is required: no answer could be read from stdin.',
withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`)
);
}
// User cancelled (Ctrl+C)
return null;
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/templates/workflows/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,9 @@ When a change is complete, we archive it. The archive path is derived from \`pla
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
\`\`\`

**DO:**
**DO:** Archive the change (\`--yes\` answers the confirmation prompts, which you cannot answer from a tool call):
\`\`\`bash
openspec archive "<name>"
openspec archive "<name>" --yes
\`\`\`

**SHOW:**
Expand Down
Loading
Loading