spec-canon 0.1.14 → 0.1.17

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/README.md CHANGED
@@ -101,12 +101,18 @@ spec-canon prompt show <id>
101
101
  spec-canon prompt guide
102
102
  ```
103
103
 
104
- `change next` 会根据当前 active change 的文档进度输出候选步骤列表,默认采用宽松模式:保留 `iface` `domain-sync` 候选,让用户自己决定 next。若确认没有接口变化或无需系统级归档,可通过 `--no-contract-change`、`--no-system-change` 收窄候选。
104
+ `change next` 会根据当前 active change 的文档进度和变更规模输出候选步骤列表。用 `--scale small|medium|complex` 指定规模:small≈`req→impl→review`、medium 增加 `iface`、complex 才铺开 `ctx/impl-spec/test-spec/domain-sync`。省略 `--scale` 时按 type 推默认(fix/chore/docs/style/ci/test→small,feat/refactor/perf→medium,complex 不自动推导)。在规模基础上还可用 `--no-contract-change`、`--no-system-change`、`-d <domain>` 进一步收窄。
105
+
106
+ `ctx` 内置 Explore project context preflight:生成 `00_context` 前会先探索项目入口、SDD 文档、历史决策、近期提交和真实代码入口;只有 goal 过大或无法定位主域时才暂停澄清,普通证据缺口会写入“待补充 / 待确认”。
107
+
108
+ `req`、`iface`、`impl-spec`、`test-spec` 内置 spark gate:生成正式 Spec 前会先检查是否存在关键澄清点或多方案决策。若存在,提示词会要求 AI 暂停写文件,先提出问题或方案比较,等待人工确认后再继续;若输入已经明确,则直接生成目标 Spec。
105
109
 
106
110
  `change` 还支持父命令选项 `-d, --dir <path>` 指定目标项目目录,默认当前目录。由于 `change next` 已用 `-d` 表示 `--domain`,指定目录时请写在子命令前:`spec-canon change -d /path/to/project next`。
107
111
 
108
112
  `sync` 会在项目内写入 `spec-canon/.template-manifest.json`,用于记录当前骨架状态,为后续更智能的升级能力预留基础。若有文件需要人工比对,CLI 会同时输出参考模板路径和建议的 `code --diff` / `diff -u` 命令,方便直接对照已安装包中的模板;对于 `spec-canon/templates/` 下的 6 个核心 Spec 模板,还会额外给出可直接覆盖的 `cp` 命令。
109
113
 
114
+ 仓库内额外提供了实验性 `skills/` 原型,用于把“CLI 输出 prompt 再复制粘贴给 agent”的动作收敛为 agent 内部直接调用 CLI 并执行。它们目前仅用于内部验证,不属于稳定 CLI 接口。
115
+
110
116
  ## 文档结构
111
117
 
112
118
  ```
@@ -124,6 +130,7 @@ docs/
124
130
  ├── prompts/ ← 提示词文件(CLI `spec-canon prompt` 数据源)
125
131
  ├── reference/ ← 速查表(随时查阅)
126
132
  └── templates/ ← Spec 模板(7 个)
133
+ skills/ ← 实验性 SDD skills(内部验证)
127
134
  ```
128
135
 
129
136
  ## ROADMAP
@@ -1,9 +1,17 @@
1
+ import { readdir, stat } from 'node:fs/promises';
1
2
  import { join, resolve } from 'node:path';
2
3
  import { ActiveChangeError, clearActiveChange, getActiveChangeProgress, ensureSddRoot, getActiveChangeStatus, getChangeDir, readActiveChange, writeActiveChange, } from '../utils/active-change.js';
3
- import { getChangeSeq, getChangeType, getNextChangeSeq, isValidChangeName, } from '../utils/change.js';
4
- import { buildChangeNextPlan } from '../utils/change-next.js';
4
+ import { CHANGE_TYPE_VALUES, getChangeSeq, getChangeType, getNextChangeSeq, isValidChangeName, isValidChangeType, } from '../utils/change.js';
5
+ import { buildChangeNextPlan, SCALE_VALUES, } from '../utils/change-next.js';
5
6
  import { ensureDir, fileExists } from '../utils/fs.js';
6
7
  import { logger } from '../utils/logger.js';
8
+ const IGNORED_PRECREATED_CHANGE_FILES = new Set([
9
+ '.DS_Store',
10
+ 'Thumbs.db',
11
+ ]);
12
+ const ADOPTABLE_PRECREATED_CHANGE_FILES = new Set([
13
+ '00_context.md',
14
+ ]);
7
15
  export function registerChangeCommand(program) {
8
16
  const change = program
9
17
  .command('change')
@@ -14,6 +22,8 @@ export function registerChangeCommand(program) {
14
22
  .description('启动或确认当前 change')
15
23
  .option('-c, --change <name>', '已确认的变更目录名')
16
24
  .option('-g, --goal <content>', '变更目标 / 问题描述 / PRD 引用')
25
+ .option('--reuse-change', '允许复用已存在且包含历史内容的 change 目录(高风险)', false)
26
+ .option('--yes', '确认复用旧 change 的风险并继续', false)
17
27
  .action(async (opts, command) => {
18
28
  await runStart(opts, getRootDir(command));
19
29
  });
@@ -33,6 +43,7 @@ export function registerChangeCommand(program) {
33
43
  .command('next')
34
44
  .description('输出当前可选的下一步提示词候选')
35
45
  .option('--type <type>', '变更类型(feat / fix / refactor / perf / chore / ci / docs / style / test)')
46
+ .option('--scale <scale>', '变更规模(small / medium / complex);省略则按 type 推导')
36
47
  .option('--no-contract-change', '收窄候选:本次不涉及接口 / Schema / 数据契约变化')
37
48
  .option('--no-system-change', '收窄候选:本次不会沉淀系统级知识')
38
49
  .option('-d, --domain <name>', '将 domain-sync 收窄到单域')
@@ -74,12 +85,27 @@ async function runStart(opts, rootDir) {
74
85
  process.exitCode = 1;
75
86
  return;
76
87
  }
77
- if (await fileExists(getChangeDir(opts.change, rootDir))) {
78
- logger.error(`change 目录已存在:spec-canon/changes/${opts.change}。为避免复用旧 change,请改用新的 change 名,或先运行 spec-canon prompt show ctx`);
88
+ const targetDir = getChangeDir(opts.change, rootDir);
89
+ const inspection = await inspectChangeDir(targetDir);
90
+ if (inspection.pathKind === 'non-directory') {
91
+ logger.error(`change 路径不是目录:spec-canon/changes/${opts.change}。请先清理该文件后重试`);
79
92
  process.exitCode = 1;
80
93
  return;
81
94
  }
82
- await ensureDir(getChangeDir(opts.change, rootDir));
95
+ if (inspection.state === 'conflict') {
96
+ if (!opts.reuseChange) {
97
+ logger.error(`change 目录已存在:spec-canon/changes/${opts.change}。如需显式复用旧 change,请使用 --reuse-change --yes`);
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ if (!opts.yes) {
102
+ logger.error(`检测到旧 change 内容:spec-canon/changes/${opts.change}(${inspection.effectiveEntries.join(', ')})。复用可能混入历史上下文;如确认复用,请执行 spec-canon change start -g <goal> -c <change> --reuse-change --yes`);
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ logger.warn(`正在复用旧 change:spec-canon/changes/${opts.change};已有内容:${inspection.effectiveEntries.join(', ')}。请确认本次 goal 与历史上下文一致。`);
107
+ }
108
+ await ensureDir(targetDir);
83
109
  await writeActiveChange({
84
110
  ...existing,
85
111
  change: opts.change,
@@ -102,17 +128,39 @@ async function runStart(opts, rootDir) {
102
128
  const changesDir = join(rootDir, 'spec-canon', 'changes');
103
129
  await ensureDir(changesDir);
104
130
  if (opts.change) {
131
+ const targetDir = getChangeDir(opts.change, rootDir);
132
+ const inspection = await inspectChangeDir(targetDir);
133
+ const targetState = inspection.state;
134
+ const targetExists = targetState !== 'missing';
135
+ const targetConflict = targetState === 'conflict';
136
+ const canInspectExistingLatestTarget = targetExists &&
137
+ getPrevChangeSeq(nextChangeSeq) === changeSeq;
105
138
  if (changeSeq !== nextChangeSeq) {
106
- logger.error(`change 名中的序号 ${changeSeq} 不是当前可用序号 ${nextChangeSeq}。为避免复用旧 change,请先运行 spec-canon change start -g <goal>,再执行 spec-canon prompt show ctx`);
107
- process.exitCode = 1;
108
- return;
139
+ if (!canInspectExistingLatestTarget) {
140
+ logger.error(`change 名中的序号 ${changeSeq} 不是当前可用序号 ${nextChangeSeq}。为避免复用旧 change,请先运行 spec-canon change start -g <goal>,再执行 spec-canon prompt show ctx`);
141
+ process.exitCode = 1;
142
+ return;
143
+ }
109
144
  }
110
- if (await fileExists(getChangeDir(opts.change, rootDir))) {
111
- logger.error(`change 目录已存在:spec-canon/changes/${opts.change}。为避免复用旧 change,请改用新的 change 名,或先运行 spec-canon change start -g <goal> 再执行 spec-canon prompt show ctx`);
145
+ if (inspection.pathKind === 'non-directory') {
146
+ logger.error(`change 路径不是目录:spec-canon/changes/${opts.change}。请先清理该文件后重试`);
112
147
  process.exitCode = 1;
113
148
  return;
114
149
  }
115
- await ensureDir(getChangeDir(opts.change, rootDir));
150
+ if (targetConflict) {
151
+ if (!opts.reuseChange) {
152
+ logger.error(`change 目录已存在:spec-canon/changes/${opts.change}。如需显式复用旧 change,请使用 --reuse-change --yes`);
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ if (!opts.yes) {
157
+ logger.error(`检测到旧 change 内容:spec-canon/changes/${opts.change}(${inspection.effectiveEntries.join(', ')})。复用可能混入历史上下文;如确认复用,请执行 spec-canon change start -g <goal> -c <change> --reuse-change --yes`);
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ logger.warn(`正在复用旧 change:spec-canon/changes/${opts.change};已有内容:${inspection.effectiveEntries.join(', ')}。请确认本次 goal 与历史上下文一致。`);
162
+ }
163
+ await ensureDir(targetDir);
116
164
  }
117
165
  await writeActiveChange({
118
166
  goal: opts.goal,
@@ -129,6 +177,46 @@ async function runStart(opts, rootDir) {
129
177
  logger.info('下一步:运行 spec-canon prompt show ctx,确认建议的 change 名后,可再次执行 change start -g <goal> -c <change>');
130
178
  }
131
179
  }
180
+ function getPrevChangeSeq(seq) {
181
+ const seqNum = Number.parseInt(seq, 10);
182
+ if (!Number.isFinite(seqNum) || seqNum <= 1) {
183
+ return null;
184
+ }
185
+ return String(seqNum - 1).padStart(3, '0');
186
+ }
187
+ async function inspectChangeDir(dirPath) {
188
+ if (!(await fileExists(dirPath))) {
189
+ return {
190
+ state: 'missing',
191
+ pathKind: 'missing',
192
+ effectiveEntries: [],
193
+ };
194
+ }
195
+ const targetStat = await stat(dirPath);
196
+ if (!targetStat.isDirectory()) {
197
+ return {
198
+ state: 'conflict',
199
+ pathKind: 'non-directory',
200
+ effectiveEntries: ['<non-directory-path>'],
201
+ };
202
+ }
203
+ const entries = await readdir(dirPath, { withFileTypes: true });
204
+ const effectiveEntries = entries.filter((entry) => !IGNORED_PRECREATED_CHANGE_FILES.has(entry.name));
205
+ if (effectiveEntries.length === 0) {
206
+ return {
207
+ state: 'adoptable',
208
+ pathKind: 'directory',
209
+ effectiveEntries: [],
210
+ };
211
+ }
212
+ const onlyPrecreatedContext = effectiveEntries.every((entry) => entry.isFile() &&
213
+ ADOPTABLE_PRECREATED_CHANGE_FILES.has(entry.name));
214
+ return {
215
+ state: onlyPrecreatedContext ? 'adoptable' : 'conflict',
216
+ pathKind: 'directory',
217
+ effectiveEntries: effectiveEntries.map((entry) => entry.name),
218
+ };
219
+ }
132
220
  async function runStatus(rootDir) {
133
221
  try {
134
222
  await ensureSddRoot(rootDir);
@@ -176,9 +264,20 @@ async function runNext(opts, rootDir) {
176
264
  try {
177
265
  await ensureSddRoot(rootDir);
178
266
  const active = await readActiveChange(rootDir);
179
- const type = resolveNextType(active?.change, opts.type);
180
- if (!type) {
181
- logger.error('change next 需要指定 --type,或在 confirmed active change 下从 change 名自动推导');
267
+ if (opts.type && !isValidChangeType(opts.type)) {
268
+ logger.error(`--type 仅支持 ${CHANGE_TYPE_VALUES.join(' / ')}`);
269
+ process.exitCode = 1;
270
+ return;
271
+ }
272
+ if (opts.scale && !SCALE_VALUES.includes(opts.scale)) {
273
+ logger.error(`--scale 仅支持 ${SCALE_VALUES.join(' / ')}`);
274
+ process.exitCode = 1;
275
+ return;
276
+ }
277
+ const type = resolveNextType(active?.change, opts.type) ?? undefined;
278
+ // type 只在需要推导规模时才必需;显式 --scale 时可缺省。
279
+ if (!type && !opts.scale) {
280
+ logger.error('change next 需要指定 --type 或 --scale,或在 confirmed active change 下从 change 名自动推导');
182
281
  process.exitCode = 1;
183
282
  return;
184
283
  }
@@ -189,6 +288,7 @@ async function runNext(opts, rootDir) {
189
288
  type,
190
289
  contractChange: opts.contractChange,
191
290
  systemChange: opts.systemChange,
291
+ scale: opts.scale,
192
292
  domain: opts.domain,
193
293
  });
194
294
  printNextPlan(plan, {
@@ -234,7 +334,8 @@ function resolveNextType(change, explicitType) {
234
334
  return getChangeType(change);
235
335
  }
236
336
  function printNextPlan(plan, opts) {
237
- console.log(`type: ${opts.type}`);
337
+ console.log(`type: ${opts.type ?? '(未指定,按 --scale)'}`);
338
+ console.log(`scale: ${plan.scale}`);
238
339
  console.log(`contract-change: ${opts.contractChange ? '默认纳入候选' : '已收窄'}`);
239
340
  console.log(`system-change: ${opts.systemChange ? '默认纳入候选' : '已收窄'}`);
240
341
  console.log(`domain: ${opts.domain ?? '(自动发现受影响域)'}`);
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { ActiveChangeError, readActiveChange } from '../utils/active-change.js';
4
- import { findPrompt, getPromptsDir, listPrompts, loadPromptContent, } from '../prompts/catalog.js';
4
+ import { findPrompt, getPromptsDir, listPrompts, loadExpandedPromptContent, loadPromptContent, } from '../prompts/catalog.js';
5
5
  import { renderPrompt } from '../prompts/renderer.js';
6
6
  import { getNextChangeSeq } from '../utils/change.js';
7
7
  import { logger } from '../utils/logger.js';
@@ -101,11 +101,12 @@ async function runShow(id, opts) {
101
101
  process.exitCode = 1;
102
102
  return;
103
103
  }
104
- const template = await loadPromptContent(entry);
105
104
  if (opts.raw) {
105
+ const template = await loadPromptContent(entry);
106
106
  process.stdout.write(template);
107
107
  return;
108
108
  }
109
+ const template = await loadExpandedPromptContent(entry);
109
110
  const variables = await resolveVariables(entry.id, opts);
110
111
  if (!variables)
111
112
  return;
@@ -16,5 +16,6 @@ export declare function loadCatalog(): Promise<CatalogData>;
16
16
  export declare function findPrompt(id: string): Promise<PromptEntry | undefined>;
17
17
  export declare function listPrompts(stage?: string): Promise<PromptEntry[]>;
18
18
  export declare function loadPromptContent(entry: PromptEntry): Promise<string>;
19
+ export declare function loadExpandedPromptContent(entry: PromptEntry): Promise<string>;
19
20
  export declare function getPromptsDir(): string;
20
21
  export {};
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { expandPromptIncludes } from './include.js';
4
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
6
  const PROMPTS_DIR = resolve(__dirname, '../../templates/prompts');
6
7
  let cachedCatalog = null;
@@ -26,6 +27,10 @@ export async function loadPromptContent(entry) {
26
27
  const filePath = join(PROMPTS_DIR, entry.file);
27
28
  return readFile(filePath, 'utf-8');
28
29
  }
30
+ export async function loadExpandedPromptContent(entry) {
31
+ const template = await loadPromptContent(entry);
32
+ return expandPromptIncludes(template, PROMPTS_DIR);
33
+ }
29
34
  export function getPromptsDir() {
30
35
  return PROMPTS_DIR;
31
36
  }
@@ -0,0 +1 @@
1
+ export declare function expandPromptIncludes(template: string, baseDir: string): Promise<string>;
@@ -0,0 +1,35 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+ const INCLUDE_LINE_RE = /^[ \t]*<!--\s*@include\s+(\S+)\s*-->[ \t\r]*$/;
4
+ export async function expandPromptIncludes(template, baseDir) {
5
+ const resolvedBaseDir = resolve(baseDir);
6
+ const lines = template.split('\n');
7
+ const expanded = [];
8
+ for (const line of lines) {
9
+ const match = line.match(INCLUDE_LINE_RE);
10
+ if (!match) {
11
+ expanded.push(line);
12
+ continue;
13
+ }
14
+ const includePath = match[1];
15
+ if (isAbsolute(includePath)) {
16
+ throw new Error(`Include path must be relative: ${includePath}`);
17
+ }
18
+ const targetPath = resolve(resolvedBaseDir, includePath);
19
+ const relativePath = relative(resolvedBaseDir, targetPath);
20
+ if (relativePath === '' ||
21
+ relativePath.startsWith('..') ||
22
+ isAbsolute(relativePath)) {
23
+ throw new Error(`Include path escapes prompts directory: ${includePath}`);
24
+ }
25
+ const content = await readFile(targetPath, 'utf-8');
26
+ const hasNestedInclude = content
27
+ .split('\n')
28
+ .some((contentLine) => INCLUDE_LINE_RE.test(contentLine));
29
+ if (hasNestedInclude) {
30
+ throw new Error(`Nested include is not supported: ${includePath}`);
31
+ }
32
+ expanded.push(content);
33
+ }
34
+ return expanded.join('\n');
35
+ }
@@ -1,10 +1,13 @@
1
1
  import type { ActiveChange, ActiveChangeProgress } from './active-change.js';
2
2
  import type { ChangeType } from './change.js';
3
3
  export type NextCandidateId = 'ctx' | 'req' | 'iface' | 'impl-spec' | 'test-spec' | 'impl' | 'review' | 'domain-sync' | 'change clear';
4
+ export type ChangeScale = 'small' | 'medium' | 'complex';
5
+ export declare const SCALE_VALUES: readonly ChangeScale[];
4
6
  export interface ChangeNextOptions {
5
- type: ChangeType;
7
+ type?: ChangeType;
6
8
  contractChange: boolean;
7
9
  systemChange: boolean;
10
+ scale?: ChangeScale;
8
11
  domain?: string;
9
12
  }
10
13
  export interface NextCandidate {
@@ -22,10 +25,12 @@ export interface HiddenCandidate {
22
25
  export interface ChangeNextPlan {
23
26
  active: ActiveChange | null;
24
27
  progress: ActiveChangeProgress | null;
28
+ scale: ChangeScale;
25
29
  blockers: string[];
26
30
  warnings: string[];
27
31
  notes: string[];
28
32
  candidates: NextCandidate[];
29
33
  hidden: HiddenCandidate[];
30
34
  }
35
+ export declare function resolveScale(type: ChangeType | undefined, explicit?: ChangeScale): ChangeScale;
31
36
  export declare function buildChangeNextPlan(active: ActiveChange | null, progress: ActiveChangeProgress | null, options: ChangeNextOptions): ChangeNextPlan;
@@ -1,3 +1,4 @@
1
+ export const SCALE_VALUES = ['small', 'medium', 'complex'];
1
2
  const CHANGE_REQUIRED = [
2
3
  'req',
3
4
  'iface',
@@ -8,6 +9,38 @@ const CHANGE_REQUIRED = [
8
9
  'domain-sync',
9
10
  'change clear',
10
11
  ];
12
+ // 规模 → 该规模默认纳入的候选集(与文档「任务规模裁剪」表一致)。
13
+ // small : 小 Bugfix / 小需求 req → impl → review
14
+ // medium : 中等需求 req → iface → impl → review → domain-sync
15
+ // complex: 复杂 / 跨域 ctx → req → iface → impl-spec → test-spec → impl → review → domain-sync
16
+ const SCALE_CANDIDATES = {
17
+ small: ['req', 'impl', 'review', 'change clear'],
18
+ medium: ['req', 'iface', 'impl', 'review', 'domain-sync', 'change clear'],
19
+ complex: [
20
+ 'ctx',
21
+ 'req',
22
+ 'iface',
23
+ 'impl-spec',
24
+ 'test-spec',
25
+ 'impl',
26
+ 'review',
27
+ 'domain-sync',
28
+ 'change clear',
29
+ ],
30
+ };
31
+ // 省略 --scale 时由 type 推导默认规模。complex 永远不自动推导——
32
+ // 它需要跨域 / 系统级证据,必须由人或 agent 显式 --scale complex。
33
+ const DEFAULT_SCALE_BY_TYPE = {
34
+ feat: 'medium',
35
+ fix: 'small',
36
+ refactor: 'medium',
37
+ perf: 'medium',
38
+ chore: 'small',
39
+ ci: 'small',
40
+ docs: 'small',
41
+ style: 'small',
42
+ test: 'small',
43
+ };
11
44
  const TYPE_LABELS = {
12
45
  feat: '功能变更',
13
46
  fix: '缺陷修复',
@@ -19,6 +52,11 @@ const TYPE_LABELS = {
19
52
  style: '样式调整',
20
53
  test: '测试变更',
21
54
  };
55
+ const SCALE_LABELS = {
56
+ small: '小改动',
57
+ medium: '中等改动',
58
+ complex: '复杂 / 跨域',
59
+ };
22
60
  const CANDIDATE_TITLES = {
23
61
  ctx: '生成 00_context',
24
62
  req: '生成 01_requirement',
@@ -30,23 +68,37 @@ const CANDIDATE_TITLES = {
30
68
  'domain-sync': '归档:domain_spec + 域间关系',
31
69
  'change clear': '清理 active change',
32
70
  };
71
+ export function resolveScale(type, explicit) {
72
+ if (explicit)
73
+ return explicit;
74
+ // 命令层保证 type 与 scale 至少有一个存在;此处兜底以防被内部误用。
75
+ return type ? DEFAULT_SCALE_BY_TYPE[type] : 'medium';
76
+ }
77
+ function scaleIncludes(scale, id) {
78
+ return SCALE_CANDIDATES[scale].includes(id);
79
+ }
80
+ function scaleHiddenReason(scale) {
81
+ return `当前规模 ${scale}(${SCALE_LABELS[scale]})不含此步;如确有需要,用 --scale 提升规模。`;
82
+ }
33
83
  export function buildChangeNextPlan(active, progress, options) {
84
+ const scale = resolveScale(options.type, options.scale);
34
85
  if (!active) {
35
- return buildNoActivePlan(options.type);
86
+ return buildNoActivePlan(options.type, scale);
36
87
  }
37
88
  if (!active.change) {
38
- return buildPendingPlan(active, options);
89
+ return buildPendingPlan(active, options, scale);
39
90
  }
40
- return buildConfirmedPlan(active, progress ?? emptyProgress(), options);
91
+ return buildConfirmedPlan(active, progress ?? emptyProgress(), options, scale);
41
92
  }
42
- function buildNoActivePlan(type) {
43
- const notes = [typeNote(type)];
44
- if (type !== 'feat' && type !== 'perf' && type !== 'refactor') {
93
+ function buildNoActivePlan(type, scale) {
94
+ const notes = [typeNote(type), scaleNote(scale)];
95
+ if (scale === 'small') {
45
96
  notes.push('如果只是极小修复,也可以直接更新 AI_CHANGELOG,无需先建立 active change。');
46
97
  }
47
98
  return {
48
99
  active: null,
49
100
  progress: null,
101
+ scale,
50
102
  blockers: ['先运行 `spec-canon change start -g <goal>` 建立 active change。'],
51
103
  warnings: [],
52
104
  notes,
@@ -59,7 +111,7 @@ function buildNoActivePlan(type) {
59
111
  ],
60
112
  };
61
113
  }
62
- function buildPendingPlan(active, options) {
114
+ function buildPendingPlan(active, options, scale) {
63
115
  const hidden = CHANGE_REQUIRED.map((id) => ({
64
116
  id,
65
117
  reason: '当前是 pending active change,需先确认 change 名。',
@@ -79,16 +131,17 @@ function buildPendingPlan(active, options) {
79
131
  return {
80
132
  active,
81
133
  progress: emptyProgress(),
134
+ scale,
82
135
  blockers: [
83
136
  '如需运行依赖 CHANGE 的提示词,先执行 `spec-canon change start -g <goal> -c <change>` 确认 change 名。',
84
137
  ],
85
138
  warnings: [],
86
- notes: [typeNote(options.type)],
139
+ notes: [typeNote(options.type), scaleNote(scale)],
87
140
  candidates: [
88
141
  {
89
142
  id: 'ctx',
90
143
  title: CANDIDATE_TITLES['ctx'],
91
- reason: 'pending active change 已具备 GOAL,可先生成 00_context',
144
+ reason: 'pending active change 已具备 GOAL,可先生成 00_context 并获得 change 命名建议。',
92
145
  command: 'spec-canon prompt show ctx',
93
146
  priority: 0,
94
147
  },
@@ -96,59 +149,63 @@ function buildPendingPlan(active, options) {
96
149
  hidden: dedupeHidden(hidden),
97
150
  };
98
151
  }
99
- function buildConfirmedPlan(active, progress, options) {
152
+ function buildConfirmedPlan(active, progress, options, scale) {
100
153
  const warnings = getWarnings(progress);
101
154
  const candidates = [];
102
155
  const hidden = [];
156
+ const ctxInScale = scaleIncludes(scale, 'ctx');
103
157
  pushCandidate(candidates, {
104
158
  id: 'ctx',
105
- enabled: !progress.context,
159
+ enabled: ctxInScale && !progress.context,
106
160
  title: CANDIDATE_TITLES['ctx'],
107
161
  reason: '00_context.md 尚未生成,适合先补齐系统现状。',
108
162
  command: 'spec-canon prompt show ctx',
109
163
  priority: 0,
110
- hiddenReason: '00_context.md 已存在。',
111
- });
164
+ hiddenReason: ctxInScale ? '00_context.md 已存在。' : scaleHiddenReason(scale),
165
+ }, hidden);
112
166
  pushCandidate(candidates, {
113
167
  id: 'req',
114
- enabled: !progress.requirement,
168
+ enabled: scaleIncludes(scale, 'req') && !progress.requirement,
115
169
  title: CANDIDATE_TITLES['req'],
116
170
  reason: '01_requirement.md 尚未生成,建议先明确需求与验收标准。',
117
171
  command: 'spec-canon prompt show req',
118
172
  priority: 10,
119
173
  hiddenReason: '01_requirement.md 已存在。',
120
174
  }, hidden);
121
- const ifaceReason = options.contractChange
122
- ? '当前保留接口/契约变化可能,且 02_interface.md 尚未生成。'
123
- : '已使用 --no-contract-change 收窄候选。';
175
+ const ifaceInScale = scaleIncludes(scale, 'iface');
176
+ const ifaceHiddenReason = !ifaceInScale
177
+ ? scaleHiddenReason(scale)
178
+ : !options.contractChange
179
+ ? '已使用 --no-contract-change 收窄候选。'
180
+ : '02_interface.md 已存在。';
124
181
  pushCandidate(candidates, {
125
182
  id: 'iface',
126
- enabled: options.contractChange && !progress.interface,
183
+ enabled: ifaceInScale && options.contractChange && !progress.interface,
127
184
  title: CANDIDATE_TITLES['iface'],
128
- reason: ifaceReason,
185
+ reason: '当前规模纳入接口/契约设计,且 02_interface.md 尚未生成。',
129
186
  command: 'spec-canon prompt show iface',
130
187
  priority: progress.requirement ? 20 : 45,
131
- hiddenReason: options.contractChange
132
- ? '02_interface.md 已存在。'
133
- : '已使用 --no-contract-change 收窄候选。',
188
+ hiddenReason: ifaceHiddenReason,
134
189
  }, hidden);
190
+ const implSpecInScale = scaleIncludes(scale, 'impl-spec');
135
191
  pushCandidate(candidates, {
136
192
  id: 'impl-spec',
137
- enabled: !progress.implementation,
193
+ enabled: implSpecInScale && !progress.implementation,
138
194
  title: CANDIDATE_TITLES['impl-spec'],
139
195
  reason: implSpecReason(options.type, progress),
140
196
  command: 'spec-canon prompt show impl-spec',
141
197
  priority: implSpecPriority(options.type, progress),
142
- hiddenReason: '03_implementation.md 已存在。',
198
+ hiddenReason: implSpecInScale ? '03_implementation.md 已存在。' : scaleHiddenReason(scale),
143
199
  }, hidden);
200
+ const testSpecInScale = scaleIncludes(scale, 'test-spec');
144
201
  pushCandidate(candidates, {
145
202
  id: 'test-spec',
146
- enabled: !progress.testSpec,
203
+ enabled: testSpecInScale && !progress.testSpec,
147
204
  title: CANDIDATE_TITLES['test-spec'],
148
205
  reason: testSpecReason(options.type, progress),
149
206
  command: 'spec-canon prompt show test-spec',
150
207
  priority: progress.implementation ? 32 : 36,
151
- hiddenReason: '04_test_spec.md 已存在。',
208
+ hiddenReason: testSpecInScale ? '04_test_spec.md 已存在。' : scaleHiddenReason(scale),
152
209
  }, hidden);
153
210
  pushCandidate(candidates, {
154
211
  id: 'impl',
@@ -170,32 +227,40 @@ function buildConfirmedPlan(active, progress, options) {
170
227
  ? 'AI_CHANGELOG 已包含当前 change。'
171
228
  : '当前还缺少足够的前置 Spec/编码结果。',
172
229
  }, hidden);
230
+ const domainSyncInScale = scaleIncludes(scale, 'domain-sync');
231
+ const domainSyncInScope = domainSyncInScale && options.systemChange;
173
232
  const domainSyncCommand = options.domain
174
233
  ? `spec-canon prompt show domain-sync -d ${options.domain}`
175
234
  : 'spec-canon prompt show domain-sync';
176
235
  const domainSyncReason = options.domain
177
236
  ? `AI_CHANGELOG 已更新,可将回填范围收窄到域 ${options.domain}。`
178
237
  : 'AI_CHANGELOG 已更新,可默认让 domain-sync 自动发现受影响域。';
238
+ const domainSyncHiddenReason = progress.domainSynced
239
+ ? 'domain-sync 已完成。'
240
+ : !domainSyncInScale
241
+ ? scaleHiddenReason(scale)
242
+ : !options.systemChange
243
+ ? '已使用 --no-system-change 收窄候选。'
244
+ : '完成 review 并更新 AI_CHANGELOG 后再执行。';
179
245
  pushCandidate(candidates, {
180
246
  id: 'domain-sync',
181
- enabled: progress.changelogUpdated && !progress.domainSynced && options.systemChange,
247
+ enabled: domainSyncInScope && progress.changelogUpdated && !progress.domainSynced,
182
248
  title: CANDIDATE_TITLES['domain-sync'],
183
249
  reason: domainSyncReason,
184
250
  command: domainSyncCommand,
185
251
  priority: 80,
186
- hiddenReason: progress.domainSynced
187
- ? 'domain-sync 已完成。'
188
- : options.systemChange
189
- ? '完成 review 并更新 AI_CHANGELOG 后再执行。'
190
- : '已使用 --no-system-change 收窄候选。',
252
+ hiddenReason: domainSyncHiddenReason,
191
253
  }, hidden);
254
+ const clearReason = progress.domainSynced
255
+ ? 'domain-sync 已完成,可清理当前 active change。'
256
+ : !domainSyncInScale
257
+ ? `当前规模 ${scale}(${SCALE_LABELS[scale]})无需 domain-sync,可在 review 后直接清理。`
258
+ : '已使用 --no-system-change 收窄归档步骤,可直接清理 active change。';
192
259
  pushCandidate(candidates, {
193
260
  id: 'change clear',
194
- enabled: progress.domainSynced || (progress.changelogUpdated && !options.systemChange),
261
+ enabled: progress.domainSynced || (progress.changelogUpdated && !domainSyncInScope),
195
262
  title: CANDIDATE_TITLES['change clear'],
196
- reason: progress.domainSynced
197
- ? 'domain-sync 已完成,可清理当前 active change。'
198
- : '已使用 --no-system-change 收窄归档步骤,可直接清理 active change。',
263
+ reason: clearReason,
199
264
  command: 'spec-canon change clear',
200
265
  priority: 90,
201
266
  hiddenReason: '当前 change 仍未完成 Review/归档。',
@@ -203,10 +268,12 @@ function buildConfirmedPlan(active, progress, options) {
203
268
  return {
204
269
  active,
205
270
  progress,
271
+ scale,
206
272
  blockers: [],
207
273
  warnings,
208
274
  notes: [
209
275
  typeNote(options.type),
276
+ scaleNote(scale),
210
277
  options.domain
211
278
  ? `domain-sync 将限定在单域 ${options.domain}。`
212
279
  : 'domain-sync 未指定 -d,将默认自动发现受影响域。',
@@ -280,8 +347,14 @@ function getWarnings(progress) {
280
347
  return warnings;
281
348
  }
282
349
  function typeNote(type) {
350
+ if (!type) {
351
+ return '变更类型:未指定(已由 --scale 显式指定规模)。';
352
+ }
283
353
  return `变更类型:${type}(${TYPE_LABELS[type]})。`;
284
354
  }
355
+ function scaleNote(scale) {
356
+ return `变更规模:${scale}(${SCALE_LABELS[scale]});候选已按规模裁剪。`;
357
+ }
285
358
  function emptyProgress() {
286
359
  return {
287
360
  context: false,
@@ -1,5 +1,7 @@
1
1
  export declare const CHANGE_NAME_PATTERN: RegExp;
2
2
  export type ChangeType = 'feat' | 'fix' | 'refactor' | 'perf' | 'chore' | 'ci' | 'docs' | 'style' | 'test';
3
+ export declare const CHANGE_TYPE_VALUES: readonly ChangeType[];
4
+ export declare function isValidChangeType(value: string): value is ChangeType;
3
5
  export declare function isValidChangeName(change: string): boolean;
4
6
  export declare function getChangeSeq(change: string): string | null;
5
7
  export declare function getChangeType(change: string): ChangeType | null;