Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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-early-synced-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

`openspec archive` no longer aborts when a REMOVED delta's requirement is already gone from the main spec (the early-sync pattern the sync skill teaches): it warns, treats the removal as already applied, and reports applied-only totals. In `--json` mode those warnings are carried in a new optional `warnings` array on the archive result. When every operation for a spec was already synced, archive skips rewriting that file instead of churning normalization differences into it. A delta that both RENAMEs and REMOVEs the same requirement is now rejected explicitly, by both `validate` and `archive`, and a REMOVED header that differs only in case or whitespace from an existing requirement still aborts (that is a typo, not an early sync). Also fixed: the archive delta gate matches section headers case-insensitively like the parser; symlinked `specs/<capability>/spec.md` files are discovered instead of silently dropped; `openspec show <change>` no longer prints a spurious "scenarios" flag warning; files generated for qwen and bob reference commands by their real hyphenated names (`/opsx-<id>`), and init's getting-started hint follows suit; apply/update/onboard guidance names the CLI fallback for profiles that don't install `/opsx:continue` or `/opsx:new`.
2 changes: 1 addition & 1 deletion docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id
Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1.

### 4.8 `archive <name> --json`
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written; an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.

### 4.9 `doctor --json`
`{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "drift"?: {ahead,behind}, "status": [] } | null, "references": [...], "status": [] }`. `drift` (present only for a git-backed store checkout that has an upstream tracking ref) is ahead/behind counts against the last-fetched upstream, not the live remote. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1.
Expand Down
6 changes: 4 additions & 2 deletions openspec/specs/openspec-conventions/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ The archive process SHALL programmatically apply delta changes to current specif
2. Parse REMOVED sections and remove by normalized header match
3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)
4. Parse ADDED sections and append new requirements
- **AND** validate that all MODIFIED/REMOVED headers exist in current spec
- **AND** validate that ADDED headers don't already exist
- **AND** validate that all MODIFIED headers exist in current spec
- **AND** treat a REMOVED header that is already absent as already removed (warn and continue; a REMOVED header that names the FROM side of a RENAMED in the same delta, or that differs only in case or whitespace from an existing requirement, is a conflict)
- **AND** treat an ADDED header that already exists with identical content as already synced (differing content is a conflict)
- **AND** treat a RENAMED whose source is gone but target present as already synced
- **AND** generate the updated spec in the main specs/ directory

#### Scenario: Handling conflicts during archive
Expand Down
2 changes: 1 addition & 1 deletion skills/openspec-apply-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Implement tasks from an OpenSpec change.
- Dynamic instruction based on current state

**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change (if it is not installed, run `openspec status --change "<name>" --json` to see the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` for how to create it)
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation

Expand Down
6 changes: 3 additions & 3 deletions skills/openspec-onboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ This same rhythm works for any size change—a small fix or a major feature.
| `/openspec-apply-change` | Implement tasks from a change |
| `/openspec-archive-change` | Archive a completed change |

**Additional commands:**
**Additional commands** (only if installed - availability depends on your profile):

| Command | What it does |
|--------------------|----------------------------------------------------------|
Expand Down Expand Up @@ -503,7 +503,7 @@ If the user says they need to stop, want to pause, or seem disengaged:
No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "<name>" --json`.

To pick up where we left off later:
- `/openspec-continue-change <name>` - Resume artifact creation
- `/openspec-continue-change <name>` - Resume artifact creation (if installed; otherwise `openspec status --change "<name>" --json` shows the next artifact)
- `/openspec-apply-change <name>` - Jump to implementation (if tasks exist)

The work won't be lost. Come back whenever you're ready.
Expand All @@ -527,7 +527,7 @@ If the user says they just want to see the commands or skip the tutorial:
| `/openspec-apply-change <name>` | Implement tasks |
| `/openspec-archive-change <name>` | Archive when done |

**Additional commands:**
**Additional commands** (only if installed - availability depends on your profile):

| Command | What it does |
|---------------------------|-------------------------------------|
Expand Down
1 change: 1 addition & 0 deletions skills/openspec-update-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,4 @@ After each invocation, show:
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic).
- `/openspec-continue-change` and `/openspec-new-change` may not be installed (core profile). When suggesting one that is unavailable, point to the CLI instead: `openspec status --change "<name>" --json` shows the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` explains how to create it.
8 changes: 6 additions & 2 deletions src/commands/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,14 @@ export class ShowCommand {

private warnIrrelevantFlags(type: ItemType, options: { [k: string]: any }): boolean {
const irrelevant: string[] = [];
// --no-scenarios makes commander default `scenarios` to true, so its
// presence alone does not mean the user passed it — only false does.
const isUserProvided = (k: string) =>
k === 'scenarios' ? options[k] === false : k in options;
if (type === 'change') {
for (const k of SPEC_FLAG_KEYS) if (k in options) irrelevant.push(k);
for (const k of SPEC_FLAG_KEYS) if (isUserProvided(k)) irrelevant.push(k);
} else {
for (const k of CHANGE_FLAG_KEYS) if (k in options) irrelevant.push(k);
for (const k of CHANGE_FLAG_KEYS) if (isUserProvided(k)) irrelevant.push(k);
}
if (irrelevant.length > 0) {
console.error(`Warning: Ignoring flags not applicable to ${type}: ${irrelevant.join(', ')}`);
Expand Down
35 changes: 28 additions & 7 deletions src/core/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ interface ArchiveResult {
path: string;
specsUpdated: boolean;
totals?: { added: number; modified: number; removed: number; renamed: number };
/** Non-blocking spec-merge warnings (e.g. a REMOVED requirement that was already gone). */
warnings?: string[];
}

/**
Expand Down Expand Up @@ -323,7 +325,9 @@ export class ArchiveCommand {
for (const { specFile } of hasDeltaSpecs ? [] : await discoverSpecFiles(changeSpecsDir)) {
try {
const content = await fs.readFile(specFile, 'utf-8');
if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) {
// Case-insensitive to match the delta parser, so a lowercase header
// routes through the same delta validation that validate runs.
if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/im.test(content)) {
hasDeltaSpecs = true;
break;
}
Expand Down Expand Up @@ -424,6 +428,7 @@ export class ArchiveCommand {
// Handle spec updates unless skipSpecs flag is set
let specsUpdated = false;
let totals: ArchiveResult['totals'];
const specWarnings: string[] = [];
if (options.skipSpecs) {
if (!json) {
console.log('Skipping spec updates (--skip-specs flag provided).');
Expand Down Expand Up @@ -468,6 +473,9 @@ export class ArchiveCommand {
for (const update of specUpdates) {
const built = await buildUpdatedSpec(update, changeName!, { silent: json });
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
// Carried into the result so JSON mode (where nothing was
// printed) still surfaces them; human mode discards the result.
specWarnings.push(...built.warnings);
}
} catch (err: any) {
if (json) {
Expand Down Expand Up @@ -511,24 +519,36 @@ export class ArchiveCommand {

// All validations passed; write files and display counts
const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 };
let wroteAny = false;
for (const p of prepared) {
const { added, modified, removed, renamed } = p.counts;
if (added + modified + removed + renamed === 0) {
// Every operation was already synced: rewriting the file would
// only churn normalization differences into it.
continue;
}
await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
silent: json,
// Cross-root paths must be absolute when a store is selected.
...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}),
});
writeTotals.added += p.counts.added;
writeTotals.modified += p.counts.modified;
writeTotals.removed += p.counts.removed;
writeTotals.renamed += p.counts.renamed;
wroteAny = true;
writeTotals.added += added;
writeTotals.modified += modified;
writeTotals.removed += removed;
writeTotals.renamed += renamed;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
specsUpdated = true;
specsUpdated = wroteAny;
totals = writeTotals;
if (!json) {
console.log(
`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}`
);
console.log('Specs updated successfully.');
console.log(
wroteAny
? 'Specs updated successfully.'
: 'Specs already in sync; no files changed.'
);
}
}
}
Expand Down Expand Up @@ -573,6 +593,7 @@ export class ArchiveCommand {
path: archivePath,
specsUpdated,
...(totals ? { totals } : {}),
...(specWarnings.length > 0 ? { warnings: specWarnings } : {}),
};
}

Expand Down
7 changes: 6 additions & 1 deletion src/core/command-generation/adapters/qwen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';

/**
* Escapes a string value for safe YAML output.
Expand Down Expand Up @@ -39,11 +40,15 @@ export const qwenAdapter: ToolCommandAdapter = {
},

formatFile(content: CommandContent): string {
// Qwen commands are invoked by filename (/opsx-<id>), so cross-references
// must use the hyphen form too.
const transformedBody = transformToHyphenCommands(content.body);

return `---
description: ${escapeYamlValue(content.description)}
---

${content.body}
${transformedBody}
`;
},
};
9 changes: 8 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,14 @@ export class InitCommand {
for (const tool of successfulTools) {
let hint: string;
if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) {
hint = `Start your first change: ${command} "your idea"`;
// Tools that invoke commands by filename (bob, qwen, ...) need the
// hyphen form here too, not just inside generated bodies.
const transformer = getTransformerForTool(
tool.value,
activeDelivery,
resolveCommandSurfaceCapability(tool.value)
);
hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
} else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
hint =
resolveCommandSurfaceCapability(tool.value) === 'skills-invocable'
Expand Down
58 changes: 4 additions & 54 deletions src/core/parsers/requirement-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,60 +9,10 @@
* `validate <change>`, `validate <spec>`, and `archive`.
*/

/**
* Build a per-line mask marking lines that fall inside a fenced code block
* (``` ``` ``` or ``` ~~~ ```), including the fence lines themselves. Mirrors the
* fence rules markdown uses: a fence opens on the first ```` ```/~~~ ```` of
* length >= 3 and closes on a line of the same marker whose length is >= the
* opening length, with nothing but whitespace after it.
*/
export function buildCodeFenceMask(lines: string[]): boolean[] {
const mask = new Array(lines.length).fill(false);
let activeFence: { marker: '`' | '~'; length: number } | null = null;

for (let i = 0; i < lines.length; i++) {
const fence = getFenceMarker(lines[i]);

if (!activeFence) {
if (fence) {
activeFence = fence;
mask[i] = true;
}
continue;
}

mask[i] = true;
if (isClosingFence(lines[i], activeFence)) {
activeFence = null;
}
}

return mask;
}

function getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (!fenceMatch) {
return null;
}

return {
marker: fenceMatch[1][0] as '`' | '~',
length: fenceMatch[1].length,
};
}

function isClosingFence(
line: string,
activeFence: { marker: '`' | '~'; length: number }
): boolean {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
return Boolean(
fenceMatch &&
fenceMatch[1][0] === activeFence.marker &&
fenceMatch[1].length >= activeFence.length
);
}
// Re-exported so existing importers keep working; the single implementation
// lives in code-fence.ts.
export { buildCodeFenceMask } from './code-fence.js';
import { buildCodeFenceMask } from './code-fence.js';

/** Lines that look like `**ID**: ...` / `**Priority**: ...` metadata. */
const METADATA_LINE = /^\*\*[^*]+\*\*:/;
Expand Down
Loading
Loading