Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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, check for artifacts still `ready`.** `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 each remaining `ready` artifact, or tell the user which one you are skipping and why
- 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 every artifact your schema requires before implementation. 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, check for artifacts still `ready`.** `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 each remaining `ready` artifact, or tell the user which one you are skipping and why
- 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, check for artifacts still \`ready\`.** \`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 each remaining \`ready\` artifact, or tell the user which one you are skipping and why
- 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, check for artifacts still \`ready\`.** \`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 each remaining \`ready\` artifact, or tell the user which one you are skipping and why
- 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 every artifact your schema requires before implementation. 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, check for artifacts still \`ready\`.** \`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 each remaining \`ready\` artifact, or tell the user which one you are skipping and why
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 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 every artifact your schema requires before implementation. 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, check for artifacts still \`ready\`.** \`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 each remaining \`ready\` artifact, or tell the user which one you are skipping and why
- Stop when all \`applyRequires\` artifacts are done

c. **If an artifact requires user input** (unclear context):
Expand Down
120 changes: 120 additions & 0 deletions test/core/templates/propose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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, check for artifacts still `ready`';
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('every artifact your schema requires');
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 each remaining `ready` artifact');
expect(body, label).toContain('or tell the user which one you are skipping and why');
}
});

it('never forbids writing a blocked artifact, which would deadlock on optional design', () => {
for (const [label, body] of loopBodies) {
expect(body, label).not.toContain('Never write an artifact whose `status` is `blocked`');
}
});

it('places the guard immediately 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