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/schema-label-from-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Stop workflows from displaying schema names that `openspec list --json` does not return. Update and continue no longer fabricate a `spec-driven` picker label, while bulk archive and explore describe only the change fields the list command actually provides.
2 changes: 1 addition & 1 deletion skills/openspec-bulk-archive-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
2. **Prompt for change selection**

Ask the user to choose changes (multi-select):
- Show each change with its schema
- Show each change name and task status from the list output
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)

Expand Down
1 change: 0 additions & 1 deletion skills/openspec-continue-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ In both branches, never create the root as a side effect: do not run `openspec i

When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)

Expand Down
2 changes: 1 addition & 1 deletion skills/openspec-explore/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ openspec list --json

This tells you:
- If there are active changes
- Their names, schemas, and status
- Their names and task status
- What the user might be working on

That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
Expand Down
1 change: 0 additions & 1 deletion skills/openspec-update-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ This workflow revises artifacts that already exist; `/openspec-continue-change`

When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)

Expand Down
8 changes: 4 additions & 4 deletions src/core/templates/workflows/bulk-archive-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ ${PROJECT_ROOT_GUARD}
2. **Prompt for change selection**

Ask the user to choose changes (multi-select):
- Show each change with its schema
- Show each change name and task status from the list output
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)

Expand Down Expand Up @@ -219,7 +219,7 @@ ${PROJECT_ROOT_GUARD}
Process changes in the determined order (respecting conflict resolution):

a. **Sync included delta specs**:
- Run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
- ${optionalWorkflow('sync', 'Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
- For conflicts, apply in resolved order.
- Pass that change's fetched specs-rule snapshot into inline sync; inline
sync must reuse it without fetching instructions again
Expand Down Expand Up @@ -372,7 +372,7 @@ No active changes found. Create a new change to get started.
- Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-<name>; a name that already starts with a \`YYYY-MM-DD-\` prefix is used as-is (never stack a second date)
- If archive target exists, fail that change but continue with others
- Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved
- If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven) for each change with included delta specs
- If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)')} for each change with included delta specs
- Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas
- Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped
- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` before moving \`changeRoot\`
Expand Down Expand Up @@ -421,7 +421,7 @@ ${PROJECT_ROOT_GUARD}
2. **Prompt for change selection**

Ask the user to choose changes (multi-select):
- Show each change with its schema
- Show each change name and task status from the list output
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)

Expand Down
2 changes: 0 additions & 2 deletions src/core/templates/workflows/continue-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ ${PROJECT_ROOT_GUARD}

When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from \`schema\` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from \`lastModified\` field)

Expand Down Expand Up @@ -167,7 +166,6 @@ ${PROJECT_ROOT_GUARD}

When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from \`schema\` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from \`lastModified\` field)

Expand Down
4 changes: 2 additions & 2 deletions src/core/templates/workflows/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ openspec list --json

This tells you:
- If there are active changes
- Their names, schemas, and status
- Their names and task status
- What the user might be working on

That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
Expand Down Expand Up @@ -499,7 +499,7 @@ openspec list --json

This tells you:
- If there are active changes
- Their names, schemas, and status
- Their names and task status
- What the user might be working on

That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
Expand Down
2 changes: 0 additions & 2 deletions src/core/templates/workflows/update-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ ${CONTINUE_SCOPE_NOTE}

When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from \`schema\` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from \`lastModified\` field)

Expand Down Expand Up @@ -199,7 +198,6 @@ ${CONTINUE_SCOPE_NOTE}

When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from \`schema\` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from \`lastModified\` field)

Expand Down
49 changes: 49 additions & 0 deletions test/cli-e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,55 @@ describe('openspec CLI e2e basics', () => {
});
});

it.each([
{ tasks: '', completedTasks: 0, totalTasks: 0, status: 'no-tasks' },
{ tasks: '- [x] Done\n- [ ] Pending\n', completedTasks: 1, totalTasks: 2, status: 'in-progress' },
{ tasks: '- [x] Done\n', completedTasks: 1, totalTasks: 1, status: 'complete' },
])('lists custom-schema task status ($status) without inventing a schema field', async (expected) => {
const projectDir = await prepareFixture('tmp-init');
const schemaDir = path.join(projectDir, 'openspec', 'schemas', 'custom-workflow');
await fs.mkdir(schemaDir, { recursive: true });
await fs.writeFile(path.join(schemaDir, 'schema.yaml'), [
'name: custom-workflow',
'version: 1',
'description: Custom tracked work',
'artifacts:',
' - id: work',
' generates: work.md',
' description: Implementation work',
' template: work.md',
' requires: []',
'apply:',
' requires: [work]',
' tracks: work.md',
'',
].join('\n'));
const changeDir = path.join(projectDir, 'openspec', 'changes', 'custom-change');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: custom-workflow\n');
await fs.writeFile(path.join(changeDir, 'work.md'), expected.tasks);

const listResult = await runCLI(['list', '--json'], { cwd: projectDir });
expectJsonOnlyOutput(listResult);
const change = JSON.parse(listResult.stdout).changes.find(
(entry: { name: string }) => entry.name === 'custom-change'
);
expect(change).toMatchObject({
name: 'custom-change',
completedTasks: expected.completedTasks,
totalTasks: expected.totalTasks,
status: expected.status,
lastModified: expect.any(String),
});
expect(Number.isFinite(Date.parse(change.lastModified))).toBe(true);
expect(change).not.toHaveProperty('schema');
expect(change).not.toHaveProperty('schemaName');

const statusResult = await runCLI(['status', '--change', 'custom-change', '--json'], { cwd: projectDir });
expectJsonOnlyOutput(statusResult);
expect(JSON.parse(statusResult.stdout).schemaName).toBe('custom-workflow');
});

it('keeps schemas --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['schemas', '--json'], { cwd: projectDir });
Expand Down
33 changes: 23 additions & 10 deletions test/core/templates/archive-task-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,33 @@ import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getArchiveChangeSkillTemplate,
getBulkArchiveChangeSkillTemplate,
getOpsxArchiveCommandTemplate,
getOpsxBulkArchiveCommandTemplate,
} from '../../../src/core/templates/skill-templates.js';
import { generateSkillContent } from '../../../src/core/shared/skill-generation.js';
generateSkillContent,
getCommandTemplates,
getSkillTemplates,
} from '../../../src/core/shared/skill-generation.js';
import { getGlobalDataDir, registerStore } from '../../../src/core/index.js';
import { runCLI } from '../../helpers/run-cli.js';

// Go through getSkillTemplates/getCommandTemplates rather than the raw
// templates: these workflows carry optional-workflow blocks, and only these
// entry points resolve them against an installed set. Building from the raw
// template leaves `[[opsx:if-workflow ...]]` in the text, which skill
// generation rejects. The set names sync so the installed branch is chosen,
// which is the wording these assertions are about.
const WORKFLOWS = ['archive', 'bulk-archive', 'sync'];
const skill = (workflowId: string): string =>
generateSkillContent(
getSkillTemplates(WORKFLOWS).find((entry) => entry.workflowId === workflowId)!.template,
'test'
);
const command = (id: string): string =>
getCommandTemplates(WORKFLOWS).find((entry) => entry.id === id)!.template.content;

const surfaces = [
['archive skill', generateSkillContent(getArchiveChangeSkillTemplate(), 'test')],
['archive command', getOpsxArchiveCommandTemplate().content],
['bulk archive skill', generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'test')],
['bulk archive command', getOpsxBulkArchiveCommandTemplate().content],
['archive skill', skill('archive')],
['archive command', command('archive')],
['bulk archive skill', skill('bulk-archive')],
['bulk archive command', command('bulk-archive')],
] as const;

describe('archive task discovery uses schema-resolved CLI progress', () => {
Expand Down
97 changes: 97 additions & 0 deletions test/core/templates/list-json-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';

import {
getBulkArchiveChangeSkillTemplate,
getContinueChangeSkillTemplate,
getExploreSkillTemplate,
getOpsxBulkArchiveCommandTemplate,
getOpsxContinueCommandTemplate,
getOpsxExploreCommandTemplate,
getOpsxUpdateCommandTemplate,
getUpdateChangeSkillTemplate,
} from '../../../src/core/templates/skill-templates.js';
import { getCommandTemplates, getSkillTemplates } from '../../../src/core/shared/skill-generation.js';

describe('workflow list --json field usage', () => {
it('does not invent schema labels in update and continue pickers', () => {
const bodies = [
getUpdateChangeSkillTemplate().instructions,
getOpsxUpdateCommandTemplate().content,
getContinueChangeSkillTemplate().instructions,
getOpsxContinueCommandTemplate().content,
];

for (const body of bodies) {
const picker = body.slice(body.indexOf('1. **Select the change**'), body.indexOf('2. **'));
expect(picker).toContain('openspec list --json');
expect(picker).toContain('- Change name');
expect(picker).toContain('- Status');
expect(picker).toContain('`lastModified`');
expect(picker).not.toMatch(/schema/i);
expect(picker).not.toContain('openspec status');

const status = body.slice(body.indexOf('2. **'), body.indexOf('3. **'));
expect(status).toContain('openspec status --change "<name>" --json');
expect(status).toContain('`schemaName`');
}
});

it('limits bulk archive selection to list fields', () => {
const bodies = [
getBulkArchiveChangeSkillTemplate().instructions,
getOpsxBulkArchiveCommandTemplate().content,
];

for (const body of bodies) {
const picker = body.slice(body.indexOf('2. **'), body.indexOf('3. **'));
expect(picker).toContain('Show each change name and task status from the list output');
expect(picker).not.toMatch(/schema/i);
expect(picker).not.toContain('openspec status');

const status = body.slice(body.indexOf('3. **'), body.indexOf('4. **'));
expect(status).toContain('openspec status --change "<name>" --json');
expect(status).toContain('`schemaName`');
}
});

it('does not claim explore receives schemas from list output', () => {
const bodies = [
getExploreSkillTemplate().instructions,
getOpsxExploreCommandTemplate().content,
];

for (const body of bodies) {
expect(body).toContain('Their names and task status');
expect(body).not.toContain('Their names, schemas, and status');
}
});

it('keeps bulk archive sync available with and without the sync workflow', () => {
const variants = [
[
getSkillTemplates(['bulk-archive', 'sync']).find((entry) => entry.workflowId === 'bulk-archive')!.template.instructions,
getSkillTemplates(['bulk-archive'])[0].template.instructions,
'openspec-sync-specs',
],
[
getCommandTemplates(['bulk-archive', 'sync']).find((entry) => entry.id === 'bulk-archive')!.template.content,
getCommandTemplates(['bulk-archive'])[0].template.content,
'/opsx:sync',
],
] as const;

for (const [withSync, withoutSync, workflow] of variants) {
const syncStep = (text: string) => text.slice(
text.indexOf('a. **Sync included delta specs**'),
text.indexOf('b. **Verify included delta specs')
);

expect(syncStep(withSync)).toContain(workflow);
expect(syncStep(withoutSync)).not.toContain(workflow);
expect(syncStep(withoutSync)).toContain('Perform the delta-to-main-spec merge inline yourself');
expect(syncStep(withoutSync)).toContain('`includedDeltas`');
expect(syncStep(withoutSync)).toContain('`excludedDeltas`');
expect(withoutSync).toContain('If sync is requested, perform the delta-to-main-spec merge inline');
}
});
});
Loading
Loading