@gordon.gan/specflow 1.2.1-beta → 1.3.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/change-new.d.ts +11 -1
- package/dist/cli/commands/change-new.js +33 -2
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +40 -4
- package/dist/core/archive.js +22 -4
- package/dist/core/artifact-graph/types.d.ts +2 -2
- package/dist/core/project-config.d.ts +4 -0
- package/dist/core/project-config.js +67 -0
- package/dist/core/store/foundation.d.ts +4 -4
- package/dist/core/upstream-spec.d.ts +14 -0
- package/dist/core/upstream-spec.js +74 -0
- package/dist/core/worksets.d.ts +2 -2
- package/package.json +1 -1
- package/skills/specflow-archive/SKILL.md +13 -3
- package/skills/specflow-propose/SKILL.md +62 -1
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* Creates a new change directory with .specflow.yaml metadata.
|
|
5
5
|
*/
|
|
6
6
|
import type { Command } from 'commander';
|
|
7
|
+
import { type UpstreamSpecSource } from '../../core/upstream-spec.js';
|
|
8
|
+
import type { StoreRegistry } from '../../core/store/foundation.js';
|
|
9
|
+
export interface CreateChangeOptions {
|
|
10
|
+
readonly source?: UpstreamSpecSource;
|
|
11
|
+
}
|
|
7
12
|
/**
|
|
8
13
|
* Creates a new change directory with initial metadata.
|
|
9
14
|
*
|
|
@@ -11,7 +16,12 @@ import type { Command } from 'commander';
|
|
|
11
16
|
* @param projectRoot - Absolute path to the project root
|
|
12
17
|
* @throws When the name is invalid or the change already exists
|
|
13
18
|
*/
|
|
14
|
-
export declare function createChange(name: string, projectRoot: string): Promise<void>;
|
|
19
|
+
export declare function createChange(name: string, projectRoot: string, options?: CreateChangeOptions): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Creates a same-name implementation Spoke change bound to an archived
|
|
22
|
+
* baseline spec from the configured upstream store.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createChangeFromSpec(specId: string, projectRoot: string, registry: StoreRegistry): Promise<void>;
|
|
15
25
|
/**
|
|
16
26
|
* Registers the `change new` subcommand with Commander.
|
|
17
27
|
*/
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { validateChangeName, writeChangeMetadata } from '../../utils/change-utils.js';
|
|
8
8
|
import { directoryExists } from '../../utils/file-system.js';
|
|
9
|
+
import { requireProjectRoot } from '../../utils/project-root.js';
|
|
10
|
+
import { resolveUpstreamSpec, } from '../../core/upstream-spec.js';
|
|
11
|
+
import { readRegistry } from '../../core/store/registry.js';
|
|
12
|
+
import { getStoreRegistryPath } from '../../core/global-config.js';
|
|
9
13
|
import { addStoreOption, resolveRootFromCommandOptions } from '../shared/store-option.js';
|
|
10
14
|
const CHANGES_REL_PATH = 'specflow/changes';
|
|
11
15
|
/**
|
|
@@ -25,7 +29,7 @@ function todayDate() {
|
|
|
25
29
|
* @param projectRoot - Absolute path to the project root
|
|
26
30
|
* @throws When the name is invalid or the change already exists
|
|
27
31
|
*/
|
|
28
|
-
export async function createChange(name, projectRoot) {
|
|
32
|
+
export async function createChange(name, projectRoot, options = {}) {
|
|
29
33
|
validateChangeName(name);
|
|
30
34
|
const changeDir = join(projectRoot, CHANGES_REL_PATH, name);
|
|
31
35
|
if (await directoryExists(changeDir)) {
|
|
@@ -35,17 +39,44 @@ export async function createChange(name, projectRoot) {
|
|
|
35
39
|
schema: 'specflow',
|
|
36
40
|
created: todayDate(),
|
|
37
41
|
phase: 'propose',
|
|
42
|
+
...(options.source ? { source: options.source } : {}),
|
|
38
43
|
};
|
|
39
44
|
await writeChangeMetadata(name, metadata, projectRoot);
|
|
40
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Creates a same-name implementation Spoke change bound to an archived
|
|
48
|
+
* baseline spec from the configured upstream store.
|
|
49
|
+
*/
|
|
50
|
+
export async function createChangeFromSpec(specId, projectRoot, registry) {
|
|
51
|
+
validateChangeName(specId);
|
|
52
|
+
const resolved = await resolveUpstreamSpec(projectRoot, specId, registry);
|
|
53
|
+
await createChange(specId, projectRoot, { source: resolved.source });
|
|
54
|
+
}
|
|
41
55
|
/**
|
|
42
56
|
* Registers the `change new` subcommand with Commander.
|
|
43
57
|
*/
|
|
44
58
|
export function registerChangeNewCommand(changeCmd) {
|
|
45
59
|
addStoreOption(changeCmd
|
|
46
|
-
.command('new
|
|
60
|
+
.command('new [name]')
|
|
47
61
|
.description('Create a new change directory')
|
|
62
|
+
.option('--spec <id>', 'Create a same-name Spoke change from an upstream baseline spec')
|
|
48
63
|
.action(async (name, opts) => {
|
|
64
|
+
if (opts.spec) {
|
|
65
|
+
if (name) {
|
|
66
|
+
throw new Error('Do not pass a change name with --spec; the change name is the spec ID.');
|
|
67
|
+
}
|
|
68
|
+
if (opts.store) {
|
|
69
|
+
throw new Error('--store cannot be combined with --spec; workflow.upstream selects the Hub.');
|
|
70
|
+
}
|
|
71
|
+
const projectRoot = requireProjectRoot();
|
|
72
|
+
const registry = await readRegistry(getStoreRegistryPath());
|
|
73
|
+
await createChangeFromSpec(opts.spec, projectRoot, registry);
|
|
74
|
+
console.info(`Created change from upstream spec: ${opts.spec}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!name) {
|
|
78
|
+
throw new Error('Change name is required unless --spec <id> is provided.');
|
|
79
|
+
}
|
|
49
80
|
const projectRoot = await resolveRootFromCommandOptions({ store: opts.store });
|
|
50
81
|
await createChange(name, projectRoot);
|
|
51
82
|
console.info(`Created change: ${name}`);
|
|
@@ -10,6 +10,8 @@ export interface InitResult {
|
|
|
10
10
|
export interface InitOptions {
|
|
11
11
|
readonly ide?: InitIdeTarget;
|
|
12
12
|
readonly artifactLanguage?: string;
|
|
13
|
+
readonly workflowProfile?: string;
|
|
14
|
+
readonly workflowUpstream?: string;
|
|
13
15
|
readonly forceAssets?: boolean;
|
|
14
16
|
readonly parityStrict?: boolean;
|
|
15
17
|
}
|
|
@@ -8,8 +8,24 @@ import { appendManagedBlock } from '../../integrations/shared/marker-write.js';
|
|
|
8
8
|
import { getRegeneratableIgnoreLines } from '../../integrations/shared/managed-assets.js';
|
|
9
9
|
import { detectMigrationState } from '../../integrations/shared/migration-state.js';
|
|
10
10
|
import { DEFAULT_ARTIFACT_LANGUAGE, requireArtifactLanguage, } from '../../core/artifact-language.js';
|
|
11
|
-
|
|
11
|
+
import { WORKFLOW_PROFILES, } from '../../core/project-config.js';
|
|
12
|
+
import { KEBAB_CASE_STORE_ID } from '../../core/store/foundation.js';
|
|
13
|
+
function requireWorkflowProfile(profile) {
|
|
14
|
+
if (WORKFLOW_PROFILES.includes(profile)) {
|
|
15
|
+
return profile;
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Unsupported workflow profile "${profile}". Supported values: ${WORKFLOW_PROFILES.join(', ')}.`);
|
|
18
|
+
}
|
|
19
|
+
function renderConfigYaml(artifactLanguage, workflowProfile, workflowUpstream) {
|
|
20
|
+
const workflowBlock = workflowProfile
|
|
21
|
+
? `
|
|
22
|
+
workflow:
|
|
23
|
+
profile: ${workflowProfile}${workflowUpstream ? `\n upstream: ${workflowUpstream}` : ''}
|
|
24
|
+
${workflowUpstream ? `\nreferences:\n - ${workflowUpstream}` : ''}
|
|
25
|
+
`
|
|
26
|
+
: '';
|
|
12
27
|
return `schema: specflow
|
|
28
|
+
${workflowBlock}
|
|
13
29
|
|
|
14
30
|
artifacts:
|
|
15
31
|
language: ${artifactLanguage}
|
|
@@ -38,8 +54,8 @@ async function createDirectoryStructure(projectRoot) {
|
|
|
38
54
|
fs.mkdir(join(projectRoot, 'specflow', 'specs'), { recursive: true }),
|
|
39
55
|
]);
|
|
40
56
|
}
|
|
41
|
-
async function writeConfig(projectRoot, artifactLanguage) {
|
|
42
|
-
await fs.writeFile(join(projectRoot, 'specflow', 'config.yaml'), renderConfigYaml(artifactLanguage), 'utf-8');
|
|
57
|
+
async function writeConfig(projectRoot, artifactLanguage, workflowProfile, workflowUpstream) {
|
|
58
|
+
await fs.writeFile(join(projectRoot, 'specflow', 'config.yaml'), renderConfigYaml(artifactLanguage, workflowProfile, workflowUpstream), 'utf-8');
|
|
43
59
|
}
|
|
44
60
|
async function pathExists(path) {
|
|
45
61
|
try {
|
|
@@ -61,6 +77,22 @@ export async function initProject(projectRoot, packageRoot, options = {}) {
|
|
|
61
77
|
const artifactLanguage = options.artifactLanguage === undefined
|
|
62
78
|
? DEFAULT_ARTIFACT_LANGUAGE
|
|
63
79
|
: requireArtifactLanguage(options.artifactLanguage);
|
|
80
|
+
const workflowProfile = options.workflowProfile === undefined
|
|
81
|
+
? undefined
|
|
82
|
+
: requireWorkflowProfile(options.workflowProfile);
|
|
83
|
+
const workflowUpstream = options.workflowUpstream;
|
|
84
|
+
if (workflowProfile === 'implementation-spoke' &&
|
|
85
|
+
workflowUpstream === undefined) {
|
|
86
|
+
throw new Error('The implementation-spoke workflow profile requires --upstream <store-id>.');
|
|
87
|
+
}
|
|
88
|
+
if (workflowUpstream !== undefined &&
|
|
89
|
+
!KEBAB_CASE_STORE_ID.test(workflowUpstream)) {
|
|
90
|
+
throw new Error('Workflow upstream must be a kebab-case store ID.');
|
|
91
|
+
}
|
|
92
|
+
if (workflowUpstream !== undefined &&
|
|
93
|
+
workflowProfile !== 'implementation-spoke') {
|
|
94
|
+
throw new Error('--upstream is only valid with --workflow-profile implementation-spoke.');
|
|
95
|
+
}
|
|
64
96
|
const ide = options.ide ?? 'both';
|
|
65
97
|
const forceAssets = options.forceAssets ?? false;
|
|
66
98
|
const parityStrict = options.parityStrict ?? true;
|
|
@@ -69,7 +101,7 @@ export async function initProject(projectRoot, packageRoot, options = {}) {
|
|
|
69
101
|
const migrationState = await detectMigrationState(projectRoot);
|
|
70
102
|
if (!initialized) {
|
|
71
103
|
await createDirectoryStructure(projectRoot);
|
|
72
|
-
await writeConfig(projectRoot, artifactLanguage);
|
|
104
|
+
await writeConfig(projectRoot, artifactLanguage, workflowProfile, workflowUpstream);
|
|
73
105
|
}
|
|
74
106
|
const languageEditMessage = options.artifactLanguage
|
|
75
107
|
? ' Edit specflow/config.yaml explicitly to change artifact language in an initialized project.'
|
|
@@ -115,6 +147,8 @@ export function registerInitCommand(program) {
|
|
|
115
147
|
.description('Initialize a project with specflow directory structure and assets')
|
|
116
148
|
.option('--ide <target>', 'Target IDE assets: claude | cursor | codex | both | all', 'both')
|
|
117
149
|
.option('--artifact-language <language>', 'Artifact content language: en | zh-CN (alias: zh)')
|
|
150
|
+
.option('--workflow-profile <profile>', 'Workflow profile: standalone | contract-hub | implementation-spoke')
|
|
151
|
+
.option('--upstream <store-id>', 'Primary contract store for implementation-spoke projects')
|
|
118
152
|
.option('--force-assets', 'Refresh managed IDE assets even when project is initialized')
|
|
119
153
|
.option('--no-parity-strict', 'Disable strict parity validation after asset generation')
|
|
120
154
|
.action(async (opts) => {
|
|
@@ -123,6 +157,8 @@ export function registerInitCommand(program) {
|
|
|
123
157
|
const result = await initProject(projectRoot, packageRoot, {
|
|
124
158
|
ide: opts.ide ?? 'both',
|
|
125
159
|
artifactLanguage: opts.artifactLanguage,
|
|
160
|
+
workflowProfile: opts.workflowProfile,
|
|
161
|
+
workflowUpstream: opts.upstream,
|
|
126
162
|
forceAssets: opts.forceAssets ?? false,
|
|
127
163
|
parityStrict: opts.parityStrict ?? true,
|
|
128
164
|
});
|
package/dist/core/archive.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { promises as fs } from 'node:fs';
|
|
10
10
|
import { join, relative } from 'node:path';
|
|
11
|
+
import yaml from 'js-yaml';
|
|
11
12
|
import { validateSpec } from './validation/validator.js';
|
|
12
13
|
import { applyDeltaSpec } from './specs-apply.js';
|
|
14
|
+
import { parseProjectConfig } from './project-config.js';
|
|
13
15
|
import { readChangeMetadata, writeChangeMetadata, } from '../utils/change-metadata.js';
|
|
14
16
|
/**
|
|
15
17
|
* Recursively list all `.md` files in a directory, returning paths relative to the base dir.
|
|
@@ -50,6 +52,15 @@ function todayDatePrefix() {
|
|
|
50
52
|
const day = String(now.getDate()).padStart(2, '0');
|
|
51
53
|
return `${year}-${month}-${day}`;
|
|
52
54
|
}
|
|
55
|
+
async function readWorkflowProfile(projectRoot) {
|
|
56
|
+
try {
|
|
57
|
+
const content = await fs.readFile(join(projectRoot, 'specflow', 'config.yaml'), 'utf-8');
|
|
58
|
+
return parseProjectConfig(yaml.load(content)).workflowProfile;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return 'standalone';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
53
64
|
/**
|
|
54
65
|
* Archive a completed change.
|
|
55
66
|
*
|
|
@@ -72,19 +83,26 @@ export async function archiveChange(changeName, projectRoot, options = {}) {
|
|
|
72
83
|
const changeDir = join(projectRoot, 'specflow', 'changes', changeName);
|
|
73
84
|
const deltaSpecsDir = join(changeDir, 'specs');
|
|
74
85
|
const mainSpecsDir = join(projectRoot, 'specflow', 'specs');
|
|
75
|
-
// 0. Phase gate:
|
|
86
|
+
// 0. Phase gate: contract Hubs may archive refined contracts; all other
|
|
87
|
+
// profiles retain the existing phase=apply requirement.
|
|
76
88
|
const metadata = await readChangeMetadata(changeDir);
|
|
77
89
|
const currentPhase = metadata?.phase;
|
|
78
|
-
|
|
90
|
+
const workflowProfile = await readWorkflowProfile(projectRoot);
|
|
91
|
+
const phaseAllowed = currentPhase === 'apply' ||
|
|
92
|
+
(workflowProfile === 'contract-hub' && currentPhase === 'refined');
|
|
93
|
+
if (!phaseAllowed && !options.force) {
|
|
79
94
|
const phaseLabel = currentPhase ?? 'unknown';
|
|
95
|
+
const expectation = workflowProfile === 'contract-hub'
|
|
96
|
+
? "expected 'refined' or 'apply'. Complete '/specflow:refine' first"
|
|
97
|
+
: "expected 'apply'. Complete '/specflow:apply' first";
|
|
80
98
|
return {
|
|
81
99
|
success: false,
|
|
82
100
|
errors: [
|
|
83
|
-
`Cannot archive: change '${changeName}' is in phase '${phaseLabel}',
|
|
101
|
+
`Cannot archive: change '${changeName}' is in phase '${phaseLabel}', ${expectation}, or pass '--force' to archive anyway.`,
|
|
84
102
|
],
|
|
85
103
|
};
|
|
86
104
|
}
|
|
87
|
-
if (options.force &&
|
|
105
|
+
if (options.force && !phaseAllowed) {
|
|
88
106
|
const phaseLabel = currentPhase ?? 'unknown';
|
|
89
107
|
console.warn(`Warning: archiving "${changeName}" in phase ${phaseLabel} with --force. ` +
|
|
90
108
|
`Consider running /specflow:apply first.`);
|
|
@@ -77,7 +77,6 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
77
77
|
tracks?: string | null | undefined;
|
|
78
78
|
}>>;
|
|
79
79
|
}, "strip", z.ZodTypeAny, {
|
|
80
|
-
version: number;
|
|
81
80
|
artifacts: {
|
|
82
81
|
id: string;
|
|
83
82
|
generates: string;
|
|
@@ -85,6 +84,7 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
85
84
|
requires: string[];
|
|
86
85
|
instruction?: string | undefined;
|
|
87
86
|
}[];
|
|
87
|
+
version: number;
|
|
88
88
|
name: string;
|
|
89
89
|
apply?: {
|
|
90
90
|
requires: string[];
|
|
@@ -93,7 +93,6 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
93
93
|
} | undefined;
|
|
94
94
|
description?: string | undefined;
|
|
95
95
|
}, {
|
|
96
|
-
version: number;
|
|
97
96
|
artifacts: {
|
|
98
97
|
id: string;
|
|
99
98
|
generates: string;
|
|
@@ -101,6 +100,7 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
101
100
|
instruction?: string | undefined;
|
|
102
101
|
requires?: string[] | undefined;
|
|
103
102
|
}[];
|
|
103
|
+
version: number;
|
|
104
104
|
name: string;
|
|
105
105
|
apply?: {
|
|
106
106
|
requires: string[];
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Diagnostic } from './diagnostics.js';
|
|
2
2
|
import { type ArtifactLanguage } from './artifact-language.js';
|
|
3
|
+
export declare const WORKFLOW_PROFILES: readonly ["standalone", "contract-hub", "implementation-spoke"];
|
|
4
|
+
export type WorkflowProfile = (typeof WORKFLOW_PROFILES)[number];
|
|
3
5
|
export interface NormalizedReference {
|
|
4
6
|
readonly id: string;
|
|
5
7
|
readonly remote?: string;
|
|
@@ -8,6 +10,8 @@ export interface ParsedProjectConfig {
|
|
|
8
10
|
readonly schema: string;
|
|
9
11
|
readonly context?: string;
|
|
10
12
|
readonly artifactLanguage: ArtifactLanguage;
|
|
13
|
+
readonly workflowProfile: WorkflowProfile;
|
|
14
|
+
readonly workflowUpstream?: string;
|
|
11
15
|
readonly store?: string;
|
|
12
16
|
readonly references: readonly NormalizedReference[];
|
|
13
17
|
readonly diagnostics: readonly Diagnostic[];
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { DEFAULT_ARTIFACT_LANGUAGE, isArtifactLanguage, } from './artifact-language.js';
|
|
3
3
|
const KEBAB_CASE_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
4
|
+
export const WORKFLOW_PROFILES = [
|
|
5
|
+
'standalone',
|
|
6
|
+
'contract-hub',
|
|
7
|
+
'implementation-spoke',
|
|
8
|
+
];
|
|
4
9
|
const ReferenceObjectSchema = z
|
|
5
10
|
.object({
|
|
6
11
|
id: z.string().regex(KEBAB_CASE_ID),
|
|
@@ -22,6 +27,8 @@ export function parseProjectConfig(raw) {
|
|
|
22
27
|
const schema = typeof record.schema === 'string' ? record.schema : 'specflow';
|
|
23
28
|
const context = typeof record.context === 'string' ? record.context : undefined;
|
|
24
29
|
let artifactLanguage = DEFAULT_ARTIFACT_LANGUAGE;
|
|
30
|
+
let workflowProfile = 'standalone';
|
|
31
|
+
let workflowUpstream;
|
|
25
32
|
if (record.artifacts !== undefined) {
|
|
26
33
|
const artifacts = typeof record.artifacts === 'object' &&
|
|
27
34
|
record.artifacts !== null &&
|
|
@@ -90,10 +97,70 @@ export function parseProjectConfig(raw) {
|
|
|
90
97
|
});
|
|
91
98
|
}
|
|
92
99
|
}
|
|
100
|
+
if (record.workflow !== undefined) {
|
|
101
|
+
const workflow = typeof record.workflow === 'object' &&
|
|
102
|
+
record.workflow !== null &&
|
|
103
|
+
!Array.isArray(record.workflow)
|
|
104
|
+
? record.workflow
|
|
105
|
+
: undefined;
|
|
106
|
+
if (!workflow) {
|
|
107
|
+
diagnostics.push({
|
|
108
|
+
severity: 'error',
|
|
109
|
+
code: 'invalid_workflow_config',
|
|
110
|
+
message: 'Project workflow configuration must be an object.',
|
|
111
|
+
target: 'config.workflow',
|
|
112
|
+
fix: 'Set workflow.profile to standalone, contract-hub, or implementation-spoke.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const configuredProfile = workflow.profile;
|
|
117
|
+
if (typeof configuredProfile === 'string' &&
|
|
118
|
+
WORKFLOW_PROFILES.includes(configuredProfile)) {
|
|
119
|
+
workflowProfile = configuredProfile;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
diagnostics.push({
|
|
123
|
+
severity: 'error',
|
|
124
|
+
code: 'invalid_workflow_profile',
|
|
125
|
+
message: "Workflow profile must be one of: 'standalone', 'contract-hub', 'implementation-spoke'.",
|
|
126
|
+
target: 'config.workflow.profile',
|
|
127
|
+
fix: 'Choose a supported workflow profile.',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (workflow.upstream !== undefined) {
|
|
131
|
+
if (typeof workflow.upstream === 'string' &&
|
|
132
|
+
KEBAB_CASE_ID.test(workflow.upstream)) {
|
|
133
|
+
workflowUpstream = workflow.upstream;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
diagnostics.push({
|
|
137
|
+
severity: 'error',
|
|
138
|
+
code: 'invalid_workflow_upstream',
|
|
139
|
+
message: 'Workflow upstream must be a kebab-case store ID.',
|
|
140
|
+
target: 'config.workflow.upstream',
|
|
141
|
+
fix: 'Set workflow.upstream to a referenced store ID.',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (workflowProfile === 'implementation-spoke' &&
|
|
148
|
+
workflowUpstream !== undefined &&
|
|
149
|
+
!references.some((reference) => reference.id === workflowUpstream)) {
|
|
150
|
+
diagnostics.push({
|
|
151
|
+
severity: 'error',
|
|
152
|
+
code: 'workflow_upstream_not_referenced',
|
|
153
|
+
message: `Workflow upstream '${workflowUpstream}' must also be declared in references.`,
|
|
154
|
+
target: 'config.workflow.upstream',
|
|
155
|
+
fix: `Add '${workflowUpstream}' to specflow/config.yaml references.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
93
158
|
return {
|
|
94
159
|
schema,
|
|
95
160
|
context,
|
|
96
161
|
artifactLanguage,
|
|
162
|
+
workflowProfile,
|
|
163
|
+
workflowUpstream,
|
|
97
164
|
store,
|
|
98
165
|
references,
|
|
99
166
|
diagnostics,
|
|
@@ -6,12 +6,12 @@ export declare const StoreIdentitySchema: z.ZodObject<{
|
|
|
6
6
|
id: z.ZodString;
|
|
7
7
|
remote: z.ZodOptional<z.ZodString>;
|
|
8
8
|
}, "strict", z.ZodTypeAny, {
|
|
9
|
-
version: 1;
|
|
10
9
|
id: string;
|
|
10
|
+
version: 1;
|
|
11
11
|
remote?: string | undefined;
|
|
12
12
|
}, {
|
|
13
|
-
version: 1;
|
|
14
13
|
id: string;
|
|
14
|
+
version: 1;
|
|
15
15
|
remote?: string | undefined;
|
|
16
16
|
}>;
|
|
17
17
|
export type StoreIdentity = z.infer<typeof StoreIdentitySchema>;
|
|
@@ -105,6 +105,7 @@ export declare const StoreRegistrySchema: z.ZodObject<{
|
|
|
105
105
|
};
|
|
106
106
|
}>>;
|
|
107
107
|
}, "strict", z.ZodTypeAny, {
|
|
108
|
+
version: 1;
|
|
108
109
|
stores: Record<string, {
|
|
109
110
|
provenance: "managed" | "external";
|
|
110
111
|
backend: {
|
|
@@ -114,8 +115,8 @@ export declare const StoreRegistrySchema: z.ZodObject<{
|
|
|
114
115
|
branch?: string | undefined;
|
|
115
116
|
};
|
|
116
117
|
}>;
|
|
117
|
-
version: 1;
|
|
118
118
|
}, {
|
|
119
|
+
version: 1;
|
|
119
120
|
stores: Record<string, {
|
|
120
121
|
provenance: "managed" | "external";
|
|
121
122
|
backend: {
|
|
@@ -125,7 +126,6 @@ export declare const StoreRegistrySchema: z.ZodObject<{
|
|
|
125
126
|
branch?: string | undefined;
|
|
126
127
|
};
|
|
127
128
|
}>;
|
|
128
|
-
version: 1;
|
|
129
129
|
}>;
|
|
130
130
|
export type StoreRegistry = z.infer<typeof StoreRegistrySchema>;
|
|
131
131
|
export type RegistryEntry = z.infer<typeof RegistryEntrySchema>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { StoreRegistry } from './store/foundation.js';
|
|
2
|
+
export interface UpstreamSpecSource {
|
|
3
|
+
readonly store: string;
|
|
4
|
+
readonly spec: string;
|
|
5
|
+
readonly digest: string;
|
|
6
|
+
readonly commit?: string;
|
|
7
|
+
readonly tag?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ResolvedUpstreamSpec {
|
|
10
|
+
readonly source: UpstreamSpecSource;
|
|
11
|
+
readonly content: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveUpstreamSpec(projectRoot: string, specId: string, registry: StoreRegistry): Promise<ResolvedUpstreamSpec>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { parseProjectConfig } from './project-config.js';
|
|
7
|
+
function readGitValue(root, args) {
|
|
8
|
+
try {
|
|
9
|
+
const value = execFileSync('git', ['-C', root, ...args], {
|
|
10
|
+
encoding: 'utf-8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
}).trim();
|
|
13
|
+
return value || undefined;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function resolveUpstreamSpec(projectRoot, specId, registry) {
|
|
20
|
+
const configPath = join(projectRoot, 'specflow', 'config.yaml');
|
|
21
|
+
let rawConfig;
|
|
22
|
+
try {
|
|
23
|
+
rawConfig = yaml.load(await fs.readFile(configPath, 'utf-8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`Cannot read Spoke configuration at ${configPath}.`);
|
|
27
|
+
}
|
|
28
|
+
const config = parseProjectConfig(rawConfig);
|
|
29
|
+
if (config.workflowProfile !== 'implementation-spoke') {
|
|
30
|
+
throw new Error("The --spec mode requires workflow.profile 'implementation-spoke'. " +
|
|
31
|
+
'Without --spec, use the existing single-repository flow.');
|
|
32
|
+
}
|
|
33
|
+
if (!config.workflowUpstream) {
|
|
34
|
+
throw new Error('The --spec mode requires workflow.upstream in specflow/config.yaml.');
|
|
35
|
+
}
|
|
36
|
+
const blockingDiagnostic = config.diagnostics.find((diagnostic) => diagnostic.severity === 'error');
|
|
37
|
+
if (blockingDiagnostic) {
|
|
38
|
+
throw new Error(blockingDiagnostic.message);
|
|
39
|
+
}
|
|
40
|
+
const storeId = config.workflowUpstream;
|
|
41
|
+
const store = registry.stores[storeId];
|
|
42
|
+
if (!store) {
|
|
43
|
+
throw new Error(`Upstream store '${storeId}' is not registered. ` +
|
|
44
|
+
`Run specflow store register <path> --id ${storeId} --yes.`);
|
|
45
|
+
}
|
|
46
|
+
const specPath = join(store.backend.local_path, 'specflow', 'specs', specId, 'spec.md');
|
|
47
|
+
let content;
|
|
48
|
+
try {
|
|
49
|
+
content = await fs.readFile(specPath, 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error(`Upstream spec '${specId}' was not found in store '${storeId}'. ` +
|
|
53
|
+
'The Hub must archive the spec before a Spoke can propose from it.');
|
|
54
|
+
}
|
|
55
|
+
const digest = `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
56
|
+
const commit = readGitValue(store.backend.local_path, ['rev-parse', 'HEAD']);
|
|
57
|
+
const tag = readGitValue(store.backend.local_path, [
|
|
58
|
+
'describe',
|
|
59
|
+
'--tags',
|
|
60
|
+
'--exact-match',
|
|
61
|
+
'HEAD',
|
|
62
|
+
]);
|
|
63
|
+
return {
|
|
64
|
+
source: {
|
|
65
|
+
store: storeId,
|
|
66
|
+
spec: specId,
|
|
67
|
+
digest,
|
|
68
|
+
...(commit ? { commit } : {}),
|
|
69
|
+
...(tag ? { tag } : {}),
|
|
70
|
+
},
|
|
71
|
+
content,
|
|
72
|
+
path: specPath,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/core/worksets.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ declare const WorksetsStateSchema: z.ZodObject<{
|
|
|
39
39
|
tool?: string | undefined;
|
|
40
40
|
}>, "many">;
|
|
41
41
|
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
version: 1;
|
|
42
43
|
worksets: {
|
|
43
44
|
name: string;
|
|
44
45
|
members: {
|
|
@@ -47,8 +48,8 @@ declare const WorksetsStateSchema: z.ZodObject<{
|
|
|
47
48
|
}[];
|
|
48
49
|
tool?: string | undefined;
|
|
49
50
|
}[];
|
|
50
|
-
version: 1;
|
|
51
51
|
}, {
|
|
52
|
+
version: 1;
|
|
52
53
|
worksets: {
|
|
53
54
|
name: string;
|
|
54
55
|
members: {
|
|
@@ -57,7 +58,6 @@ declare const WorksetsStateSchema: z.ZodObject<{
|
|
|
57
58
|
}[];
|
|
58
59
|
tool?: string | undefined;
|
|
59
60
|
}[];
|
|
60
|
-
version: 1;
|
|
61
61
|
}>;
|
|
62
62
|
export type WorksetsState = z.infer<typeof WorksetsStateSchema>;
|
|
63
63
|
export declare function validateWorksetName(name: string): void;
|
package/package.json
CHANGED
|
@@ -9,13 +9,23 @@ description: "Archive change + merge specs + git branch cleanup"
|
|
|
9
9
|
|
|
10
10
|
## Prerequisites
|
|
11
11
|
|
|
12
|
-
- An active change must exist
|
|
12
|
+
- An active change must exist.
|
|
13
13
|
- `specflow` CLI must be available on PATH.
|
|
14
|
-
-
|
|
14
|
+
- Read `specflow/config.yaml` and apply its workflow profile gate:
|
|
15
|
+
- Default, `workflow.profile: standalone`, and `implementation-spoke` require phase `apply`.
|
|
16
|
+
- `workflow.profile: contract-hub` permits phase `refined` or `apply`.
|
|
17
|
+
- A contract Hub publishes validated contract/specification artifacts and does not require business implementation. Do not route a refined contract Hub through a fake `/specflow:apply`.
|
|
18
|
+
- `specflow change archive` enforces these profile-aware gates. Do NOT pass `--force` from this skill; the flag remains reserved for explicit user discretion.
|
|
15
19
|
|
|
16
20
|
## Stage 1: Test Gate
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
For standalone and implementation Spoke projects, run the project test suite
|
|
23
|
+
for all affected modules. If implementation was done in a git worktree (common
|
|
24
|
+
after `/specflow:apply`), run tests inside that worktree.
|
|
25
|
+
|
|
26
|
+
For a contract Hub archiving from `refined`, run SpecFlow validation for every
|
|
27
|
+
delta spec and the repository's contract/schema tests. Business implementation
|
|
28
|
+
tests are not required when the repository has no business implementation.
|
|
19
29
|
|
|
20
30
|
If tests fail:
|
|
21
31
|
- Report failures to the user.
|
|
@@ -13,6 +13,53 @@ Propose is the **first-iteration deep-analysis pass**: in a single invocation it
|
|
|
13
13
|
|
|
14
14
|
Treat every artifact here as "v1, to be iterated on" — depth matters, but so does moving through all four stages in one pass.
|
|
15
15
|
|
|
16
|
+
## Invocation Modes
|
|
17
|
+
|
|
18
|
+
### Existing single-repository flow (default)
|
|
19
|
+
|
|
20
|
+
Without `--spec`, preserve the existing single-repository flow exactly:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
/specflow:propose <change-name>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Do not require workflow profiles, references, or an upstream store in this mode.
|
|
27
|
+
|
|
28
|
+
### Upstream Spec mode (implementation Spoke)
|
|
29
|
+
|
|
30
|
+
An implementation Spoke may derive a same-name local change from one archived
|
|
31
|
+
Hub baseline spec:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
/specflow:propose --spec <spec-id>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--spec` is explicit and takes exactly one kebab-case spec ID. In this mode:
|
|
38
|
+
|
|
39
|
+
1. Read `specflow/config.yaml`.
|
|
40
|
+
2. Require `workflow.profile: implementation-spoke`.
|
|
41
|
+
3. Require `workflow.upstream` and require that store ID in `references`.
|
|
42
|
+
4. Run `specflow change new --spec <spec-id>`. The CLI resolves the registered
|
|
43
|
+
upstream, requires `specflow/specs/<spec-id>/spec.md`, creates a same-name
|
|
44
|
+
local change, and records the source store/spec/digest plus Git commit/tag
|
|
45
|
+
when available.
|
|
46
|
+
5. Run `specflow show <spec-id> --type spec --store <workflow.upstream>` and
|
|
47
|
+
read the complete baseline spec before generating artifacts.
|
|
48
|
+
|
|
49
|
+
If the Hub has not archived the spec into its baseline `specs/` directory,
|
|
50
|
+
stop. Do not read an active Hub change as a stable dependency.
|
|
51
|
+
|
|
52
|
+
The Hub baseline spec is authoritative for cross-repository behavior, but all
|
|
53
|
+
four generated artifacts belong to the current Spoke:
|
|
54
|
+
|
|
55
|
+
- proposal: cite the source binding and limit impact to this repository;
|
|
56
|
+
- delta specs: express only this repository's testable behavior;
|
|
57
|
+
- design: describe this repository's implementation decisions;
|
|
58
|
+
- tasks: contain only this repository's implementation work.
|
|
59
|
+
|
|
60
|
+
Do not copy Hub proposal/design/tasks into the Spoke and do not redefine the
|
|
61
|
+
cross-repository contract.
|
|
62
|
+
|
|
16
63
|
## Prerequisites
|
|
17
64
|
|
|
18
65
|
- `specflow/specs/` directory should exist, indicating this is a specflow-initialized project. For a brand-new greenfield change with no existing specs, proceed — the prompt handles that case. If `specflow/` itself does not exist, suggest running `specflow init` first.
|
|
@@ -26,6 +73,10 @@ or tasks. Reuse the resolved policy for all four artifacts.
|
|
|
26
73
|
|
|
27
74
|
## Stage 0: Explore Handoff (when explore.md exists)
|
|
28
75
|
|
|
76
|
+
In Upstream Spec mode, use the resolved Hub baseline spec as the requirements
|
|
77
|
+
handoff and skip the local explore requirement. Otherwise follow the existing
|
|
78
|
+
explore handoff below.
|
|
79
|
+
|
|
29
80
|
Before creating a new change or generating a proposal, check for an existing exploration artifact:
|
|
30
81
|
|
|
31
82
|
```bash
|
|
@@ -53,7 +104,11 @@ ls specflow/changes/<name>/explore.md 2>/dev/null
|
|
|
53
104
|
|
|
54
105
|
## Stage 1: Create Change
|
|
55
106
|
|
|
56
|
-
|
|
107
|
+
- Upstream Spec mode: if the same-name change does not exist, run
|
|
108
|
+
`specflow change new --spec <spec-id>`. If it already exists, read
|
|
109
|
+
`.specflow.yaml`, require its `source.store` and `source.spec` to match the
|
|
110
|
+
current configuration and argument, and resume without overwriting artifacts.
|
|
111
|
+
- Existing single-repository flow: run `specflow change new <name>`.
|
|
57
112
|
|
|
58
113
|
The CLI automatically sets `phase=propose` in `.specflow.yaml` on creation (no separate phase call needed here).
|
|
59
114
|
|
|
@@ -65,6 +120,9 @@ Read the file at `.claude/specflow/prompts/propose/proposal.md` and follow its i
|
|
|
65
120
|
|
|
66
121
|
Generate the proposal document inside the change directory at `specflow/changes/<name>/proposal.md`.
|
|
67
122
|
|
|
123
|
+
In Upstream Spec mode, the proposal MUST name the bound upstream store, spec,
|
|
124
|
+
digest, and commit/tag when present in `.specflow.yaml`.
|
|
125
|
+
|
|
68
126
|
### Gate: Proposal Confirmation (HARD GATE)
|
|
69
127
|
|
|
70
128
|
Present the proposal to the user.
|
|
@@ -77,6 +135,9 @@ Read the file at `.claude/specflow/prompts/propose/specs.md` and follow its inst
|
|
|
77
135
|
|
|
78
136
|
Generate delta specs inside the change directory at `specflow/changes/<name>/specs/<capability>/spec.md`.
|
|
79
137
|
|
|
138
|
+
In Upstream Spec mode, derive these scenarios from the Hub baseline while
|
|
139
|
+
keeping only behavior testable in the current Spoke.
|
|
140
|
+
|
|
80
141
|
### Gate: Specs Confirmation (optional light gate)
|
|
81
142
|
|
|
82
143
|
Briefly summarize the delta specs generated. Accept a quick acknowledgement from the user ("looks good" / "continue") and proceed. If the user raises substantive objections, pause and revise — otherwise continue directly to Stage 4. This is an optional checkpoint, not a full hard gate; deeper scrutiny will happen in `/specflow:refine`.
|