sillyspec 3.20.5 → 3.20.6
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/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +5 -0
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +3 -3
- package/docs/sillyspec/file-lifecycle.md +4 -3
- package/package.json +1 -1
- package/src/index.js +4 -4
- package/src/progress.js +18 -0
- package/src/quick-recommend.js +115 -0
- package/src/run.js +142 -22
- package/src/stage-contract.js +9 -3
- package/src/stages/quick.js +36 -26
- package/src/sync.js +4 -3
- package/test/decision-ref-version.mjs +85 -0
- package/test/platform-scan-p0.test.mjs +18 -7
- package/test/quick-recommend.test.mjs +146 -0
- package/test/stage-contract.test.mjs +5 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
author: qinyi
|
|
3
3
|
created_at: 2026-06-04 16:25:42
|
|
4
|
+
updated_at: 2026-07-02 11:00:00
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# 平台模式、Workflow 与 Sync
|
|
@@ -24,6 +25,10 @@ created_at: 2026-06-04 16:25:42
|
|
|
24
25
|
|
|
25
26
|
后续 `--done` / `--skip` 会从该文件恢复参数。
|
|
26
27
|
|
|
28
|
+
## 平台残留清理(一次性)
|
|
29
|
+
|
|
30
|
+
`run.js` 在平台模式(`specRoot` 指向外部)首次启动时,会检查源码目录 `cwd/.sillyspec/` 是否为旧版本残留并清理(复用 `init.js` 的 `cleanupRuntimeResidue`)。该清理**仅在首次执行一次**:完成后在 cwd 根写入 `.sillyspec-platform-cleaned` 标记文件,后续每次 `run` 直接跳过,避免重复检查与噪声。清理白名单与边界见总览的「平台模式残留清理边界」。
|
|
31
|
+
|
|
27
32
|
## 路径替换
|
|
28
33
|
|
|
29
34
|
`run.js outputStep()` 在 scan 阶段替换:
|
|
@@ -31,7 +31,7 @@ created_at: 2026-06-04 16:25:42
|
|
|
31
31
|
| execute | 12+ | 生成/使用 worktree,按 Wave 执行;最终 apply/cleanup |
|
|
32
32
|
| verify | 7 | 写 `verify-result.md` |
|
|
33
33
|
| archive | 5 | 写 `module-impact.md`,同步模块文档,归档目录 |
|
|
34
|
-
| quick | 3 |
|
|
34
|
+
| quick | 3 | 始终写 quicklog;关联变更时另在各 change tasks.md 追加并勾选 task;直接改主工作区 |
|
|
35
35
|
|
|
36
36
|
## 变更四件套
|
|
37
37
|
|
|
@@ -42,7 +42,7 @@ created_at: 2026-06-04 16:25:42
|
|
|
42
42
|
| `proposal.md` | brainstorm 第 11 步;propose 第 5 步 | propose/plan/verify/archive prompt |
|
|
43
43
|
| `design.md` | brainstorm 第 10/11 步;propose 第 5 步 | plan、execute、verify、worktree apply 的文件清单 |
|
|
44
44
|
| `requirements.md` | brainstorm 第 11 步;propose 第 5 步 | plan、verify |
|
|
45
|
-
| `tasks.md` | brainstorm 第 11 步;propose 第 5 步;quick
|
|
45
|
+
| `tasks.md` | brainstorm 第 11 步;propose 第 5 步;quick 关联变更时可追加 task | plan、execute、verify、archive |
|
|
46
46
|
|
|
47
47
|
`run.js validateFileLocations()` 在阶段完成时会检查:
|
|
48
48
|
|
|
@@ -140,7 +140,7 @@ created_at: 2026-06-04 16:25:42
|
|
|
140
140
|
|
|
141
141
|
路径:`.sillyspec/quicklog/QUICKLOG-<git-user>.md`
|
|
142
142
|
|
|
143
|
-
创建方式:quick 阶段“理解任务”prompt
|
|
143
|
+
创建方式:quick 阶段“理解任务”prompt,**每次 quick 都创建/追加**(无论是否关联变更)。关联变更时,同一 ql-ID 会同步写入各关联变更的 tasks.md 作为未勾选 task,step 3 完成时勾选。
|
|
144
144
|
|
|
145
145
|
格式规则来自 prompt:
|
|
146
146
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
author: qinyi
|
|
3
3
|
created_at: 2026-05-31 11:00:00
|
|
4
|
-
updated_at: 2026-07-
|
|
4
|
+
updated_at: 2026-07-02 11:00:00
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# SillySpec 文件生命周期
|
|
@@ -66,15 +66,16 @@ updated_at: 2026-07-01 15:00:00
|
|
|
66
66
|
| `.sillyspec/changes/archive/` | 是 | archive `确认归档 --confirm` 分支 | 已归档变更目录 |
|
|
67
67
|
| `.sillyspec/knowledge/` | 是 | `init.js` 建目录;scan「Extract Project Knowledge」步骤产出 | `INDEX.md`、`uncategorized.md`,以及 scan 提取的 `conventions.md`/`patterns.md`/`known-issues.md` |
|
|
68
68
|
| `.sillyspec/workflows/` | 是 | `init.js` 从模板复制 | workflow check 定义 |
|
|
69
|
-
| `.sillyspec/quicklog/` | 是 | quick prompt |
|
|
69
|
+
| `.sillyspec/quicklog/` | 是 | quick prompt | 每次 quick 任务记录(始终写入;关联变更另在各 change tasks.md 勾选) |
|
|
70
70
|
| `.sillyspec/shared/` | 是 | `init.js` | 共享目录,当前无核心生命周期逻辑 |
|
|
71
71
|
| `.sillyspec/workspace/` | 是 | `init.js` | 工作区目录,当前无核心生命周期逻辑 |
|
|
72
72
|
| `.sillyspec/.runtime/` | 否 | `init.js`、`ProgressManager`、运行时命令 | DB、gate、artifacts、history、workflow-runs、worktrees、knowledge-hit-report.json、postcheck-result.json |
|
|
73
73
|
|
|
74
74
|
`init.js` 会把 `.sillyspec/.runtime/`、`.sillyspec/local.yaml`、`.sillyspec/codebase/SCAN-RAW.md` 追加到 `.gitignore`。
|
|
75
75
|
|
|
76
|
-
> **平台模式残留清理边界**(`init.js` `cleanupRuntimeResidue`,由 `run.js`
|
|
76
|
+
> **平台模式残留清理边界**(`init.js` `cleanupRuntimeResidue`,由 `run.js` 启动时首次执行一次):
|
|
77
77
|
> 当 `specRoot` 指向外部、源码目录的 `.sillyspec/` 含真实资产(`changes/`/`projects/`/`sillyspec.db`)时,只清理运行时残留,**不整删 `.runtime/`**。清理白名单保留权威状态:`worktrees/`、`sillyspec.db`、`global.json`、`gate-status.json`、`contract-artifacts/`、`execute-runs/`;其余子项(`artifacts/`、`scan-runs/`、`scan-projects.json`、`user-inputs.md`、`postcheck-result.json` 等可重建缓存)逐项删除,`local.yaml`、`codebase/` 整删。未知子项默认保留(安全侧倾斜)。
|
|
78
|
+
> 该清理在 `run.js` 启动时**仅执行一次**:首次处理后写 cwd 根的 `.sillyspec-platform-cleaned` 标记文件,后续每次 `run` 直接跳过。旧版每次启动都打印 `❌ 拒绝删除` 红叉属误导性噪声(清理既不阻塞流程也不动真实资产),已降为 `ℹ️` 一次性提示。
|
|
78
79
|
|
|
79
80
|
## 主要文件流
|
|
80
81
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
10
10
|
import { join, resolve } from 'path';
|
|
11
11
|
import { cmdInit, getVersion } from './init.js';
|
|
12
|
-
import { ProgressManager } from './progress.js';
|
|
12
|
+
import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
|
|
13
13
|
|
|
14
14
|
// ── CLI 入口 ──
|
|
15
15
|
|
|
@@ -188,7 +188,7 @@ async function main() {
|
|
|
188
188
|
await (await import('./setup.js')).cmdSetup(dir, { json, list: setupList });
|
|
189
189
|
break;
|
|
190
190
|
case 'progress': {
|
|
191
|
-
const pm = new ProgressManager();
|
|
191
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
|
|
192
192
|
const progDir = specDir ? dir : resolveEffectiveDir(dir);
|
|
193
193
|
const subCommand = filteredArgs[1];
|
|
194
194
|
const stageIdx = filteredArgs.indexOf('--stage');
|
|
@@ -347,7 +347,7 @@ async function main() {
|
|
|
347
347
|
const wtSubCmd = filteredArgs[1];
|
|
348
348
|
const wtName = filteredArgs.slice(2).find(a => !a.startsWith('-'));
|
|
349
349
|
const wm = new WorktreeManager({ cwd: dir });
|
|
350
|
-
const pm = new ProgressManager({ specDir });
|
|
350
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
|
|
351
351
|
|
|
352
352
|
// isolation 写入 DB 的辅助函数
|
|
353
353
|
async function _writeIsolationToDB(cwd, changeName, info) {
|
|
@@ -749,7 +749,7 @@ SillySpec platform — SillyHub 平台同步
|
|
|
749
749
|
console.error('❌ 用法: sillyspec change-rename <旧变更名> <新变更名>');
|
|
750
750
|
process.exit(1);
|
|
751
751
|
}
|
|
752
|
-
const pm = new ProgressManager({ specDir });
|
|
752
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
|
|
753
753
|
await pm.renameChange(dir, oldName, newName);
|
|
754
754
|
break;
|
|
755
755
|
}
|
package/src/progress.js
CHANGED
|
@@ -34,6 +34,24 @@ export function resolveSpecDir(startDir) {
|
|
|
34
34
|
}
|
|
35
35
|
return join(resolve(startDir), SPEC_DIR_NAME);
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 平台感知的 specDir 解析(所有 CLI 子命令统一入口)。
|
|
40
|
+
* 优先级:显式 --spec-dir > 平台 pointer(.sillyspec-platform.json 的 specRoot) > resolveSpecDir(cwd)
|
|
41
|
+
* 修复 run 读 pointer、其它子命令读 legacy .sillyspec 的割裂。
|
|
42
|
+
*/
|
|
43
|
+
export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
|
|
44
|
+
if (explicitSpecDir) return resolve(explicitSpecDir);
|
|
45
|
+
const pointerPath = join(resolve(cwd), '.sillyspec-platform.json');
|
|
46
|
+
if (existsSync(pointerPath)) {
|
|
47
|
+
try {
|
|
48
|
+
const ptr = JSON.parse(readFileSync(pointerPath, 'utf8'));
|
|
49
|
+
if (ptr.specRoot) return ptr.specRoot;
|
|
50
|
+
} catch { /* 损坏的 pointer 落到 fallback */ }
|
|
51
|
+
}
|
|
52
|
+
return resolveSpecDir(cwd);
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
const CHANGES_SUBDIR = 'changes';
|
|
38
56
|
const GLOBAL_FILE = 'global.json';
|
|
39
57
|
const CURRENT_VERSION = 3;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-recommend.js — quick 阶段多变更关联推荐打分
|
|
3
|
+
*
|
|
4
|
+
* 多变更场景下,quick 启动前用「脏文件 + 任务描述」双信号,
|
|
5
|
+
* 推测当前 quick 改动最可能归属哪些活跃变更,供交互式多选默认勾选。
|
|
6
|
+
*
|
|
7
|
+
* 纯函数 + 只读文件系统,无副作用,便于单测。
|
|
8
|
+
*/
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
|
+
import { parseFileChangeList } from './change-list.js'
|
|
12
|
+
|
|
13
|
+
function escapeRegex(s) {
|
|
14
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 单个 token 是否命中上下文(参考 knowledge-match.js 的 keywordMatchesContext 思路)。
|
|
19
|
+
* - 过短(<2 字符)视为噪音
|
|
20
|
+
* - 非 ASCII(中文等)用子串匹配
|
|
21
|
+
* - ASCII 用词边界匹配,避免 "DB" 误命中 "dashboard"
|
|
22
|
+
*/
|
|
23
|
+
function tokenInContext(token, contextLower) {
|
|
24
|
+
const t = token.toLowerCase().trim()
|
|
25
|
+
if (t.length < 2) return false
|
|
26
|
+
if (/[^\x00-\x7f]/.test(t)) {
|
|
27
|
+
// 中文(等非 ASCII):用 2-gram 重叠匹配。
|
|
28
|
+
// 避免整串子串匹配漏召回(如 token「修解析器」与 context「修复解析器」整串不等,
|
|
29
|
+
// 但共享 2-gram「解析」)。2-gram 足以抑制单字噪音,又能覆盖词形变化。
|
|
30
|
+
for (let i = 0; i <= t.length - 2; i++) {
|
|
31
|
+
if (contextLower.includes(t.slice(i, i + 2))) return true
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`(^|[^a-z0-9])${escapeRegex(t)}([^a-z0-9]|$)`).test(contextLower)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 把任务描述切成用于匹配的 token(按空白/标点切分,过滤空串)。
|
|
40
|
+
* 保留中英文混排,不做分词——推荐只求召回,精确度靠用户最终勾选把关。
|
|
41
|
+
*/
|
|
42
|
+
function tokenizeDescription(desc) {
|
|
43
|
+
if (!desc) return []
|
|
44
|
+
return desc
|
|
45
|
+
.split(/[\s,,。、;;::()()\[\]{}"'`/\\|]+/)
|
|
46
|
+
.map(t => t.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 读取变更 proposal.md 的全文(小写),用于任务描述信号匹配;缺失返回空串。 */
|
|
51
|
+
function readProposalContext(specDir, changeName) {
|
|
52
|
+
const p = join(specDir, 'changes', changeName, 'proposal.md')
|
|
53
|
+
if (!existsSync(p)) return ''
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(p, 'utf8').toLowerCase()
|
|
56
|
+
} catch {
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 判断单个脏文件是否命中 design.md 声明的文件清单。
|
|
63
|
+
* 支持精确匹配 + 目录前缀匹配(design 可能写目录、脏文件是目录下文件,或反之)。
|
|
64
|
+
*/
|
|
65
|
+
function fileHitsList(dirtyFile, fileList) {
|
|
66
|
+
if (fileList.has(dirtyFile)) return true
|
|
67
|
+
for (const declared of fileList) {
|
|
68
|
+
if (declared === dirtyFile) continue
|
|
69
|
+
if (dirtyFile.startsWith(declared + '/') || declared.startsWith(dirtyFile + '/')) return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 推荐关联变更。
|
|
76
|
+
* @param {{ activeChanges: string[], specDir: string, baselineFiles?: string[], quickFiles?: string[], taskDescription?: string }} opts
|
|
77
|
+
* @returns {{ name: string, score: number, reasons: string[] }[]} 按 score 降序、同名升序
|
|
78
|
+
*/
|
|
79
|
+
export function recommendChanges({
|
|
80
|
+
activeChanges,
|
|
81
|
+
specDir,
|
|
82
|
+
baselineFiles = [],
|
|
83
|
+
quickFiles = [],
|
|
84
|
+
taskDescription = '',
|
|
85
|
+
}) {
|
|
86
|
+
if (!activeChanges || activeChanges.length === 0) return []
|
|
87
|
+
|
|
88
|
+
const dirtyFiles = [...new Set([...baselineFiles, ...quickFiles])]
|
|
89
|
+
.filter(f => f && !f.startsWith('.sillyspec/'))
|
|
90
|
+
const descTokens = [...new Set(tokenizeDescription(taskDescription))]
|
|
91
|
+
|
|
92
|
+
const results = activeChanges.map(name => {
|
|
93
|
+
const reasons = []
|
|
94
|
+
|
|
95
|
+
// 信号 1:脏文件命中 design.md 文件变更清单
|
|
96
|
+
const fileList = parseFileChangeList(join(specDir, 'changes', name, 'design.md'))
|
|
97
|
+
if (fileList.size > 0 && dirtyFiles.length > 0) {
|
|
98
|
+
for (const f of dirtyFiles) {
|
|
99
|
+
if (fileHitsList(f, fileList)) reasons.push(`脏文件命中: ${f}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 信号 2:任务描述 token 命中 proposal.md
|
|
104
|
+
const proposalCtx = readProposalContext(specDir, name)
|
|
105
|
+
if (proposalCtx && descTokens.length > 0) {
|
|
106
|
+
for (const t of descTokens) {
|
|
107
|
+
if (tokenInContext(t, proposalCtx)) reasons.push(`任务描述命中: ${t}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { name, score: reasons.length, reasons }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
115
|
+
}
|
package/src/run.js
CHANGED
|
@@ -10,6 +10,7 @@ import { createRequire } from 'module'
|
|
|
10
10
|
const require = createRequire(import.meta.url)
|
|
11
11
|
import { ProgressManager } from './progress.js'
|
|
12
12
|
import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
|
|
13
|
+
import { checkbox } from '@inquirer/prompts'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
|
|
@@ -703,6 +704,20 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
703
704
|
if (changeName && promptText.includes('<change-name>')) {
|
|
704
705
|
promptText = promptText.replace(/<change-name>/g, changeName)
|
|
705
706
|
}
|
|
707
|
+
// 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-guard.json 读关联变更)
|
|
708
|
+
if (promptText.includes('<linked-changes>')) {
|
|
709
|
+
const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
710
|
+
let linkedChanges = []
|
|
711
|
+
try {
|
|
712
|
+
const guardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
|
|
713
|
+
if (existsSync(guardFile)) {
|
|
714
|
+
const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
715
|
+
linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
|
|
716
|
+
}
|
|
717
|
+
} catch {}
|
|
718
|
+
const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
|
|
719
|
+
promptText = promptText.replace(/<linked-changes>/g, display)
|
|
720
|
+
}
|
|
706
721
|
// 平台模式:注入路径覆盖指令
|
|
707
722
|
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
708
723
|
const projectName = dbProjectName || basename(cwd)
|
|
@@ -1276,30 +1291,39 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1276
1291
|
// runCommand 后续所有 .sillyspec/ 操作必须用 specBase
|
|
1277
1292
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1278
1293
|
|
|
1279
|
-
//
|
|
1294
|
+
// 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
1280
1295
|
// ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
|
|
1296
|
+
// 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
|
|
1297
|
+
// 后续每次 run 直接跳过,避免重复检查 + 红叉噪声(此清理不阻塞流程、不动真实资产)。
|
|
1281
1298
|
if (platformOpts.specRoot) {
|
|
1282
1299
|
const legacyDir = join(cwd, '.sillyspec')
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1300
|
+
const cleanedMarker = join(cwd, '.sillyspec-platform-cleaned')
|
|
1301
|
+
if (!existsSync(cleanedMarker)) {
|
|
1302
|
+
if (existsSync(legacyDir)) {
|
|
1303
|
+
let hasChanges = false;
|
|
1304
|
+
try {
|
|
1305
|
+
const cd = join(legacyDir, 'changes');
|
|
1306
|
+
if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
|
|
1307
|
+
} catch {}
|
|
1308
|
+
let hasProjects = false;
|
|
1309
|
+
try {
|
|
1310
|
+
const pd = join(legacyDir, 'projects');
|
|
1311
|
+
if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
|
|
1312
|
+
} catch {}
|
|
1313
|
+
const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
|
|
1314
|
+
|
|
1315
|
+
if (hasChanges || hasProjects || hasDb) {
|
|
1316
|
+
// 真实资产存在:只清运行时残留(白名单保留 worktrees/进度),不整删
|
|
1317
|
+
const { cleanupRuntimeResidue } = await import('./init.js')
|
|
1318
|
+
cleanupRuntimeResidue(legacyDir);
|
|
1319
|
+
console.log('ℹ️ [sillyspec] 源码目录 .sillyspec/ 含真实资产,已跳过整删,仅清理运行时残留(仅本次首次,后续不再提示)。');
|
|
1320
|
+
} else {
|
|
1321
|
+
try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
|
|
1322
|
+
if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
|
|
1323
|
+
}
|
|
1302
1324
|
}
|
|
1325
|
+
// 标记本 cwd 已做平台残留清理决策,后续 run 跳过(即使之后 .sillyspec/ 被重建也不误清)
|
|
1326
|
+
try { writeFileSync(cleanedMarker, new Date().toISOString() + '\n') } catch {}
|
|
1303
1327
|
}
|
|
1304
1328
|
}
|
|
1305
1329
|
|
|
@@ -1317,11 +1341,23 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1317
1341
|
inputText = flags[inputIdx + 1]
|
|
1318
1342
|
}
|
|
1319
1343
|
|
|
1320
|
-
// 解析 --change <name
|
|
1344
|
+
// 解析 --change <name>(quick 阶段支持逗号分隔多值,作为「关联变更」)
|
|
1321
1345
|
let changeName = null
|
|
1346
|
+
let linkedChanges = []
|
|
1322
1347
|
const changeIdx = flags.indexOf('--change')
|
|
1323
1348
|
if (changeIdx !== -1 && flags[changeIdx + 1]) {
|
|
1324
1349
|
changeName = flags[changeIdx + 1]
|
|
1350
|
+
if (stageName === 'quick') {
|
|
1351
|
+
linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
|
|
1352
|
+
changeName = null
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
// quick 的 progress 键固定走 'default':归属语义已由 linkedChanges 承担(→ quick-guard.json
|
|
1356
|
+
// → <linked-changes> 占位符注入 prompt)。progress 不能走 pm.read(cwd, null) 的自动检测——
|
|
1357
|
+
// 多活跃 change 项目里 quick 步骤进度会随活跃 change 增减在各 change/default 间漂移,
|
|
1358
|
+
// 表现为 --done 时「回退」到更早步骤。固定 default 后无论是否带 --change 都稳定。
|
|
1359
|
+
if (stageName === 'quick') {
|
|
1360
|
+
changeName = 'default'
|
|
1325
1361
|
}
|
|
1326
1362
|
|
|
1327
1363
|
// 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
|
|
@@ -1363,6 +1399,16 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1363
1399
|
// scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
|
|
1364
1400
|
|
|
1365
1401
|
const pm = new ProgressManager({ specDir: specRoot })
|
|
1402
|
+
|
|
1403
|
+
// quick 阶段:多变更交互式选择关联变更(--change 未指定时)
|
|
1404
|
+
if (stageName === 'quick' && linkedChanges.length === 0) {
|
|
1405
|
+
linkedChanges = await resolveQuickLinkedChanges({
|
|
1406
|
+
pm, cwd, specDir: specRoot, quickFiles,
|
|
1407
|
+
taskDescription: inputText || '',
|
|
1408
|
+
nonInteractive: isNonInteractive,
|
|
1409
|
+
})
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1366
1412
|
let progress = await pm.read(cwd, changeName)
|
|
1367
1413
|
|
|
1368
1414
|
if (!progress) {
|
|
@@ -1382,6 +1428,12 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1382
1428
|
} else {
|
|
1383
1429
|
// brainstorm 作为流程入口,自动生成变更名并初始化
|
|
1384
1430
|
if (stageName === 'brainstorm') {
|
|
1431
|
+
if (isDone) {
|
|
1432
|
+
console.error('❌ --done 找不到变更进度数据。')
|
|
1433
|
+
console.error(' 请用 --change <变更名> 指定要完成的变更,')
|
|
1434
|
+
console.error(' 或先运行 sillyspec run brainstorm --change <变更名> 初始化。')
|
|
1435
|
+
process.exit(1)
|
|
1436
|
+
}
|
|
1385
1437
|
const date = new Date().toISOString().slice(0, 10)
|
|
1386
1438
|
const autoName = `${date}-new-change`
|
|
1387
1439
|
console.log(`🔄 自动创建变更:${autoName}`)
|
|
@@ -1523,7 +1575,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1523
1575
|
}
|
|
1524
1576
|
|
|
1525
1577
|
// 默认:输出当前步骤
|
|
1526
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
|
|
1578
|
+
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges })
|
|
1527
1579
|
}
|
|
1528
1580
|
|
|
1529
1581
|
/**
|
|
@@ -1538,6 +1590,73 @@ function resolveChangeNameAuto(cwd, specDir = null) {
|
|
|
1538
1590
|
return null
|
|
1539
1591
|
}
|
|
1540
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* quick 阶段多变更交互式选择关联变更。
|
|
1595
|
+
* - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
|
|
1596
|
+
* - 1 活跃变更 → 默认关联它(保持现状友好,不弹交互)
|
|
1597
|
+
* - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
|
|
1598
|
+
* - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
|
|
1599
|
+
*/
|
|
1600
|
+
async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
|
|
1601
|
+
let activeChanges = []
|
|
1602
|
+
try {
|
|
1603
|
+
activeChanges = await pm.listChanges(cwd)
|
|
1604
|
+
} catch {
|
|
1605
|
+
activeChanges = []
|
|
1606
|
+
}
|
|
1607
|
+
if (activeChanges.length === 0) return []
|
|
1608
|
+
if (activeChanges.length === 1) return [activeChanges[0]]
|
|
1609
|
+
|
|
1610
|
+
if (nonInteractive || process.stdin.isTTY === false) {
|
|
1611
|
+
console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
|
|
1612
|
+
return []
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// 脏文件(推荐信号之一)
|
|
1616
|
+
let baselineFiles = []
|
|
1617
|
+
try {
|
|
1618
|
+
const { execSync } = await import('child_process')
|
|
1619
|
+
const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
1620
|
+
baselineFiles = gs.trim().split('\n').filter(Boolean)
|
|
1621
|
+
.map(l => l.slice(3).trim())
|
|
1622
|
+
.filter(f => !f.startsWith('.sillyspec/'))
|
|
1623
|
+
} catch {}
|
|
1624
|
+
|
|
1625
|
+
// 推荐打分(脏文件 + 任务描述双信号)
|
|
1626
|
+
let recommendations = []
|
|
1627
|
+
try {
|
|
1628
|
+
const { recommendChanges } = await import('./quick-recommend.js')
|
|
1629
|
+
recommendations = recommendChanges({ activeChanges, specDir, baselineFiles, quickFiles, taskDescription })
|
|
1630
|
+
} catch {
|
|
1631
|
+
recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
|
|
1632
|
+
}
|
|
1633
|
+
const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
|
|
1634
|
+
const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
|
|
1635
|
+
const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
|
|
1636
|
+
|
|
1637
|
+
// 按推荐分降序展示
|
|
1638
|
+
const ordered = [...activeChanges].sort((a, b) =>
|
|
1639
|
+
(scoreMap.get(b) || 0) - (scoreMap.get(a) || 0) || a.localeCompare(b))
|
|
1640
|
+
|
|
1641
|
+
const choices = ordered.map(name => {
|
|
1642
|
+
const reasons = reasonMap.get(name) || []
|
|
1643
|
+
const isRec = recommendedSet.has(name)
|
|
1644
|
+
return {
|
|
1645
|
+
name: `${isRec ? '⭐ ' : ' '}${name}`,
|
|
1646
|
+
value: name,
|
|
1647
|
+
description: reasons.length > 0 ? reasons.join(';') : '无推荐信号',
|
|
1648
|
+
checked: isRec,
|
|
1649
|
+
}
|
|
1650
|
+
})
|
|
1651
|
+
|
|
1652
|
+
console.log('🔗 检测到多个活跃变更,选择本次 quick 关联哪些(可多选;不勾选任何项 = 仅记 QUICKLOG,不关联变更)')
|
|
1653
|
+
if (recommendedSet.size > 0) {
|
|
1654
|
+
console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
|
|
1655
|
+
}
|
|
1656
|
+
const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
|
|
1657
|
+
return selected
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1541
1660
|
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
1542
1661
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1543
1662
|
// 状态转换校验
|
|
@@ -1730,6 +1849,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1730
1849
|
allowedFiles,
|
|
1731
1850
|
allowNew,
|
|
1732
1851
|
forceBaseline,
|
|
1852
|
+
linkedChanges: Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : [],
|
|
1733
1853
|
startedAt: new Date().toISOString(),
|
|
1734
1854
|
}
|
|
1735
1855
|
// 写入 quick-guard.json 供 worktree-guard hook 读取
|
package/src/stage-contract.js
CHANGED
|
@@ -144,8 +144,14 @@ function readIfExists(file) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function warnMissingIds(warnings, ids, targetContent, targetName, sourceName) {
|
|
147
|
+
// 剥版本后缀(@vN)按基号词边界匹配:target 里写裸号 D-001 即视为引用了 D-001@V1
|
|
148
|
+
// 的当前版本(prompt 常裸号引用,旧版字面 includes("D-001@V1") 会批量误报)。
|
|
149
|
+
// 大小写不敏感:target 整体大写后比对。
|
|
150
|
+
const targetUpper = targetContent.toUpperCase()
|
|
147
151
|
for (const id of ids) {
|
|
148
|
-
|
|
152
|
+
const base = id.replace(/@V\d+$/, '')
|
|
153
|
+
const re = new RegExp(`\\b${base}\\b`)
|
|
154
|
+
if (!re.test(targetUpper)) {
|
|
149
155
|
warnings.push(`${targetName} 未引用 ${sourceName} 中的 ${id}`)
|
|
150
156
|
}
|
|
151
157
|
}
|
|
@@ -281,9 +287,9 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
|
|
|
281
287
|
const design = readIfExists(join(changeDir, 'design.md'))
|
|
282
288
|
const requirements = readIfExists(join(changeDir, 'requirements.md'))
|
|
283
289
|
const tasks = readIfExists(join(changeDir, 'tasks.md'))
|
|
290
|
+
// decision 的天然引用落点是 design.md;requirements(需求按 FR 组织)与
|
|
291
|
+
// tasks(骨架,待 plan 展开)不强求逐条引用每个架构决策,否则批量误报。
|
|
284
292
|
warnMissingIds(warnings, decisionIds, design, 'design.md', 'decisions.md')
|
|
285
|
-
warnMissingIds(warnings, decisionIds, requirements, 'requirements.md', 'decisions.md')
|
|
286
|
-
warnMissingIds(warnings, decisionIds, tasks, 'tasks.md', 'decisions.md')
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
|
package/src/stages/quick.js
CHANGED
|
@@ -9,12 +9,12 @@ export const definition = {
|
|
|
9
9
|
prompt: `解析任务参数,加载项目上下文。
|
|
10
10
|
|
|
11
11
|
### 操作
|
|
12
|
-
1.
|
|
12
|
+
1. 检查关联变更(\`<linked-changes>\`,逗号分隔的变更名列表;显示「(无)」= 不关联变更),确定记录方式
|
|
13
13
|
2. 理解任务:模糊则问一个问题确认
|
|
14
14
|
3. 加载项目信息:\`cat .sillyspec/projects/*.yaml 2>/dev/null\`(了解项目结构和技术栈)
|
|
15
15
|
4. 加载上下文:\`cat .sillyspec/docs/<project>/scan/CONVENTIONS.md 2>/dev/null\`
|
|
16
16
|
5. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`(构建命令、测试命令、环境变量等)
|
|
17
|
-
6.
|
|
17
|
+
6. 若有关联变更,加载每个变更的设计文档:\`cat .sillyspec/changes/<c>/design.md 2>/dev/null\`(理解设计意图)
|
|
18
18
|
7. 如有需要,查询知识库:\`cat .sillyspec/knowledge/INDEX.md 2>/dev/null\`
|
|
19
19
|
|
|
20
20
|
### 模块文档加载
|
|
@@ -24,11 +24,14 @@ export const definition = {
|
|
|
24
24
|
|
|
25
25
|
### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
|
|
26
26
|
理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
|
|
27
|
+
|
|
28
|
+
**A. 始终创建 QUICKLOG(无论是否关联变更)**
|
|
27
29
|
1. 使用预注入的 git 用户名:\`<git-user>\`
|
|
28
|
-
2.
|
|
30
|
+
2. 创建/追加 \`.sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`,写入:
|
|
29
31
|
\`\`\`
|
|
30
32
|
## ql-<YYYYMMDD>-<NNN>-<XXXX> | <now-datetime> | <一句话任务描述>
|
|
31
33
|
状态:进行中
|
|
34
|
+
关联变更:<linked-changes>
|
|
32
35
|
文件:<预估要改的文件>
|
|
33
36
|
\`\`\`
|
|
34
37
|
- ID 格式:\`ql-YYYYMMDD-NNN-XXXX\`
|
|
@@ -36,9 +39,16 @@ export const definition = {
|
|
|
36
39
|
- 追加前扫描文件中已有的 \`ql-<当天日期>-\` 前缀的最大序号,+1 作为新序号
|
|
37
40
|
- 每天从 001 开始,跨日重新计数
|
|
38
41
|
- 此 ID 可被 design.md / plan.md / archive / module 变更索引引用
|
|
39
|
-
3. 有 \`--change\`:在 \`.sillyspec/changes/<change-name>/tasks.md\` 追加未勾选的 task
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
**B. 若关联变更(\`<linked-changes>\` 不为「(无)」)**
|
|
44
|
+
3. 解析 \`<linked-changes>\`(逗号分隔的变更名列表,可能有多个 = 一次 quick 同时归属多个变更)
|
|
45
|
+
4. 对**每个**变更 \`<c>\`:在 \`.sillyspec/changes/<c>/tasks.md\` 追加一条未勾选 task:
|
|
46
|
+
\`- [ ] <ql-ID> <一句话任务描述>\`(复用步骤 A 生成的同一个 ql-ID)
|
|
47
|
+
- 若 \`changes/<c>/\` 目录或 tasks.md 不存在,先 \`mkdir -p .sillyspec/changes/<c>\` 再创建 tasks.md
|
|
48
|
+
- 同一个 ql-ID 出现在 QUICKLOG 和所有关联变更的 tasks.md 中,便于交叉引用
|
|
49
|
+
- \`<linked-changes>\` 为「(无)」时跳过本步,仅保留 QUICKLOG
|
|
50
|
+
|
|
51
|
+
这样 Gate 检测到 \`.sillyspec/\` 下有变更,就不会拦截后续的代码修改。
|
|
42
52
|
|
|
43
53
|
### 输出
|
|
44
54
|
quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
|
|
@@ -72,27 +82,27 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
|
|
|
72
82
|
prompt: `Git 暂存并更新任务记录。
|
|
73
83
|
|
|
74
84
|
### 操作
|
|
75
|
-
1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
|
|
76
|
-
2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
|
|
77
|
-
- 禁止使用 \`git add -A\`
|
|
78
|
-
- 不要暂存 quick 开始前就已存在的无关改动
|
|
79
|
-
3. 更新 Step 1 创建的记录:
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
|
|
83
|
-
5. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
|
|
84
|
-
6. 任务比预期复杂 → 建议用完整流程
|
|
85
|
-
|
|
86
|
-
### 模块文档同步
|
|
87
|
-
7. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
|
|
88
|
-
8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
|
|
89
|
-
9. 如果命中模块 → 直接同步模块文档:
|
|
90
|
-
- 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
|
|
91
|
-
- 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
|
|
92
|
-
- 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
|
|
93
|
-
- 写入模块文档
|
|
94
|
-
- 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
|
|
95
|
-
10. 未命中任何模块 → 跳过,不做额外操作
|
|
85
|
+
1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
|
|
86
|
+
2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
|
|
87
|
+
- 禁止使用 \`git add -A\`
|
|
88
|
+
- 不要暂存 quick 开始前就已存在的无关改动
|
|
89
|
+
3. 更新 Step 1 创建的记录:
|
|
90
|
+
- QUICKLOG:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要(**始终做**,无论是否关联变更)
|
|
91
|
+
- 关联变更:对 Step 1 在每个 \`changes/<c>/tasks.md\` 写入的 ql-ID task,将其 checkbox 由 \`- [ ]\` 勾选为 \`- [x]\`;若 Step 1 未写入(用户事后才决定关联),则按 Step 1 的格式补写并直接勾选
|
|
92
|
+
4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
|
|
93
|
+
5. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
|
|
94
|
+
6. 任务比预期复杂 → 建议用完整流程
|
|
95
|
+
|
|
96
|
+
### 模块文档同步
|
|
97
|
+
7. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
|
|
98
|
+
8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
|
|
99
|
+
9. 如果命中模块 → 直接同步模块文档:
|
|
100
|
+
- 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
|
|
101
|
+
- 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
|
|
102
|
+
- 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
|
|
103
|
+
- 写入模块文档
|
|
104
|
+
- 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
|
|
105
|
+
10. 未命中任何模块 → 跳过,不做额外操作
|
|
96
106
|
|
|
97
107
|
### 输出
|
|
98
108
|
暂存确认 + 记录路径 + 模块文档同步结果(如有)`,
|
package/src/sync.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
|
+
import { resolvePlatformSpecDir } from './progress.js';
|
|
13
14
|
|
|
14
15
|
const LOCAL_YAML = '.sillyspec/local.yaml';
|
|
15
16
|
const CHANGES_DIR = '.sillyspec/changes';
|
|
@@ -200,7 +201,7 @@ export class SyncManager {
|
|
|
200
201
|
let progressData;
|
|
201
202
|
try {
|
|
202
203
|
const { ProgressManager } = await import('./progress.js');
|
|
203
|
-
const pm = new ProgressManager();
|
|
204
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
|
|
204
205
|
progressData = await pm.read(this.cwd, changeName);
|
|
205
206
|
} catch (err) {
|
|
206
207
|
console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
|
|
@@ -225,7 +226,7 @@ export class SyncManager {
|
|
|
225
226
|
// 更新 platform_last_sync
|
|
226
227
|
try {
|
|
227
228
|
const { ProgressManager } = await import('./progress.js');
|
|
228
|
-
const pm = new ProgressManager();
|
|
229
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
|
|
229
230
|
await pm._updatePlatformLastSync(this.cwd, changeName);
|
|
230
231
|
} catch (err) {
|
|
231
232
|
console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
|
|
@@ -326,7 +327,7 @@ export class SyncManager {
|
|
|
326
327
|
// 更新本地 approvals 表
|
|
327
328
|
try {
|
|
328
329
|
const { ProgressManager } = await import('./progress.js');
|
|
329
|
-
const pm = new ProgressManager();
|
|
330
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
|
|
330
331
|
await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
|
|
331
332
|
} catch (err) {
|
|
332
333
|
console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回归:decision 引用校验不应因「版本号有无 / 大小写」误报
|
|
3
|
+
*
|
|
4
|
+
* 现象:prompt 让用 D-xxx@v1,但 design/requirements 里常裸号引用 D-xxx。
|
|
5
|
+
* 旧校验器用 targetContent.includes("D-xxx@V1") 字面匹配 → 批量误报。
|
|
6
|
+
* 另:强制 decision 在 design+requirements+tasks 三处都引用,tasks 骨架不自然。
|
|
7
|
+
*
|
|
8
|
+
* 修 A:warnMissingIds 剥 @vN 后缀,按基号词边界匹配(裸号 D-001 视为引用 D-001@V1)。
|
|
9
|
+
* 修 B:brainstorm 阶段不再强制 requirements.md / tasks.md 引用每个 decision(decision 天然落点在 design)。
|
|
10
|
+
*/
|
|
11
|
+
import { runValidators } from '../src/stage-contract.js'
|
|
12
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
import os from 'os'
|
|
15
|
+
|
|
16
|
+
let failed = 0
|
|
17
|
+
const fail = m => { failed++; console.log(` ❌ FAIL: ${m}`) }
|
|
18
|
+
const pass = m => console.log(` ✅ PASS: ${m}`)
|
|
19
|
+
|
|
20
|
+
function setup() {
|
|
21
|
+
const tmp = join(os.tmpdir(), `ss-ref-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`)
|
|
22
|
+
const cd = join(tmp, '.sillyspec', 'changes', 'tc')
|
|
23
|
+
mkdirSync(cd, { recursive: true })
|
|
24
|
+
return { tmp, cd }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('=== decision 引用校验:版本号 / 裸号 / 三文件放宽 ===\n')
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
const { tmp, cd } = setup()
|
|
31
|
+
// decisions:小写 @v1(测大小写不敏感)+ 一条 supersede 链
|
|
32
|
+
writeFileSync(join(cd, 'decisions.md'), [
|
|
33
|
+
'# Decisions', '',
|
|
34
|
+
'## D-001@v1: 决策一', 'status: accepted', 'priority: P2', '',
|
|
35
|
+
'## D-002@v1: 决策二', 'status: accepted', 'priority: P2', '',
|
|
36
|
+
'## D-003@v1: 旧', 'status: superseded', 'supersedes: ', '',
|
|
37
|
+
'## D-003@v2: 新', 'status: accepted', 'supersedes: D-003@v1', '',
|
|
38
|
+
].join('\n'))
|
|
39
|
+
// design:裸号引用 D-001(小写原文);不引用 D-002(真实缺口);D-003@v2 裸号 D-003
|
|
40
|
+
writeFileSync(join(cd, 'design.md'), [
|
|
41
|
+
'# Design', '',
|
|
42
|
+
'## 文件变更清单', '- src/a.js 覆盖 D-001', '- src/b.py 涉及 D-003', '',
|
|
43
|
+
'## 风险登记', '- 风险', '',
|
|
44
|
+
'## 自审', '- 已审', '',
|
|
45
|
+
].join('\n'))
|
|
46
|
+
writeFileSync(join(cd, 'proposal.md'), '# P\n\n## 不在范围内\n- x\n')
|
|
47
|
+
writeFileSync(join(cd, 'requirements.md'), '# Req\n\n- FR-01: 功能\n')
|
|
48
|
+
// tasks:骨架,不引用任何 decision
|
|
49
|
+
writeFileSync(join(cd, 'tasks.md'), '# Tasks\n\n## W1\n- [ ] (待 plan 展开)\n')
|
|
50
|
+
|
|
51
|
+
const r = runValidators('brainstorm', tmp, 'tc', {})
|
|
52
|
+
const ref = r.warnings.filter(w => w.includes('未引用'))
|
|
53
|
+
console.log(' 产出未引用警告:')
|
|
54
|
+
for (const w of ref) console.log(` - ${w}`)
|
|
55
|
+
|
|
56
|
+
// 修 A:裸号 D-001(小写)应被识别,design 不报 D-001
|
|
57
|
+
if (ref.some(w => w.includes('design.md') && w.includes('D-001'))) {
|
|
58
|
+
fail('design 裸号 D-001(小写)仍被报 —— 修A 未生效(版本号/大小写)')
|
|
59
|
+
} else pass('design 裸号 D-001 被识别(修A:剥版本号 + 大小写不敏感)')
|
|
60
|
+
|
|
61
|
+
// 修 A:D-003 裸号应匹配 active 的 D-003@V2(V1 被 supersede 排除)
|
|
62
|
+
if (ref.some(w => w.includes('design.md') && w.includes('D-003'))) {
|
|
63
|
+
fail('design 裸号 D-003 未匹配到 active D-003@V2')
|
|
64
|
+
} else pass('design 裸号 D-003 匹配 active D-003@V2(supersede 链正确)')
|
|
65
|
+
|
|
66
|
+
// 真实缺口保留:design 确实没引用 D-002
|
|
67
|
+
if (ref.some(w => w.includes('design.md') && w.includes('D-002'))) {
|
|
68
|
+
pass('design 未引用 D-002 正确报警(真实缺口保留)')
|
|
69
|
+
} else fail('design D-002 缺口未报警 —— 校验器被改得过松')
|
|
70
|
+
|
|
71
|
+
// 修 B:requirements / tasks 不再被强制引用每个 decision
|
|
72
|
+
if (ref.some(w => w.includes('requirements.md'))) {
|
|
73
|
+
fail('requirements 仍被强制引用 decision —— 修B 未生效')
|
|
74
|
+
} else pass('requirements 不再强制引用 decision(修B)')
|
|
75
|
+
if (ref.some(w => w.includes('tasks.md'))) {
|
|
76
|
+
fail('tasks 骨架仍被强制引用 decision —— 修B 未生效')
|
|
77
|
+
} else pass('tasks 骨架不再强制引用 decision(修B)')
|
|
78
|
+
|
|
79
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('\n' + '='.repeat(50))
|
|
83
|
+
console.log(failed === 0 ? '✅ 全部通过' : `❌ 失败 ${failed}`)
|
|
84
|
+
console.log('='.repeat(50))
|
|
85
|
+
if (failed > 0) process.exit(1)
|
|
@@ -140,21 +140,32 @@ function assert(label, condition, detail) {
|
|
|
140
140
|
|
|
141
141
|
// ── 测试 6:quick step 1 prompt 强制要求 quicklog ──
|
|
142
142
|
{
|
|
143
|
-
const { definition } = await import('../src/stages/quick.js')
|
|
144
|
-
const step1Prompt = definition.steps[0].prompt
|
|
145
|
-
const step3Prompt = definition.steps[2].prompt
|
|
143
|
+
const { definition } = await import('../src/stages/quick.js')
|
|
144
|
+
const step1Prompt = definition.steps[0].prompt
|
|
145
|
+
const step3Prompt = definition.steps[2].prompt
|
|
146
146
|
|
|
147
147
|
assert('quick step 1 包含 ⛔ 标记', step1Prompt.includes('⛔'))
|
|
148
148
|
assert('quick step 1 包含「不能跳过」', step1Prompt.includes('不能跳过'))
|
|
149
|
-
assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
|
|
150
|
-
assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
|
|
151
|
-
assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
|
|
152
|
-
assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
|
|
149
|
+
assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
|
|
150
|
+
assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
|
|
151
|
+
assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
|
|
152
|
+
assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
|
|
153
|
+
|
|
154
|
+
// 新语义:QUICKLOG 始终记录 + 多变更关联
|
|
155
|
+
assert('quick step 1 含 <linked-changes> 占位符', step1Prompt.includes('<linked-changes>'))
|
|
156
|
+
assert('quick step 1 含「关联变更」措辞', step1Prompt.includes('关联变更'))
|
|
157
|
+
assert('quick step 1 QUICKLOG 始终创建', step1Prompt.includes('始终创建 QUICKLOG'))
|
|
158
|
+
assert('quick step 3 QUICKLOG 始终更新', step3Prompt.includes('始终做'))
|
|
153
159
|
|
|
154
160
|
// run.js 审计包含 quicklog 检查
|
|
155
161
|
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
156
162
|
assert('quick 审计检查 quicklog 目录存在', runSrc.includes('quicklog 目录不存在'))
|
|
157
163
|
assert('quick 审计检查 quicklog 为空', runSrc.includes('quicklog 目录为空'))
|
|
164
|
+
|
|
165
|
+
// run.js 多变更交互入口 + 推荐打分
|
|
166
|
+
assert('run.js 含 resolveQuickLinkedChanges 交互入口', runSrc.includes('resolveQuickLinkedChanges'))
|
|
167
|
+
assert('run.js 含 recommendChanges 推荐打分', runSrc.includes('recommendChanges'))
|
|
168
|
+
assert('run.js 含 <linked-changes> 占位符注入', runSrc.includes('<linked-changes>'))
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
// ── 结果 ──
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-recommend.test.mjs — 推荐打分单测
|
|
3
|
+
* 覆盖:脏文件命中 / 任务描述命中 / 双信号叠加 / 都不命中 / 空活跃变更 / 排序
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
6
|
+
import { tmpdir } from 'os'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { recommendChanges } from '../src/quick-recommend.js'
|
|
9
|
+
|
|
10
|
+
const passed = []
|
|
11
|
+
const failed = []
|
|
12
|
+
function assert(label, cond) {
|
|
13
|
+
;(cond ? passed : failed).push(label)
|
|
14
|
+
if (!cond) console.error(` ❌ ${label}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function setupTmp() {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'qrec-'))
|
|
19
|
+
const specDir = join(root, '.sillyspec')
|
|
20
|
+
return { root, specDir, cleanup: () => rmSync(root, { recursive: true, force: true }) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeChange(specDir, name, { designFiles = [], proposalText = '' } = {}) {
|
|
24
|
+
const dir = join(specDir, 'changes', name)
|
|
25
|
+
mkdirSync(dir, { recursive: true })
|
|
26
|
+
if (designFiles.length) {
|
|
27
|
+
const rows = designFiles.map((f) => `| 修改 | \`${f}\` | x |`).join('\n')
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(dir, 'design.md'),
|
|
30
|
+
`# ${name}\n\n## 文件变更清单\n\n| 操作 | 文件路径 | 说明 |\n|------|----------|------|\n${rows}\n`,
|
|
31
|
+
)
|
|
32
|
+
} else {
|
|
33
|
+
writeFileSync(join(dir, 'design.md'), `# ${name}\n\n无文件清单\n`)
|
|
34
|
+
}
|
|
35
|
+
writeFileSync(join(dir, 'proposal.md'), proposalText || `# ${name} proposal\n`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 测试 1:脏文件命中
|
|
39
|
+
{
|
|
40
|
+
const { specDir, cleanup } = setupTmp()
|
|
41
|
+
try {
|
|
42
|
+
writeChange(specDir, 'change-a', { designFiles: ['src/foo.js', 'src/bar.js'] })
|
|
43
|
+
writeChange(specDir, 'change-b', { designFiles: ['docs/x.md'] })
|
|
44
|
+
const r = recommendChanges({
|
|
45
|
+
activeChanges: ['change-a', 'change-b'],
|
|
46
|
+
specDir,
|
|
47
|
+
baselineFiles: ['src/foo.js'],
|
|
48
|
+
taskDescription: '',
|
|
49
|
+
})
|
|
50
|
+
const a = r.find((x) => x.name === 'change-a')
|
|
51
|
+
const b = r.find((x) => x.name === 'change-b')
|
|
52
|
+
assert('脏文件命中:change-a score>0', a.score > 0)
|
|
53
|
+
assert('脏文件命中:change-a reasons 含「脏文件命中」', a.reasons.some((rr) => rr.startsWith('脏文件命中')))
|
|
54
|
+
assert('脏文件未命中:change-b score=0', b.score === 0)
|
|
55
|
+
assert('排序:高分在前', r[0].name === 'change-a')
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 测试 2:任务描述命中
|
|
62
|
+
{
|
|
63
|
+
const { specDir, cleanup } = setupTmp()
|
|
64
|
+
try {
|
|
65
|
+
writeChange(specDir, 'parser-fix', { proposalText: '# 解析器重构\n本变更修复解析器逻辑\n' })
|
|
66
|
+
writeChange(specDir, 'unrelated', { proposalText: '# 文档更新\n' })
|
|
67
|
+
const r = recommendChanges({
|
|
68
|
+
activeChanges: ['parser-fix', 'unrelated'],
|
|
69
|
+
specDir,
|
|
70
|
+
taskDescription: '修复解析器 bug',
|
|
71
|
+
})
|
|
72
|
+
const p = r.find((x) => x.name === 'parser-fix')
|
|
73
|
+
const u = r.find((x) => x.name === 'unrelated')
|
|
74
|
+
assert('描述命中:parser-fix score>0', p.score > 0)
|
|
75
|
+
assert('描述命中:reasons 含「任务描述命中」', p.reasons.some((rr) => rr.startsWith('任务描述命中')))
|
|
76
|
+
assert('描述未命中:unrelated score=0', u.score === 0)
|
|
77
|
+
} finally {
|
|
78
|
+
cleanup()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 测试 3:双信号叠加
|
|
83
|
+
{
|
|
84
|
+
const { specDir, cleanup } = setupTmp()
|
|
85
|
+
try {
|
|
86
|
+
writeChange(specDir, 'both', { designFiles: ['src/foo.js'], proposalText: '# 解析器\n修复解析器\n' })
|
|
87
|
+
const r = recommendChanges({
|
|
88
|
+
activeChanges: ['both'],
|
|
89
|
+
specDir,
|
|
90
|
+
baselineFiles: ['src/foo.js'],
|
|
91
|
+
taskDescription: '修解析器',
|
|
92
|
+
})
|
|
93
|
+
assert('双信号叠加 score=2', r[0].score === 2)
|
|
94
|
+
assert('双信号 reasons 数=2', r[0].reasons.length === 2)
|
|
95
|
+
} finally {
|
|
96
|
+
cleanup()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 测试 4:都不命中
|
|
101
|
+
{
|
|
102
|
+
const { specDir, cleanup } = setupTmp()
|
|
103
|
+
try {
|
|
104
|
+
writeChange(specDir, 'c', { designFiles: ['src/x.js'], proposalText: '# 其他\n' })
|
|
105
|
+
const r = recommendChanges({
|
|
106
|
+
activeChanges: ['c'],
|
|
107
|
+
specDir,
|
|
108
|
+
baselineFiles: ['src/unrelated.js'],
|
|
109
|
+
taskDescription: '别的任务',
|
|
110
|
+
})
|
|
111
|
+
assert('都不命中 score=0', r[0].score === 0)
|
|
112
|
+
assert('都不命中 reasons 为空', r[0].reasons.length === 0)
|
|
113
|
+
} finally {
|
|
114
|
+
cleanup()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 测试 5:空活跃变更
|
|
119
|
+
{
|
|
120
|
+
const r = recommendChanges({ activeChanges: [], specDir: '/tmp/x', baselineFiles: ['a.js'] })
|
|
121
|
+
assert('空活跃变更返回 []', Array.isArray(r) && r.length === 0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 测试 6:无 design/proposal 文件不报错(robustness)
|
|
125
|
+
{
|
|
126
|
+
const { specDir, cleanup } = setupTmp()
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(join(specDir, 'changes', 'empty'), { recursive: true }) // 无任何文件
|
|
129
|
+
const r = recommendChanges({ activeChanges: ['empty'], specDir, baselineFiles: ['a.js'], taskDescription: 'x' })
|
|
130
|
+
assert('缺文件不报错 score=0', r[0].score === 0)
|
|
131
|
+
} finally {
|
|
132
|
+
cleanup()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
137
|
+
console.log(`✅ 通过: ${passed.length}`)
|
|
138
|
+
console.log(`❌ 失败: ${failed.length}`)
|
|
139
|
+
console.log(`${'='.repeat(50)}`)
|
|
140
|
+
|
|
141
|
+
if (failed.length > 0) {
|
|
142
|
+
console.log('\n失败详情:')
|
|
143
|
+
for (const f of failed) console.log(` ❌ ${f}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
process.exit(failed.length > 0 ? 1 : 0)
|
|
@@ -167,10 +167,12 @@ writeFileSync(join(traceDir, 'requirements.md'), '# Requirements\n\n### FR-01: A
|
|
|
167
167
|
writeFileSync(join(traceDir, 'tasks.md'), '- [ ] task-01: implement naming (D-001@v1)\n')
|
|
168
168
|
|
|
169
169
|
const brainstormTrace = runValidators('brainstorm', traceRoot, 'trace')
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
// 修 B:requirements.md / tasks.md 不再强制引用每个 decision(decision 天然落点 design.md)。
|
|
171
|
+
// 此处 requirements.md 没有 D-001,但 design.md 有,故不应报 requirements 未引用。
|
|
172
|
+
if (brainstormTrace.ok === true && !brainstormTrace.warnings.some(w => w.includes('requirements.md 未引用'))) {
|
|
173
|
+
console.log('✅ brainstorm validator 不再强制 requirements.md 引用 decision(修B:落点 design)')
|
|
172
174
|
} else {
|
|
173
|
-
console.log('❌ brainstorm validator
|
|
175
|
+
console.log('❌ brainstorm validator 仍强制 requirements.md 引用 decision', brainstormTrace.warnings)
|
|
174
176
|
failed++
|
|
175
177
|
}
|
|
176
178
|
|