Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
3 changes: 2 additions & 1 deletion skills/openspec-ff-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ Fast-forward through artifact creation - generate everything needed to start imp
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob pattern, choose the concrete file path using the schema instruction and the change's context
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"

b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- **Before stopping, sweep for artifacts still `ready` and repeat until none remain** - creating one can unblock others. `status` flips to `done` as soon as a matching file exists, so writing `tasks.md` before `specs` satisfies the check above while leaving the spec unwritten. Create every remaining `ready` artifact; skip one only when its own `instruction` says it is conditional (spec-driven's `design.md`), and tell the user you did
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- Stop when all `applyRequires` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down
6 changes: 4 additions & 2 deletions skills/openspec-propose/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ metadata:

Propose a new change - create the change and generate all artifacts in one step.

I'll create a change with artifacts:
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
- proposal.md (what & why)
- `specs/<capability>/spec.md` (what the system must do) - a delta spec inside the change directory, not the main spec
- design.md (how)
- tasks.md (implementation steps)

Expand Down Expand Up @@ -69,13 +70,14 @@ When ready to implement, run /opsx:apply
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob pattern, choose the concrete file path using the schema instruction and the change's context
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- **Before stopping, sweep for artifacts still `ready` and repeat until none remain** - creating one can unblock others. `status` flips to `done` as soon as a matching file exists, so writing `tasks.md` before `specs` satisfies the check above while leaving the spec unwritten. Create every remaining `ready` artifact; skip one only when its own `instruction` says it is conditional (spec-driven's `design.md`), and tell the user you did
- Stop when all `applyRequires` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down
6 changes: 4 additions & 2 deletions src/core/templates/workflows/ff-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob pattern, choose the concrete file path using the schema instruction and the change's context
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"

b. **Continue until all \`applyRequires\` artifacts are complete**
- After creating each artifact, re-run \`openspec status --change "<name>" --json\`
- Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array
- **Before stopping, sweep for artifacts still \`ready\` and repeat until none remain** - creating one can unblock others. \`status\` flips to \`done\` as soon as a matching file exists, so writing \`tasks.md\` before \`specs\` satisfies the check above while leaving the spec unwritten. Create every remaining \`ready\` artifact; skip one only when its own \`instruction\` says it is conditional (spec-driven's \`design.md\`), and tell the user you did
- Stop when all \`applyRequires\` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down Expand Up @@ -167,13 +168,14 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob pattern, choose the concrete file path using the schema instruction and the change's context
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"

b. **Continue until all \`applyRequires\` artifacts are complete**
- After creating each artifact, re-run \`openspec status --change "<name>" --json\`
- Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array
- **Before stopping, sweep for artifacts still \`ready\` and repeat until none remain** - creating one can unblock others. \`status\` flips to \`done\` as soon as a matching file exists, so writing \`tasks.md\` before \`specs\` satisfies the check above while leaving the spec unwritten. Create every remaining \`ready\` artifact; skip one only when its own \`instruction\` says it is conditional (spec-driven's \`design.md\`), and tell the user you did
- Stop when all \`applyRequires\` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down
12 changes: 8 additions & 4 deletions src/core/templates/workflows/propose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export function getOpsxProposeSkillTemplate(): SkillTemplate {
description: 'Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.',
instructions: `Propose a new change - create the change and generate all artifacts in one step.

I'll create a change with artifacts:
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
- proposal.md (what & why)
- \`specs/<capability>/spec.md\` (what the system must do) - a delta spec inside the change directory, not the main spec
- design.md (how)
- tasks.md (implementation steps)

Expand Down Expand Up @@ -71,13 +72,14 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob pattern, choose the concrete file path using the schema instruction and the change's context
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

b. **Continue until all \`applyRequires\` artifacts are complete**
- After creating each artifact, re-run \`openspec status --change "<name>" --json\`
- Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array
- **Before stopping, sweep for artifacts still \`ready\` and repeat until none remain** - creating one can unblock others. \`status\` flips to \`done\` as soon as a matching file exists, so writing \`tasks.md\` before \`specs\` satisfies the check above while leaving the spec unwritten. Create every remaining \`ready\` artifact; skip one only when its own \`instruction\` says it is conditional (spec-driven's \`design.md\`), and tell the user you did
- Stop when all \`applyRequires\` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down Expand Up @@ -127,8 +129,9 @@ export function getOpsxProposeCommandTemplate(): CommandTemplate {
tags: ['workflow', 'artifacts', 'experimental'],
content: `Propose a new change - create the change and generate all artifacts in one step.

I'll create a change with artifacts:
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
- proposal.md (what & why)
- \`specs/<capability>/spec.md\` (what the system must do) - a delta spec inside the change directory, not the main spec
- design.md (how)
- tasks.md (implementation steps)

Expand Down Expand Up @@ -185,13 +188,14 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`
- Create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob pattern, choose the concrete file path using the schema instruction and the change's context
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

b. **Continue until all \`applyRequires\` artifacts are complete**
- After creating each artifact, re-run \`openspec status --change "<name>" --json\`
- Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array
- **Before stopping, sweep for artifacts still \`ready\` and repeat until none remain** - creating one can unblock others. \`status\` flips to \`done\` as soon as a matching file exists, so writing \`tasks.md\` before \`specs\` satisfies the check above while leaving the spec unwritten. Create every remaining \`ready\` artifact; skip one only when its own \`instruction\` says it is conditional (spec-driven's \`design.md\`), and tell the user you did
- Stop when all \`applyRequires\` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down
138 changes: 138 additions & 0 deletions test/core/templates/propose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { describe, expect, it } from 'vitest';

import {
getOpsxProposeSkillTemplate,
getOpsxProposeCommandTemplate,
getFfChangeSkillTemplate,
getOpsxFfCommandTemplate,
} from '../../../src/core/templates/skill-templates.js';
import { loadSchema } from '../../../src/core/artifact-graph/schema.js';

const skill = getOpsxProposeSkillTemplate();
const command = getOpsxProposeCommandTemplate();

// Both delivery surfaces carry the same body; every assertion runs against each.
const proposeBodies: Array<[string, string]> = [
['propose skill', skill.instructions],
['propose command', command.content],
];

// ff runs the identical artifact loop, so it needs the identical guard.
const loopBodies: Array<[string, string]> = [
...proposeBodies,
['ff skill', getFfChangeSkillTemplate().instructions],
['ff command', getOpsxFfCommandTemplate().content],
];

const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../../..');
const defaultSchema = loadSchema(path.join(repoRoot, 'schemas', 'spec-driven', 'schema.yaml'));

const READY_GUARD = 'Before stopping, sweep for artifacts still `ready` and repeat until none remain';
const STOP_CONDITION = 'Stop when all `applyRequires` artifacts are done';

/** The opening list that tells the agent which artifacts propose will produce. */
function artifactPreamble(body: string): string {
const start = body.indexOf("I'll create a change with");
const end = body.indexOf('When ready to implement');
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return body.slice(start, end);
}

describe('propose templates', () => {
// Issue #788/#1260: the preamble advertised proposal/design/tasks only, so
// agents treated specs as optional and produced changes with no spec at all.
it('advertises every artifact the default schema defines (#788, #1260)', () => {
const ids = defaultSchema.artifacts.map(artifact => artifact.id);
expect(ids).toContain('specs');

for (const [label, body] of proposeBodies) {
const preamble = artifactPreamble(body);
for (const id of ids) {
expect(preamble, `${label} preamble is missing the "${id}" artifact`).toContain(id);
}
expect(preamble, label).toContain('specs/<capability>/spec.md');
}
});

it('marks the spec as a delta inside the change directory, not the main spec', () => {
for (const [label, body] of proposeBodies) {
const preamble = artifactPreamble(body);
expect(preamble, label).toContain(
'a delta spec inside the change directory, not the main spec'
);
}
});

it('presents the artifact list as schema-derived, not fixed', () => {
for (const [label, body] of proposeBodies) {
const preamble = artifactPreamble(body);
expect(preamble, label).toContain('the artifacts your schema defines');
expect(preamble, label).toContain('default spec-driven schema');
}
});
});

describe('artifact loop guards (propose and ff)', () => {
// `status` is file-existence based (detectCompleted), so writing tasks.md before
// specs flips tasks to done and satisfies the applyRequires stop condition with
// specs never created. The guard is a stop-time sweep for leftover `ready`
// artifacts. It deliberately does NOT forbid writing a `blocked` artifact:
// tasks is blocked on design too, and docs/concepts.md states dependencies are
// enablers, not gates ("You can skip design if you don't need it") — forbidding
// it would deadlock propose for every change that skips design.
it('sweeps for leftover ready artifacts before stopping (#788, #1260)', () => {
for (const [label, body] of loopBodies) {
expect(body, label).toContain(READY_GUARD);
expect(body, label).toContain('Create every remaining `ready` artifact');
}
});

// A required artifact (specs) must not be skippable: `openspec validate`
// rejects a change with no deltas. The only machine-readable signal for
// "optional" is the artifact's own instruction text — spec-driven's design
// says "create only if any apply", specs says nothing of the kind — so the
// skip permission is anchored there rather than granted unconditionally.
it('permits skipping only artifacts their own instruction marks conditional', () => {
for (const [label, body] of loopBodies) {
expect(body, label).toContain(
'skip one only when its own `instruction` says it is conditional'
);
// No unconditional "or just say you skipped it" escape hatch.
expect(body, label).not.toMatch(/or tell the user which one you are skipping/i);
}
});

it('never forbids writing a blocked artifact, which would deadlock on optional design', () => {
// Property, not the one sentence the rejected first attempt used: any
// rewording that prohibits writing blocked artifacts reintroduces the
// deadlock, so match the shape rather than the exact string.
for (const [label, body] of loopBodies) {
expect(body, label).not.toMatch(/(never|do not|don't)\s+(write|create)[^.\n]{0,60}blocked/i);
expect(body, label).not.toMatch(/only\s+(write|create)[^.\n]{0,60}\bready\b/i);
}
});

it('places the guard before the stop condition it protects', () => {
for (const [label, body] of loopBodies) {
const guardAt = body.indexOf(READY_GUARD);
const stopAt = body.indexOf(STOP_CONDITION);
// Both must exist; indexOf returning -1 must not silently satisfy the order.
expect(guardAt, `${label} is missing the ready-sweep guard`).toBeGreaterThan(-1);
expect(stopAt, `${label} is missing the stop condition`).toBeGreaterThan(-1);
expect(guardAt, label).toBeLessThan(stopAt);
}
});

// The specs artifact `generates` a glob (specs/**/*.md), so an agent told only
// to "write it to resolvedOutputPath" would create a directory named `**`.
it('tells the agent how to resolve a glob output path', () => {
for (const [label, body] of loopBodies) {
expect(body, label).toContain(
'If `resolvedOutputPath` is a glob pattern, choose the concrete file path'
);
}
});
});
Loading
Loading