@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.
@@ -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 <name>')
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
- function renderConfigYaml(artifactLanguage) {
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
  });
@@ -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: require phase=apply unless --force
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
- if (currentPhase !== 'apply' && !options.force) {
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}', expected 'apply'. Complete '/specflow:apply' first, or pass '--force' to archive anyway.`,
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 && currentPhase !== 'apply') {
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gordon.gan/specflow",
3
- "version": "1.2.1-beta",
3
+ "version": "1.3.0-beta",
4
4
  "type": "module",
5
5
  "description": "SpecFlow — unified spec-driven development: OpenSpec planning + Superpowers execution in one CLI and cross-IDE workflow",
6
6
  "keywords": [
@@ -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 with completed implementation.
12
+ - An active change must exist.
13
13
  - `specflow` CLI must be available on PATH.
14
- - **Phase must be `apply`** in `.specflow.yaml`. This is set automatically when `/specflow:apply` Phase B completes. `specflow change archive` refuses to archive changes whose phase is not `apply` unless the caller passes `--force` explicitly. Do NOT pass `--force` from this skill — the flag is reserved for explicit user discretion. If archive fails with a phase check error, route the user back to `/specflow:apply` to complete the missing phase transition rather than forcing past the guard.
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
- Run the project test suite for all affected modules. If implementation was done in a git worktree (common after `/specflow:apply`), run tests inside that worktree.
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
- Run `specflow change new <name>` to initialize a new change directory.
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`.