sillyspec 3.24.8 → 3.25.1
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/package.json +1 -1
- package/src/constants.js +13 -0
- package/src/contract-matrix.js +48 -20
- package/src/db.js +28 -8
- package/src/endpoint-extractor.js +92 -6
- package/src/hooks/worktree-guard.js +25 -6
- package/src/index.js +6 -6
- package/src/machine-interface.js +13 -7
- package/src/progress.js +46 -50
- package/src/run/command.js +1011 -0
- package/src/run/complete-handlers.js +873 -0
- package/src/run/complete.js +695 -0
- package/src/run/gates.js +321 -0
- package/src/run/prompt.js +604 -0
- package/src/run/quick-audit.js +102 -0
- package/src/run/scan-profile.js +238 -0
- package/src/run/shared.js +375 -0
- package/src/run/stage.js +414 -0
- package/src/run.js +23 -4278
- package/src/stage-contract.js +11 -11
- package/src/stages/index.js +1 -1
- package/src/verify-postcheck.js +41 -0
- package/src/worktree-apply.js +45 -18
- package/src/worktree.js +30 -13
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run/quick-audit.js(W6 Step2 从 run.js 抽出)。
|
|
3
|
+
*
|
|
4
|
+
* quick 审计结论打印 + quick 多变更关联选择(自洽,不依赖 run.js 闭包)。
|
|
5
|
+
* 安全锚:run.js 始终 barrel,本模块函数 run.js import 回来;无 test 直接 import,无需 re-export。
|
|
6
|
+
*
|
|
7
|
+
* 路径修正(相对 src/run/):
|
|
8
|
+
* - resolveQuickLinkedChanges 的 quick-recommend.js 动态 import './quick-recommend.js' → '../quick-recommend.js'
|
|
9
|
+
* - 原动态 import('child_process') 删除,改顶部静态 execSync
|
|
10
|
+
* - parsePorcelainPath 从 ./shared.js 重 import;checkbox 顶部 import
|
|
11
|
+
*/
|
|
12
|
+
import { execSync } from 'node:child_process'
|
|
13
|
+
import { checkbox } from '@inquirer/prompts'
|
|
14
|
+
import { parsePorcelainPath } from './shared.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 打印 quick 完成审计结论(按 review.status 输出 SAFE/WARNING/BLOCKED)。
|
|
18
|
+
*/
|
|
19
|
+
export function printQuickAuditReview(review) {
|
|
20
|
+
if (review.status === 'blocked') {
|
|
21
|
+
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
22
|
+
for (const r of review.reasons) {
|
|
23
|
+
console.error(` - ${r}`)
|
|
24
|
+
}
|
|
25
|
+
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
26
|
+
} else if (review.status === 'warning') {
|
|
27
|
+
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
28
|
+
for (const r of review.reasons) {
|
|
29
|
+
console.warn(` - ${r}`)
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* quick 阶段多变更交互式选择关联变更。
|
|
38
|
+
* - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
|
|
39
|
+
* - 1 活跃变更 → 默认关联它(保持现状友好,不弹交互)
|
|
40
|
+
* - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
|
|
41
|
+
* - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
|
|
44
|
+
let activeChanges = []
|
|
45
|
+
try {
|
|
46
|
+
activeChanges = await pm.listChanges(cwd)
|
|
47
|
+
} catch {
|
|
48
|
+
activeChanges = []
|
|
49
|
+
}
|
|
50
|
+
if (activeChanges.length === 0) return []
|
|
51
|
+
if (activeChanges.length === 1) return [activeChanges[0]]
|
|
52
|
+
|
|
53
|
+
if (nonInteractive || !process.stdin.isTTY) {
|
|
54
|
+
console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 脏文件(推荐信号之一)
|
|
59
|
+
let baselineFiles = []
|
|
60
|
+
try {
|
|
61
|
+
const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
62
|
+
baselineFiles = gs.split('\n').filter(Boolean)
|
|
63
|
+
.map(l => parsePorcelainPath(l))
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.filter(f => !f.startsWith('.sillyspec/'))
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
68
|
+
// 推荐打分(脏文件 + 任务描述双信号)
|
|
69
|
+
let recommendations = []
|
|
70
|
+
try {
|
|
71
|
+
// quick-recommend.js 在 src/,本模块在 src/run/ → 退一层(真环依赖,保留动态 import)
|
|
72
|
+
const { recommendChanges } = await import('../quick-recommend.js')
|
|
73
|
+
recommendations = recommendChanges({ activeChanges, specDir, baselineFiles, quickFiles, taskDescription })
|
|
74
|
+
} catch {
|
|
75
|
+
recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
|
|
76
|
+
}
|
|
77
|
+
const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
|
|
78
|
+
const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
|
|
79
|
+
const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
|
|
80
|
+
|
|
81
|
+
// 按推荐分降序展示
|
|
82
|
+
const ordered = [...activeChanges].sort((a, b) =>
|
|
83
|
+
(scoreMap.get(b) || 0) - (scoreMap.get(a) || 0) || a.localeCompare(b))
|
|
84
|
+
|
|
85
|
+
const choices = ordered.map(name => {
|
|
86
|
+
const reasons = reasonMap.get(name) || []
|
|
87
|
+
const isRec = recommendedSet.has(name)
|
|
88
|
+
return {
|
|
89
|
+
name: `${isRec ? '⭐ ' : ' '}${name}`,
|
|
90
|
+
value: name,
|
|
91
|
+
description: reasons.length > 0 ? reasons.join(';') : '无推荐信号',
|
|
92
|
+
checked: isRec,
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
console.log('🔗 检测到多个活跃变更,选择本次 quick 关联哪些(可多选;不勾选任何项 = 仅记 QUICKLOG,不关联变更)')
|
|
97
|
+
if (recommendedSet.size > 0) {
|
|
98
|
+
console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
|
|
99
|
+
}
|
|
100
|
+
const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
|
|
101
|
+
return selected
|
|
102
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run/scan-profile.js(W6 Step2 从 run.js 抽出)。
|
|
3
|
+
*
|
|
4
|
+
* scan profile 数据生成 + quick scan CLI preflight/postcheck(自洽,不依赖 run.js 闭包)。
|
|
5
|
+
* 安全锚:run.js 始终 barrel,本模块函数 run.js import 回来;无 test 直接 import,无需 re-export。
|
|
6
|
+
*
|
|
7
|
+
* 路径修正(相对 src/run/):
|
|
8
|
+
* - executeScanPostcheck 的 scan-postcheck.js 动态 import './scan-postcheck.js' → '../scan-postcheck.js'
|
|
9
|
+
* - executeScanPostcheck 原冗余动态 import('fs'/'path'/'child_process') 删除,改顶部静态(execSync 实际未直接用,纯遗留)
|
|
10
|
+
*/
|
|
11
|
+
import { join, basename, extname } from 'node:path'
|
|
12
|
+
import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { safeGit } from './shared.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 估算源码规模(文件数 + 字节数)—— 跨平台 Node 遍历,替代 Unix `find`(原生 Windows 无 find,
|
|
17
|
+
* 原 find 失效会让所有 Windows 项目永远 fallback 到 standard profile)。
|
|
18
|
+
* 扩展名与排除目录与原 find 命令逐字一致;maxFiles 封顶避免超大仓库遍历过久
|
|
19
|
+
* (超限时 fileCount 已远超 quick/standard 阈值,不影响 profile 判定)。
|
|
20
|
+
*/
|
|
21
|
+
export function estimateSourceSize(cwd, maxFiles = 5000) {
|
|
22
|
+
const sourceExts = new Set(['.js','.ts','.tsx','.py','.java','.go','.rs','.rb','.php','.c','.cpp','.h','.jsx','.vue','.svelte'])
|
|
23
|
+
const skipDirs = new Set(['node_modules','.git','dist','build','__pycache__','.sillyspec','.claude'])
|
|
24
|
+
let fileCount = 0
|
|
25
|
+
let sourceBytes = 0
|
|
26
|
+
const stack = [cwd]
|
|
27
|
+
while (stack.length) {
|
|
28
|
+
const dir = stack.pop()
|
|
29
|
+
let entries
|
|
30
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) }
|
|
31
|
+
catch { continue }
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
if (e.isDirectory()) {
|
|
34
|
+
if (!skipDirs.has(e.name)) stack.push(join(dir, e.name))
|
|
35
|
+
} else if (e.isFile() && sourceExts.has(extname(e.name))) {
|
|
36
|
+
fileCount++
|
|
37
|
+
try { sourceBytes += statSync(join(dir, e.name)).size } catch {}
|
|
38
|
+
if (fileCount >= maxFiles) return { fileCount, sourceBytes }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { fileCount, sourceBytes }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 根据 project 规模计算 scan profile
|
|
47
|
+
* quick: fileCount≤30 && sourceBytes≤80KB && projectCount≤3 → 3 步,0 子代理,5 份文档
|
|
48
|
+
* standard: fileCount≤200 && sourceBytes≤800KB → 压缩步骤,最多 1 子代理
|
|
49
|
+
* deep: 大项目或 --deep → 完整流程
|
|
50
|
+
*/
|
|
51
|
+
export function computeScanProfile(cwd, platformOpts) {
|
|
52
|
+
// --deep 标志强制 deep
|
|
53
|
+
const flags = process.argv.slice(2)
|
|
54
|
+
if (flags.includes('--deep')) {
|
|
55
|
+
return { mode: 'deep', reason: '用户指定 --deep', maxAgentCalls: 4, maxDocs: 99 }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const specDir = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
59
|
+
const projectsDir = join(specDir, 'projects')
|
|
60
|
+
let projectCount = 1
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(projectsDir)) {
|
|
63
|
+
projectCount = readdirSync(projectsDir).filter(f => f.endsWith('.yaml')).length
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
|
|
67
|
+
// 快速估算源码规模(Node 遍历,跨平台;原 Unix find 在原生 Windows 上失效会永远 fallback standard)
|
|
68
|
+
let fileCount = 0
|
|
69
|
+
let sourceBytes = 0
|
|
70
|
+
try {
|
|
71
|
+
const est = estimateSourceSize(cwd)
|
|
72
|
+
fileCount = est.fileCount
|
|
73
|
+
sourceBytes = est.sourceBytes
|
|
74
|
+
} catch {
|
|
75
|
+
// 遍历异常时假设中等规模
|
|
76
|
+
return { mode: 'standard', reason: '无法估算项目规模', maxAgentCalls: 1, maxDocs: 8 }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (fileCount <= 30 && sourceBytes <= 80_000 && projectCount <= 3) {
|
|
80
|
+
return { mode: 'quick', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 0, maxDocs: 5, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
|
|
81
|
+
}
|
|
82
|
+
if (fileCount <= 200 && sourceBytes <= 800_000) {
|
|
83
|
+
return { mode: 'standard', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 1, maxDocs: 8, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
|
|
84
|
+
}
|
|
85
|
+
return { mode: 'deep', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 4, maxDocs: 99, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 根据 scanProfile 裁剪步骤
|
|
90
|
+
* quick: 3 步 — CLI preflight / AI generate / CLI postcheck
|
|
91
|
+
* standard: 跳过续扫检测(4), 跳过可选步骤(9)
|
|
92
|
+
*/
|
|
93
|
+
export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
|
|
94
|
+
const steps = stageData.steps
|
|
95
|
+
const mode = profile.mode
|
|
96
|
+
|
|
97
|
+
if (mode === 'quick') {
|
|
98
|
+
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
99
|
+
const projectName = basename(cwd)
|
|
100
|
+
const docsRoot = join(specBase, 'docs', projectName)
|
|
101
|
+
|
|
102
|
+
// Step 1: CLI preflight(不调 AI,自动完成)
|
|
103
|
+
const step1 = {
|
|
104
|
+
name: '项目概览(自动探测)',
|
|
105
|
+
status: 'pending',
|
|
106
|
+
noAI: true,
|
|
107
|
+
_cliAction: 'scanPreflight',
|
|
108
|
+
prompt: '',
|
|
109
|
+
outputHint: 'preflight 结果',
|
|
110
|
+
optional: false
|
|
111
|
+
}
|
|
112
|
+
// Step 2: AI 生成核心文档(唯一 AI roundtrip)
|
|
113
|
+
const step2 = {
|
|
114
|
+
name: '生成核心文档',
|
|
115
|
+
status: 'pending',
|
|
116
|
+
prompt: `## Quick Scan — 核心文档生成
|
|
117
|
+
|
|
118
|
+
项目规模较小(quick profile),请一次性生成所有核心文档。
|
|
119
|
+
|
|
120
|
+
### 操作
|
|
121
|
+
1. 读取项目结构和关键文件(package.json / pyproject.toml / README / 入口文件)
|
|
122
|
+
2. 生成以下 4 份文档并写入 \`{DOCS_ROOT}/scan/\`:
|
|
123
|
+
- **PROJECT.md** — 项目简介、技术栈、模块划分
|
|
124
|
+
- **ARCHITECTURE.md** — 架构概览、模块关系、技术决策
|
|
125
|
+
- **CONVENTIONS.md** — 代码风格、框架隐形规则
|
|
126
|
+
- **STRUCTURE.md** — 目录树 + 模块说明
|
|
127
|
+
3. 如发现子项目,注册到 \`{PROJECTS_ROOT}/\` 下
|
|
128
|
+
|
|
129
|
+
每份文档头必须包含 frontmatter:\`author\` 和 \`created_at\`。
|
|
130
|
+
|
|
131
|
+
### ⛔ 硬约束
|
|
132
|
+
- **严禁使用子代理(Agent/Task 工具)。** 所有文档在一个 turn 内完成。
|
|
133
|
+
- 不要搜索 .sillyspec/ .claude/ .git/ node_modules/ dist/ build/
|
|
134
|
+
- --output 只需要列出生成的文件名,不要写长篇总结
|
|
135
|
+
|
|
136
|
+
### 输出
|
|
137
|
+
生成的文件列表`,
|
|
138
|
+
outputHint: '文件列表',
|
|
139
|
+
optional: false
|
|
140
|
+
}
|
|
141
|
+
// Step 3: CLI postcheck(不调 AI,自动完成)
|
|
142
|
+
const selfCheck = steps.find(s => s.name === '自检和提交') || {
|
|
143
|
+
name: '自检和提交', status: 'pending', prompt: '', outputHint: '结果', optional: false
|
|
144
|
+
}
|
|
145
|
+
const step3 = { ...selfCheck, status: 'pending', noAI: true, _cliAction: 'scanPostcheck', prompt: '' }
|
|
146
|
+
stageData.steps = [step1, step2, step3]
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (mode === 'standard') {
|
|
151
|
+
// 跳过 Step 4(断点续扫检测),跳过 Step 9(flows+glossary,可选)
|
|
152
|
+
const skipNames = ['断点续扫检测', '生成业务流程和术语表(可选)']
|
|
153
|
+
for (const step of stageData.steps) {
|
|
154
|
+
if (skipNames.includes(step.name) && step.status === 'pending') {
|
|
155
|
+
step.status = 'skipped'
|
|
156
|
+
step.skippedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* CLI-only: quick scan preflight
|
|
164
|
+
* 收集项目快照,打印 summary,不调 AI
|
|
165
|
+
*/
|
|
166
|
+
export async function executeScanPreflight(cwd, platformOpts, scanProfile) {
|
|
167
|
+
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
168
|
+
const projectName = basename(cwd)
|
|
169
|
+
console.log(` 📁 项目: ${projectName}`)
|
|
170
|
+
console.log(` 📊 Profile: ${scanProfile.mode} (${scanProfile.reason})`)
|
|
171
|
+
// 快速列出顶层结构(readdirSync 跨平台,避免 spawn shell + 5 个 grep 进程)
|
|
172
|
+
try {
|
|
173
|
+
const skip = new Set(['node_modules', '.git', '.sillyspec', '.claude'])
|
|
174
|
+
const dirNames = readdirSync(cwd, { withFileTypes: true })
|
|
175
|
+
.filter(d => d.isDirectory() && !skip.has(d.name) && !d.name.startsWith('.'))
|
|
176
|
+
.slice(0, 20)
|
|
177
|
+
.map(d => d.name)
|
|
178
|
+
if (dirNames.length) {
|
|
179
|
+
console.log(` 📂 目录: ${dirNames.join(', ')}`)
|
|
180
|
+
}
|
|
181
|
+
} catch {}
|
|
182
|
+
console.log(` ✅ Preflight 完成,准备生成核心文档\n`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* CLI-only: quick scan postcheck
|
|
187
|
+
* 执行文件存在性 + manifest 检查,不调 AI
|
|
188
|
+
*/
|
|
189
|
+
export async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
|
|
190
|
+
// scan-postcheck.js 在 src/,本模块在 src/run/ → 退一层(真环依赖,保留动态 import)
|
|
191
|
+
const { runScanPostCheck, printScanPostCheckResult } = await import('../scan-postcheck.js')
|
|
192
|
+
const specDir = platformOpts?.specRoot || null
|
|
193
|
+
const result = runScanPostCheck({
|
|
194
|
+
cwd,
|
|
195
|
+
specDir,
|
|
196
|
+
scanMeta: {
|
|
197
|
+
projectListParsed: true,
|
|
198
|
+
manifestWritten: undefined,
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
printScanPostCheckResult(result)
|
|
202
|
+
// 写 manifest(如果还没写)
|
|
203
|
+
if (platformOpts?.specRoot) {
|
|
204
|
+
try {
|
|
205
|
+
const manifestDir = platformOpts.specRoot
|
|
206
|
+
let sourceCommit = null
|
|
207
|
+
let sourceCommitError = null
|
|
208
|
+
try {
|
|
209
|
+
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
210
|
+
sourceCommit = gitResult.value
|
|
211
|
+
sourceCommitError = gitResult.error
|
|
212
|
+
} catch (e) {
|
|
213
|
+
sourceCommitError = e.message
|
|
214
|
+
}
|
|
215
|
+
mkdirSync(manifestDir, { recursive: true })
|
|
216
|
+
const manifest = {
|
|
217
|
+
scan_profile: {
|
|
218
|
+
mode: scanProfile.mode,
|
|
219
|
+
file_count: scanProfile._fileCount || 0,
|
|
220
|
+
source_bytes: scanProfile._sourceBytes || 0,
|
|
221
|
+
project_count: scanProfile._projectCount || 0,
|
|
222
|
+
reason: scanProfile.reason,
|
|
223
|
+
},
|
|
224
|
+
workspace_id: platformOpts.workspaceId || null,
|
|
225
|
+
scan_run_id: platformOpts.scanRunId || null,
|
|
226
|
+
source_commit: sourceCommit,
|
|
227
|
+
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
228
|
+
generated_at: new Date().toISOString(),
|
|
229
|
+
schema_version: 2,
|
|
230
|
+
}
|
|
231
|
+
const manifestPath = join(manifestDir, 'manifest.json')
|
|
232
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
233
|
+
console.log(` 📄 manifest.json 已写入: ${manifestPath}`)
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.warn(` ⚠️ manifest 写入失败: ${e.message}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|