sillyspec 3.25.5 → 3.25.7
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/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-scan/SKILL.md +12 -7
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -2
- package/package.json +1 -1
- package/src/change-list.js +100 -12
- package/src/constants.js +11 -0
- package/src/index.js +1 -1
- package/src/init.js +69 -0
- package/src/quicklog.js +41 -2
- package/src/run/command.js +57 -2
- package/src/run/complete-handlers.js +5 -1
- package/src/run/complete.js +103 -3
- package/src/run/gates.js +37 -0
- package/src/run/quick-audit.js +8 -1
- package/src/run/scan-profile.js +29 -10
- package/src/run/shared.js +175 -5
- package/src/run/stage.js +12 -2
- package/src/scan-postcheck.js +25 -11
- package/src/stage-contract.js +6 -1
- package/src/stage-review.js +11 -5
- package/src/stages/brainstorm.js +9 -14
- package/src/stages/plan-postcheck.js +1 -1
- package/src/stages/plan.js +6 -0
- package/src/stages/quick.js +31 -31
- package/src/stages/scan.js +2 -1
- package/src/stages/verify.js +6 -3
- package/src/task-review.js +159 -0
- package/src/verify-postcheck.js +148 -3
- package/src/worktree-apply.js +34 -29
- package/templates/claude-instruction.md +23 -0
- package/templates/prompts/verify-probes.md +13 -2
- package/CLAUDE.md +0 -40
|
@@ -48,7 +48,7 @@ sillyspec run brainstorm --done --answer "..." --output "..." # 一步完成
|
|
|
48
48
|
|
|
49
49
|
某些步骤(如"对话式探索与需求澄清")需要用户输入。两种方式:
|
|
50
50
|
|
|
51
|
-
> 自动去重:若前置 step 已对同一问题(waitReason
|
|
51
|
+
> 自动去重:若前置 step 已对同一问题(waitReason 归一化后相同)确认过,后续重复 wait 会自动跳过,无需再 `--wait`。
|
|
52
52
|
|
|
53
53
|
- **方式一(推荐)**:AI 自行与用户交互后,一步完成:
|
|
54
54
|
```bash
|
|
@@ -38,22 +38,27 @@ sillyspec run scan --reset # 重置阶段(从头开始)
|
|
|
38
38
|
|
|
39
39
|
| 参数 | 说明 |
|
|
40
40
|
|---|---|
|
|
41
|
-
| `--
|
|
41
|
+
| `--quick` | 强制 quick profile(快速接入,仅 4 份核心文档,0 子代理) |
|
|
42
|
+
| `--standard` | 强制 standard profile(压缩步骤,最多 1 子代理) |
|
|
43
|
+
| `--deep` | 强制 deep profile(完整流程,不按规模裁剪) |
|
|
42
44
|
| `--force-rescan` | 覆盖已有 scan 文档的保护(默认覆盖需 source_commit/updated_at 匹配) |
|
|
43
45
|
|
|
44
|
-
### scanProfile
|
|
46
|
+
### scanProfile(显式选择优先,否则按规模自动裁剪)
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
可用 `--quick` / `--standard` / `--deep` **显式指定 profile**(三档互斥,优先于自动判定)。
|
|
49
|
+
不带 flag 时 CLI 按源码规模自动选择:
|
|
47
50
|
|
|
48
51
|
| profile | 触发条件 | 行为 |
|
|
49
52
|
|---|---|---|
|
|
50
|
-
| quick | ≤30 文件 且 ≤80KB 且 ≤3 项目 | 3 步,0 子代理,
|
|
51
|
-
| standard | ≤200 文件 且 ≤800KB | 压缩步骤,最多 1 子代理 |
|
|
52
|
-
| deep |
|
|
53
|
+
| quick | `--quick` 或 ≤30 文件 且 ≤80KB 且 ≤3 项目 | 3 步,0 子代理,4 份核心文档 |
|
|
54
|
+
| standard | `--standard` 或 ≤200 文件 且 ≤800KB | 压缩步骤,最多 1 子代理 |
|
|
55
|
+
| deep | `--deep` 或 大项目 | 完整流程 |
|
|
56
|
+
|
|
57
|
+
> **平台快速接入**:对大项目用 `sillyspec run scan --quick`(含平台参数 `--spec-root`/`--workspace-id`/`--scan-run-id`)只生成 4 份核心文档完成接入,后续 `sillyspec run scan --deep` 覆盖升级为完整 7 份。quick 文档 frontmatter 标 `scan_depth: quick`,深度扫描识别后允许覆盖。
|
|
53
58
|
|
|
54
59
|
### post-check
|
|
55
60
|
|
|
56
|
-
scan 完成时 CLI
|
|
61
|
+
scan 完成时 CLI **按 profile 校验文档齐全**:quick 模式只要求 4 份核心文档(PROJECT/ARCHITECTURE/CONVENTIONS/STRUCTURE),standard/deep 要求完整 7 份。缺失会设状态为 `failed_post_check`,阻断进入主流程下游(brainstorm/plan 等),需修复后重跑 scan。quick 模式带一条 informational warning(`quick_profile_notice`),状态落 `completed_with_warnings`,不阻断完成。
|
|
57
62
|
|
|
58
63
|
## 阶段流转
|
|
59
64
|
|
|
@@ -43,7 +43,7 @@ verify 是只读阶段(**禁止改代码/改 git 状态**,只检查 + 写报
|
|
|
43
43
|
|
|
44
44
|
- **必须产出 `verify-result.md`**——不存在则阻断完成(不能跳过报告直接 `--done`)
|
|
45
45
|
- **结论为 `FAIL` 则阻断完成**——不能带着 FAIL 标记 verify 完成
|
|
46
|
-
- **`integration-critical` / `deployment-critical` 变更**(design/plan 含 daemon/session/lease/lifecycle 等关键词):结论 PASS WITH NOTES 降级为 FAIL,必须有真实集成证据(Runtime Evidence section
|
|
46
|
+
- **`integration-critical` / `deployment-critical` 变更**(design/plan 含 daemon/session/lease/lifecycle 等关键词):结论 PASS WITH NOTES 降级为 FAIL,必须有真实集成证据(Runtime Evidence section)——该证据为 Agent 自报告,CLI 仅校验其字面存在、不独立运行时核验,须真实执行过
|
|
47
47
|
- `verify-required-evidence.json`(execute 写入)中每条 missing evidence → 阻断
|
|
48
48
|
|
|
49
49
|
被阻断时 CLI 打印 ❌ 校验失败,不会提示"验证通过"。修复 `verify-result.md` 后重新 `--done`。
|
|
@@ -59,7 +59,7 @@ PASS / PASS WITH NOTES / FAIL ← 必须有结论章节(标题含"结论/
|
|
|
59
59
|
## 探针结果
|
|
60
60
|
## 测试结果
|
|
61
61
|
## 变更风险等级
|
|
62
|
-
## Runtime Evidence(integration/deployment-critical
|
|
62
|
+
## Runtime Evidence(integration/deployment-critical 必填;自报告,CLI 仅字面校验、不独立核验)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
## 阶段流转
|
package/package.json
CHANGED
package/src/change-list.js
CHANGED
|
@@ -81,6 +81,43 @@ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变
|
|
|
81
81
|
*/
|
|
82
82
|
const EXCLUDE_SUBSECTION_RE = /不修改|不变|保留|无变更|未变更|不改动|暂不|暂缓|暂定|待定|后续|未改|无需|不涉及/
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* operation 词→归一化标签。design 清单「操作」列 cell 与分类列表子标题共用。
|
|
86
|
+
* 用途:verify 删除探针对账——声明「新增/修改」却整文件删除 = 高风险信号。
|
|
87
|
+
*/
|
|
88
|
+
const OP_KEYWORDS = [
|
|
89
|
+
{ re: /^(新增|新建|创建|添加|add|new|create)$/i, op: '新增' },
|
|
90
|
+
{ re: /^(修改|更新|调整|改动|变更|重写|modify|update|change)$/i, op: '修改' },
|
|
91
|
+
{ re: /^(删除|移除|去掉|remove|delete|drop)$/i, op: '删除' },
|
|
92
|
+
{ re: /^(重命名|改名|rename|move)$/i, op: '重命名' },
|
|
93
|
+
]
|
|
94
|
+
function classifyOperation(raw) {
|
|
95
|
+
if (!raw) return null
|
|
96
|
+
const hit = OP_KEYWORDS.find(k => k.re.test(raw.trim()))
|
|
97
|
+
return hit ? hit.op : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 分类列表子标题 → operation(`### 删除文件` → '删除')。不命中返回 null。
|
|
102
|
+
* ⚠️ 调用方必须先判 EXCLUDE_SUBSECTION_RE:「### 不修改文件」含「修改」二字,
|
|
103
|
+
* 会被此处误匹配为「修改」——exclude 永远优先,命中 exclude 不跑 OP 映射。
|
|
104
|
+
*/
|
|
105
|
+
const OP_SUBSECTION_RE = [
|
|
106
|
+
{ re: /新增|新建|创建/, op: '新增' },
|
|
107
|
+
{ re: /修改|更新|改动|重写/, op: '修改' },
|
|
108
|
+
{ re: /删除|移除|去掉/, op: '删除' },
|
|
109
|
+
{ re: /重命名|改名/, op: '重命名' },
|
|
110
|
+
]
|
|
111
|
+
function classifySubsectionOp(title) {
|
|
112
|
+
const hit = OP_SUBSECTION_RE.find(k => k.re.test(title))
|
|
113
|
+
return hit ? hit.op : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 表头列定位:cell 整体匹配操作类列名(与 isPathHeaderCell 对偶,用于定位 operation 列) */
|
|
117
|
+
function isOperationHeaderCell(c) {
|
|
118
|
+
return /^(操作|类型|变更类型|改动类型|变更|operation|type|action|op)$/i.test(c)
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
function isPlaceholder(p) {
|
|
85
122
|
return !p || p === '—' || p === '-' || /^(n\/?a|无|none|-+)$/i.test(p)
|
|
86
123
|
}
|
|
@@ -103,23 +140,33 @@ function looksLikePath(p) {
|
|
|
103
140
|
}
|
|
104
141
|
|
|
105
142
|
/**
|
|
106
|
-
*
|
|
143
|
+
* 「顺带修复」标记:design §6 清单里标注的、不属本次变更 task 边界但合规修的预存债文件
|
|
144
|
+
* (CLAUDE.md 规则20 鼓励)。assess 的 allowed_paths 校验对 incidental 文件豁免(坑
|
|
145
|
+
* worktree-execute-apply-friction 坑1/4)。标记载体:表格「说明」列写「顺带修复:xxx」,
|
|
146
|
+
* 或路径 cell / 列表项的括号注释「(顺带修复)」。
|
|
147
|
+
*/
|
|
148
|
+
const INCIDENTAL_RE = /顺带修复|附带修复|顺带|drive-?by|incidental/i
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 从 design.md 解析文件变更清单(含 incidental 标记)。兼容两种真实写法:
|
|
107
152
|
* ① 表格:`| 操作 | 文件路径 | 说明 |`(brainstorm 模板默认)
|
|
108
153
|
* ② 分类列表:`### 新增文件` / `### 修改文件` / `### 不修改文件` 下的 `- path`
|
|
109
154
|
* 表头列顺序自适应(定位「文件/路径/file/path」列,列顺序写反时不会把操作名当路径);
|
|
110
155
|
* 忽略 `.sillyspec/` 与占位符(`—`/`-`/`N/A`/`无`);「不修改/暂缓」子段下的路径会被排除;
|
|
111
|
-
* CRLF 容错。
|
|
156
|
+
* CRLF 容错。incidental 嗅探:表格非路径列(说明列)+ 路径 cell 原始值(剥注释前的括号内容)+ 列表项原文。
|
|
157
|
+
*
|
|
158
|
+
* 内核函数:parseFileChangeList(Set 包装,向后兼容)与 parseFileChangeListDetailed 共用,
|
|
159
|
+
* 单一真相源,避免两处各自重写清单解析漂移。
|
|
112
160
|
* @param {string} designMdPath - design.md 文件路径
|
|
113
|
-
* @returns {
|
|
161
|
+
* @returns {Array<{ path: string, operation: string|null, incidental: boolean }>}(顺序按首次出现,exclude 子段移除)
|
|
114
162
|
*/
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (!designMdPath || !existsSync(designMdPath)) return result
|
|
163
|
+
function _parseFileListDetailed(designMdPath) {
|
|
164
|
+
if (!designMdPath || !existsSync(designMdPath)) return []
|
|
118
165
|
|
|
119
166
|
const content = readFileSync(designMdPath, 'utf8').replace(/\r\n/g, '\n')
|
|
120
167
|
|
|
121
168
|
const sectionMatch = content.match(FILE_LIST_SECTION_RE)
|
|
122
|
-
if (!sectionMatch) return
|
|
169
|
+
if (!sectionMatch) return []
|
|
123
170
|
|
|
124
171
|
// 从标题后开始,截取到下一个 ## 标题或文件末尾
|
|
125
172
|
const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length)
|
|
@@ -131,13 +178,24 @@ export function parseFileChangeList(designMdPath) {
|
|
|
131
178
|
const lines = relevantContent.split('\n')
|
|
132
179
|
let headerSkipped = false
|
|
133
180
|
let pathColIdx = 1 // 默认第 2 列;解析表头后可重定位
|
|
181
|
+
let opColIdx = -1 // 操作列下标(表头扫描后定位;-1 = 无操作列 → operation=null)
|
|
134
182
|
let listMode = 'include' // include | exclude(分类列表子段)
|
|
183
|
+
let currentOp = null // 分类列表子标题推导的 operation(表格模式不用)
|
|
184
|
+
const entries = new Map() // path -> { path, operation, incidental }(exclude 子段 delete)
|
|
135
185
|
|
|
136
186
|
for (const line of lines) {
|
|
137
187
|
// 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件
|
|
138
188
|
const subHeader = line.match(/^###\s+(.+?)\s*$/)
|
|
139
189
|
if (subHeader) {
|
|
140
|
-
|
|
190
|
+
// ⚠️ EXCLUDE 优先:「### 不修改文件」含「修改」二字,会被 classifySubsectionOp 误匹配。
|
|
191
|
+
// 先判 exclude 词集,命中则 exclude 且 currentOp=null,绝不跑 OP 映射。
|
|
192
|
+
if (EXCLUDE_SUBSECTION_RE.test(subHeader[1])) {
|
|
193
|
+
listMode = 'exclude'
|
|
194
|
+
currentOp = null
|
|
195
|
+
} else {
|
|
196
|
+
listMode = 'include'
|
|
197
|
+
currentOp = classifySubsectionOp(subHeader[1])
|
|
198
|
+
}
|
|
141
199
|
continue
|
|
142
200
|
}
|
|
143
201
|
|
|
@@ -152,6 +210,9 @@ export function parseFileChangeList(designMdPath) {
|
|
|
152
210
|
// 表头列定位:找「文件/路径/file/path」列,找不到保持默认第 2 列
|
|
153
211
|
const idx = cells.findIndex(isPathHeaderCell)
|
|
154
212
|
if (idx >= 0) pathColIdx = idx
|
|
213
|
+
// 操作列定位:非路径列里找操作类列名(「操作/类型/operation」),找不到 opColIdx 保持 -1
|
|
214
|
+
const opIdx = cells.findIndex((c, i) => i !== pathColIdx && isOperationHeaderCell(c))
|
|
215
|
+
if (opIdx >= 0) opColIdx = opIdx
|
|
155
216
|
continue
|
|
156
217
|
}
|
|
157
218
|
|
|
@@ -160,7 +221,13 @@ export function parseFileChangeList(designMdPath) {
|
|
|
160
221
|
if (/^(新增|修改|删除|重命名|new|modify|update|delete|create|rename)$/i.test(filePath)) continue
|
|
161
222
|
if (isPlaceholder(filePath) || filePath.startsWith('.sillyspec/')) continue
|
|
162
223
|
if (!looksLikePath(filePath)) continue // 脏描述兜底:丢弃非路径自由文本(防虚高 fileCount)
|
|
163
|
-
|
|
224
|
+
// operation:操作列 cell 分类(无操作列 → null)
|
|
225
|
+
const operation = opColIdx >= 0 ? classifyOperation(cells[opColIdx] || '') : null
|
|
226
|
+
// incidental:说明列(非路径列的所有 cell)+ 路径 cell 原始值(剥注释前的括号内容)
|
|
227
|
+
const incidental = cells.some((c, i) => i !== pathColIdx && INCIDENTAL_RE.test(c))
|
|
228
|
+
|| INCIDENTAL_RE.test(cells[pathColIdx] || '')
|
|
229
|
+
if (listMode === 'exclude') { entries.delete(filePath); continue }
|
|
230
|
+
entries.set(filePath, { path: filePath, operation, incidental })
|
|
164
231
|
continue
|
|
165
232
|
}
|
|
166
233
|
|
|
@@ -170,10 +237,31 @@ export function parseFileChangeList(designMdPath) {
|
|
|
170
237
|
const filePath = normalizePath(listItem[1])
|
|
171
238
|
if (isPlaceholder(filePath) || filePath.startsWith('.sillyspec/')) continue
|
|
172
239
|
if (!looksLikePath(filePath)) continue // 脏描述兜底
|
|
173
|
-
|
|
174
|
-
|
|
240
|
+
const incidental = INCIDENTAL_RE.test(listItem[1])
|
|
241
|
+
if (listMode === 'exclude') { entries.delete(filePath); continue }
|
|
242
|
+
entries.set(filePath, { path: filePath, operation: currentOp, incidental })
|
|
175
243
|
}
|
|
176
244
|
}
|
|
177
245
|
|
|
178
|
-
return
|
|
246
|
+
return [...entries.values()]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 从 design.md 解析文件变更清单(路径集合,向后兼容)。
|
|
251
|
+
* @param {string} designMdPath - design.md 文件路径
|
|
252
|
+
* @returns {Set<string>} 文件路径集合(相对路径,如 "src/worktree.js")
|
|
253
|
+
*/
|
|
254
|
+
export function parseFileChangeList(designMdPath) {
|
|
255
|
+
return new Set(_parseFileListDetailed(designMdPath).map(e => e.path))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 从 design.md 解析文件变更清单(含 operation + incidental 标记)。
|
|
260
|
+
* operation 供 verify 删除探针对账(声明「新增/修改」却整文件删除 = 高风险);
|
|
261
|
+
* incidental 供 assess allowed_paths 豁免。
|
|
262
|
+
* @param {string} designMdPath - design.md 文件路径
|
|
263
|
+
* @returns {Array<{ path: string, operation: string|null, incidental: boolean }>}
|
|
264
|
+
*/
|
|
265
|
+
export function parseFileChangeListDetailed(designMdPath) {
|
|
266
|
+
return _parseFileListDetailed(designMdPath)
|
|
179
267
|
}
|
package/src/constants.js
CHANGED
|
@@ -81,6 +81,17 @@ export const SCAN_REQUIRED_DOCS = Object.freeze([
|
|
|
81
81
|
'PROJECT.md',
|
|
82
82
|
])
|
|
83
83
|
|
|
84
|
+
// ── scan quick profile 必需文档子集(4 份核心文档)──
|
|
85
|
+
// quick 档(--quick 显式 或 小项目自动判定)只生成这 4 份;scan-postcheck 按 profile 取清单:
|
|
86
|
+
// mode==='quick' → SCAN_REQUIRED_DOCS_QUICK,否则 → SCAN_REQUIRED_DOCS(7 份)。
|
|
87
|
+
// 由 scan-postcheck.js + complete-handlers.js 透传的 scanProfile.mode 决定取哪份。
|
|
88
|
+
export const SCAN_REQUIRED_DOCS_QUICK = Object.freeze([
|
|
89
|
+
'PROJECT.md',
|
|
90
|
+
'ARCHITECTURE.md',
|
|
91
|
+
'CONVENTIONS.md',
|
|
92
|
+
'STRUCTURE.md',
|
|
93
|
+
])
|
|
94
|
+
|
|
84
95
|
// ── 辅助 stage 清单(stage-contract 转移规则 与 stages/index 注册表 共用单一真相源)──
|
|
85
96
|
// 曾在 stage-contract.js 与 stages/index.js 逐字重复定义,新增/调整辅助 stage 需同步改两处
|
|
86
97
|
// 易分叉。现收敛一处。(注意:主流程序 STAGE_ORDER/mainFlowStages/flowStages 语义不同、
|
package/src/index.js
CHANGED
|
@@ -54,7 +54,7 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
54
54
|
--allow-new 允许新增文件(默认禁止)
|
|
55
55
|
--force-baseline 允许覆盖 baseline 受保护文件
|
|
56
56
|
--confirm 完成时确认接受变更审计
|
|
57
|
-
scan: --
|
|
57
|
+
scan: --quick | --standard | --deep 显式选择 profile(优先于规模自动判定;三档互斥)
|
|
58
58
|
--force-rescan 覆盖已有 scan 文档保护
|
|
59
59
|
archive: --confirm 归档确认(必须)
|
|
60
60
|
auto: --mode <模式> 显式指定流程模式
|
package/src/init.js
CHANGED
|
@@ -123,6 +123,70 @@ function injectInstructions(tool, projectDir) {
|
|
|
123
123
|
writeFileSync(filePath, content.trimEnd() + '\n\n' + INJECTION_CONTENT);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// ── 注入 CLAUDE.md(claude,版本感知幂等)──
|
|
127
|
+
// 与 codex/gemini/opencode 不同:claude 需要完整 CLAUDE.md 模板 + 版本感知三态。
|
|
128
|
+
// 故 claude 不进 INSTRUCTION_TOOLS,单独走此函数(design: 2026-08-02-init-claude-md)。
|
|
129
|
+
const CLAUDE_TEMPLATE_PATH = join(__dirname, '..', 'templates', 'claude-instruction.md');
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 为 Claude Code 注入 CLAUDE.md(版本感知幂等)。
|
|
133
|
+
* - 不存在:写完整模板(templates/claude-instruction.md)+ 顶部版本注释
|
|
134
|
+
* - 存在无标记:追加受管段(INJECTION_CONTENT)+ 版本块标记
|
|
135
|
+
* - 存在同版本标记:跳过
|
|
136
|
+
* - 存在异版本标记:追加态刷新块 / 完整态仅 stderr 提示
|
|
137
|
+
* @param {string} projectDir - 源码项目根
|
|
138
|
+
*/
|
|
139
|
+
export function injectClaudeInstructions(projectDir) {
|
|
140
|
+
const filePath = join(projectDir, 'CLAUDE.md');
|
|
141
|
+
const version = getVersion();
|
|
142
|
+
// 完整态顶部注释(新文件首行,轻量、不限制后续编辑)
|
|
143
|
+
const fullHeader = `<!-- SillySpec v${version} — 由 sillyspec init 生成,可自由编辑;重跑 init 同版本不更新 -->`;
|
|
144
|
+
// 追加态受管段(包版本块标记,明确"勿手动编辑此段")
|
|
145
|
+
const appendBlock =
|
|
146
|
+
`<!-- SillySpec v${version} START — 由 sillyspec init 注入,勿手动编辑此段 -->\n` +
|
|
147
|
+
INJECTION_CONTENT.trimEnd() +
|
|
148
|
+
`\n<!-- SillySpec END -->`;
|
|
149
|
+
|
|
150
|
+
// 状态 1:文件不存在 → 写完整模板 + 顶部版本注释
|
|
151
|
+
if (!existsSync(filePath)) {
|
|
152
|
+
let template = '';
|
|
153
|
+
try {
|
|
154
|
+
template = readFileSync(CLAUDE_TEMPLATE_PATH, 'utf8');
|
|
155
|
+
} catch {
|
|
156
|
+
console.error(`❌ [sillyspec] 未找到 Claude 模板:${CLAUDE_TEMPLATE_PATH}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
writeFileSync(filePath, fullHeader + '\n' + template);
|
|
160
|
+
console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已生成(SillySpec v${version})`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const content = readFileSync(filePath, 'utf8');
|
|
165
|
+
|
|
166
|
+
// 无任何 `<!-- SillySpec v` 标记 → 状态 2:追加受管段(原文字节保留)
|
|
167
|
+
const markMatch = content.match(/<!-- SillySpec v(\S+)/);
|
|
168
|
+
if (!markMatch) {
|
|
169
|
+
writeFileSync(filePath, content.trimEnd() + '\n\n' + appendBlock + '\n');
|
|
170
|
+
console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已追加 SillySpec 受管段(v${version})`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const existingVersion = markMatch[1];
|
|
175
|
+
// 状态 3:同版本 → 跳过(幂等,不写)
|
|
176
|
+
if (existingVersion === version) return;
|
|
177
|
+
|
|
178
|
+
// 状态 4:异版本(升级)。区分追加态(有 START...END 块)/ 完整态(仅顶部注释)。
|
|
179
|
+
const startBlockRe = /<!-- SillySpec v\S+\s+START[\s\S]*?<!-- SillySpec END -->/;
|
|
180
|
+
if (startBlockRe.test(content)) {
|
|
181
|
+
// 4a:追加态 → 用当前版本受管段替换该块(块外用户内容字节保留)
|
|
182
|
+
writeFileSync(filePath, content.replace(startBlockRe, appendBlock));
|
|
183
|
+
console.log(chalk.green(` ✓ Claude Code CLAUDE.md 受管段已升级(v${existingVersion} → v${version})`));
|
|
184
|
+
} else {
|
|
185
|
+
// 4b:完整态 → 不覆盖(保留用户改动),仅 stderr 打印升级提示
|
|
186
|
+
console.error(`⚠️ [sillyspec] SillySpec 升级 v${existingVersion}→v${version},CLAUDE.md 未自动更新(保留你的改动)。如需采用新模板:备份后删除 CLAUDE.md 再跑 sillyspec init。`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
126
190
|
// ── 检测工具 ──
|
|
127
191
|
|
|
128
192
|
function detectTools(projectDir) {
|
|
@@ -273,6 +337,11 @@ async function doInstall(projectDir, tools, subprojects = [], specDir = null) {
|
|
|
273
337
|
}
|
|
274
338
|
}
|
|
275
339
|
|
|
340
|
+
// 注入 CLAUDE.md(claude,版本感知幂等三态四分支;claude 不进 INSTRUCTION_TOOLS)
|
|
341
|
+
if (tools.includes('claude')) {
|
|
342
|
+
injectClaudeInstructions(projectDir);
|
|
343
|
+
}
|
|
344
|
+
|
|
276
345
|
// 复制 skills 到各工具目录
|
|
277
346
|
const skillToolDirs = {
|
|
278
347
|
claude: '.claude/skills',
|
package/src/quicklog.js
CHANGED
|
@@ -81,6 +81,36 @@ function sanitizeDesc(description) {
|
|
|
81
81
|
return s.length > 120 ? s.slice(0, 120) + '…' : s
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
|
|
85
|
+
// 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
|
|
86
|
+
// 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
|
|
87
|
+
export function deriveTitleFromLinkedChange(specBase, change) {
|
|
88
|
+
if (!change) return ''
|
|
89
|
+
for (const f of ['proposal.md', 'design.md']) {
|
|
90
|
+
let content
|
|
91
|
+
try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
|
|
92
|
+
const m = content.match(/^#\s+(.+?)\s*$/m)
|
|
93
|
+
if (!m) continue
|
|
94
|
+
const raw = m[1].trim()
|
|
95
|
+
// 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
|
|
96
|
+
const dash = raw.match(/[—-]\s*(.+)$/)
|
|
97
|
+
return dash ? dash[1].trim() : raw
|
|
98
|
+
}
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 从 quick step3 --output 四字段提取「需求:」摘要,用于翻完成时刷新标题行
|
|
103
|
+
// (覆盖启动时的占位/弱标题)。提取失败返回 ''(不刷新)。
|
|
104
|
+
function extractTitleFromResult(result) {
|
|
105
|
+
if (!result) return ''
|
|
106
|
+
const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
|
|
107
|
+
if (!m) return ''
|
|
108
|
+
let t = m[1].replace(/[,。;,;].*$/, '').trim() // 截到首个标点,取核心句
|
|
109
|
+
if (!t) return ''
|
|
110
|
+
if (t.length > 80) t = t.slice(0, 80) + '…'
|
|
111
|
+
return t
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
function sanitizeResult(resultText) {
|
|
85
115
|
// 保留换行:结果块支持字段化结构(需求:/根因:/方案:/结果:...)。多行时 flipEntryInContent
|
|
86
116
|
// 写成字段块(追加在「状态:」行下方),不是单条「结果:<长行>」。仅去首尾空白。
|
|
@@ -189,10 +219,13 @@ function tasksPath(specBase, change) {
|
|
|
189
219
|
return join(specBase, 'changes', change, 'tasks.md')
|
|
190
220
|
}
|
|
191
221
|
|
|
192
|
-
// 追加未勾选 task;幂等(已含同 qlId
|
|
222
|
+
// 追加未勾选 task;幂等(已含同 qlId 则跳过)。
|
|
223
|
+
// 关联变更目录不存在(笔误 / 未建 / 仅作标签关联)→ 不 fabricate stub 目录(历史坑 quick-change-phantom:
|
|
224
|
+
// mkdirSync 硬造 changes/<名>/tasks.md,致 quick --done 边界审计自造自拦 BLOCK)。关联仍记入
|
|
225
|
+
// QUICKLOG「关联变更」行(allocateQuicklogEntry 独立写入,不依赖本函数)。
|
|
193
226
|
function appendTaskCheckbox(specBase, change, qlId, desc) {
|
|
194
227
|
const dir = join(specBase, 'changes', change)
|
|
195
|
-
|
|
228
|
+
if (!existsSync(dir)) return
|
|
196
229
|
const p = tasksPath(specBase, change)
|
|
197
230
|
let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
|
|
198
231
|
if (content.includes(qlId)) return
|
|
@@ -219,6 +252,12 @@ function flipEntryInContent(content, qlId, result, changedFiles = []) {
|
|
|
219
252
|
const lines = content.split('\n')
|
|
220
253
|
const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
|
|
221
254
|
if (startIdx === -1) return null
|
|
255
|
+
// 翻完成时若能从结果提取「需求:」摘要,刷新标题行(覆盖启动时的占位/弱标题)
|
|
256
|
+
const newTitle = extractTitleFromResult(result)
|
|
257
|
+
if (newTitle) {
|
|
258
|
+
const headerCore = lines[startIdx].match(/^(## \S+ \| [^|]+ \| )/)
|
|
259
|
+
if (headerCore) lines[startIdx] = headerCore[1] + newTitle
|
|
260
|
+
}
|
|
222
261
|
let endIdx = lines.length
|
|
223
262
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
224
263
|
if (lines[i].startsWith('## ')) { endIdx = i; break }
|
package/src/run/command.js
CHANGED
|
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
23
23
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
24
24
|
import { randomBytes, randomUUID } from 'node:crypto'
|
|
25
25
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
26
|
-
import { resolveSpecDir, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName } from './shared.js'
|
|
26
|
+
import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift } from './shared.js'
|
|
27
27
|
import { resolveQuickLinkedChanges } from './quick-audit.js'
|
|
28
28
|
import { outputStep } from './prompt.js'
|
|
29
29
|
import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
|
|
@@ -195,6 +195,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
195
195
|
console.error(' 这些动作互斥,请只保留其中一个再重试。')
|
|
196
196
|
process.exit(2)
|
|
197
197
|
}
|
|
198
|
+
// scan profile flag 互斥:--quick/--standard/--deep 同时给≥2 个语义矛盾(computeScanProfile
|
|
199
|
+
// 只取首个命中、其余静默忽略 → agent 以为生效的其实没生效,与 STEP_ACTIONS 同类风险)。
|
|
200
|
+
const PROFILE_FLAGS = [
|
|
201
|
+
['--quick', flags.includes('--quick'), 'quick profile(快速接入,4 份核心文档)'],
|
|
202
|
+
['--standard', flags.includes('--standard'), 'standard profile(压缩步骤)'],
|
|
203
|
+
['--deep', flags.includes('--deep'), 'deep profile(完整扫描)'],
|
|
204
|
+
]
|
|
205
|
+
const activeProfiles = PROFILE_FLAGS.filter(([, v]) => v)
|
|
206
|
+
if (activeProfiles.length >= 2) {
|
|
207
|
+
console.error('❌ scan profile 参数冲突(同时只能指定一个):')
|
|
208
|
+
for (const [name, , desc] of activeProfiles) console.error(` ${name} — ${desc}`)
|
|
209
|
+
console.error(' 这三档互斥,请只保留其中一个再重试。')
|
|
210
|
+
process.exit(2)
|
|
211
|
+
}
|
|
198
212
|
// 注:--non-interactive 与 --interactive 的冲突在 index.js 检测(--interactive 被 index.js
|
|
199
213
|
// 作为全局 flag 吞掉、不透传到此处,故 isInteractive 在此恒为 false)。
|
|
200
214
|
const waitReason = getFlagValue('--reason')
|
|
@@ -279,6 +293,17 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
279
293
|
// runCommand 后续所有 .sillyspec/ 操作必须用 specBase
|
|
280
294
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
281
295
|
|
|
296
|
+
// 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
|
|
297
|
+
// 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
|
|
298
|
+
// 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。仅提醒不阻断。
|
|
299
|
+
if (!platformOpts.specRoot && !specDir) {
|
|
300
|
+
const ancestorCount = countAncestorSpecDirs(cwd)
|
|
301
|
+
if (ancestorCount >= 2) {
|
|
302
|
+
console.warn(`⚠️ 检测到祖先链有 ${ancestorCount} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
|
|
303
|
+
console.warn(` 若意图是另一个项目:cd 回该项目根,或用 --spec-dir <根>/.sillyspec 显式指定。`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
282
307
|
// 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
283
308
|
// ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
|
|
284
309
|
// 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
|
|
@@ -437,7 +462,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
437
462
|
'--files', '--allow-new', '--force-baseline', '--force-rescan',
|
|
438
463
|
'--json', '--dir', '--help',
|
|
439
464
|
'--reopen', '--from-step', '--mode',
|
|
440
|
-
'--deep', //
|
|
465
|
+
'--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)
|
|
441
466
|
])
|
|
442
467
|
for (let i = 0; i < flags.length; i++) {
|
|
443
468
|
const f = flags[i]
|
|
@@ -497,6 +522,21 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
497
522
|
process.exit(2) // 用法错(execute 必须显式 --change)→ exit 2
|
|
498
523
|
}
|
|
499
524
|
|
|
525
|
+
// worktree 副本漂移硬守卫(坑 worktree-execute-spec-drift):specBase 命中 worktree 内 .sillyspec
|
|
526
|
+
// checkout 副本 → 进度/产出会写分裂库。必须在 validateChangeExists 之前——副本里 change 目录真实
|
|
527
|
+
// 存在,存在性校验会被骗放行。覆盖 plan/execute/verify/archive(validateChangeExists 同 Set):
|
|
528
|
+
// 都要求 change 已存在、都被副本骗、都写 .sillyspec 资产(archive 尤甚:在副本里移动 change+commit)。
|
|
529
|
+
// 平台模式/显式 --spec-dir 跳过(已明确指定,与 line~285 warn / ~529 quick 守卫条件对齐)。
|
|
530
|
+
if (!platformOpts.specRoot && !specDir
|
|
531
|
+
&& ['plan', 'execute', 'verify', 'archive'].includes(stageName)) {
|
|
532
|
+
const wt = detectWorktreeSpecDrift(specBase)
|
|
533
|
+
if (wt) {
|
|
534
|
+
console.error(`❌ ${wt.message}`)
|
|
535
|
+
console.error(` 当前 cwd=${cwd},命中的 spec=${specBase}。`)
|
|
536
|
+
process.exit(2) // 环境错(cwd/spec 漂移)→ exit 2
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
500
540
|
// --change 变更名存在性校验(治 cwd 漂移误匹配,缺陷 execute-in-place-windows-pitfalls 坑5):
|
|
501
541
|
// 必须在 pm.read/initChange 之前——initChange 会为 --change 指定的新名字创建 changes/ 目录,
|
|
502
542
|
// 校验若在其后则目录已存在、永远通过。用原始 changeName(--change 参数),覆盖 plan/execute/
|
|
@@ -509,6 +549,21 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
509
549
|
process.exit(2) // 环境错(cwd/spec 漂移)→ exit 2
|
|
510
550
|
}
|
|
511
551
|
|
|
552
|
+
// quick 专用 cwd 漂移 fail-fast 守卫(坑 quick-cwd-drift-splits-specdir):
|
|
553
|
+
// quick 被 validateChangeExists 的 sessionId 豁免(quick-<8hex> 不在 changes/ 下),漂移时除上方
|
|
554
|
+
// countAncestorSpecDirs 的 warn 外无硬守卫 → 无声分裂(progress/artifact/QUICKLOG 落子项目、根
|
|
555
|
+
// 会话停滞)。这里补:当前 specBase 无本 session guard、但祖先链别处有 = 跨 specDir 漂移 → fail-fast。
|
|
556
|
+
// 平台模式/显式 --spec-dir 跳过(specBase 已明确,与 line~285 warn 条件对齐)。
|
|
557
|
+
if (stageName === 'quick' && changeName && /^quick-[0-9a-f]{8}$/.test(changeName)
|
|
558
|
+
&& !platformOpts.specRoot && !specDir) {
|
|
559
|
+
const drift = detectQuickSessionDrift(cwd, specBase, changeName)
|
|
560
|
+
if (drift) {
|
|
561
|
+
console.error(`❌ ${drift.message}`)
|
|
562
|
+
console.error(` 当前 cwd=${cwd},命中的 spec=${specBase}。`)
|
|
563
|
+
process.exit(2) // 环境错(cwd/spec 漂移)→ exit 2
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
512
567
|
let progress = await pm.read(cwd, changeName)
|
|
513
568
|
|
|
514
569
|
if (!progress) {
|
|
@@ -769,6 +769,9 @@ export async function handleScanStageCompleted({ stageName, currentIdx, cwd, pro
|
|
|
769
769
|
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
770
770
|
generated_at: new Date().toISOString(),
|
|
771
771
|
schema_version: 1,
|
|
772
|
+
scan_profile: stageData.scanProfile
|
|
773
|
+
? { mode: stageData.scanProfile.mode, reason: stageData.scanProfile.reason }
|
|
774
|
+
: null,
|
|
772
775
|
postcheck_result_path: null,
|
|
773
776
|
workflow_runs_dir: platformOpts.runtimeRoot
|
|
774
777
|
? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown', 'workflow-runs')
|
|
@@ -797,6 +800,7 @@ export async function handleScanStageCompleted({ stageName, currentIdx, cwd, pro
|
|
|
797
800
|
projectListParsed: stageData.scanMeta?.projectListParsed ?? null,
|
|
798
801
|
manifestWritten: stageData.scanMeta?.manifestWritten ?? null,
|
|
799
802
|
},
|
|
803
|
+
scanProfile: stageData.scanProfile || null,
|
|
800
804
|
})
|
|
801
805
|
printScanPostCheckResult(postResult)
|
|
802
806
|
|
|
@@ -871,7 +875,7 @@ export async function handleScanStageCompleted({ stageName, currentIdx, cwd, pro
|
|
|
871
875
|
// 非 platform 模式 scan 也做轻量 post-check + 结构化输出
|
|
872
876
|
if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
|
|
873
877
|
const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('../scan-postcheck.js')
|
|
874
|
-
const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
|
|
878
|
+
const postResult = runScanPostCheck({ cwd, specDir: null, outputText, scanProfile: stageData.scanProfile || null })
|
|
875
879
|
printScanPostCheckResult(postResult)
|
|
876
880
|
// 结构化结果写入 .sillyspec/.runtime/
|
|
877
881
|
const structured = formatStructuredResult(postResult, { source_root: cwd })
|