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 +8 -1
- package/dist/commands/change.js +116 -15
- package/dist/commands/prompt.js +3 -2
- package/dist/prompts/catalog.d.ts +1 -0
- package/dist/prompts/catalog.js +5 -0
- package/dist/prompts/include.d.ts +1 -0
- package/dist/prompts/include.js +35 -0
- package/dist/utils/change-next.d.ts +6 -1
- package/dist/utils/change-next.js +109 -36
- package/dist/utils/change.d.ts +2 -0
- package/dist/utils/change.js +14 -0
- package/package.json +3 -2
- package/templates/00_context.md +38 -27
- package/templates/01_requirement.md +35 -13
- package/templates/02_interface.md +31 -28
- package/templates/03_implementation.md +52 -54
- package/templates/04_test_spec.md +29 -14
- package/templates/claude-md-section.md +7 -1
- package/templates/prompts/daily/step0_context.md +9 -0
- package/templates/prompts/daily/step1_requirement.md +16 -0
- package/templates/prompts/daily/step2_implementation.md +18 -3
- package/templates/prompts/daily/step2_interface.md +17 -0
- package/templates/prompts/daily/step2_test_spec.md +17 -0
- package/templates/prompts/daily/step3_execute.md +19 -8
- package/templates/prompts/guide.md +6 -0
- package/templates/prompts/shared/spark_protocol.md +25 -0
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
|
|
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
|
package/dist/commands/change.js
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
111
|
-
logger.error(`change
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 ?? '(自动发现受影响域)'}`);
|
package/dist/commands/prompt.js
CHANGED
|
@@ -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 {};
|
package/dist/prompts/catalog.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
185
|
+
reason: '当前规模纳入接口/契约设计,且 02_interface.md 尚未生成。',
|
|
129
186
|
command: 'spec-canon prompt show iface',
|
|
130
187
|
priority: progress.requirement ? 20 : 45,
|
|
131
|
-
hiddenReason:
|
|
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
|
|
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:
|
|
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 && !
|
|
261
|
+
enabled: progress.domainSynced || (progress.changelogUpdated && !domainSyncInScope),
|
|
195
262
|
title: CANDIDATE_TITLES['change clear'],
|
|
196
|
-
reason:
|
|
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,
|
package/dist/utils/change.d.ts
CHANGED
|
@@ -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;
|