sillyspec 3.26.8 → 3.26.9
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-archive/SKILL.md +1 -2
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
- package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
- package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
- package/.claude/skills/sillyspec-status/SKILL.md +0 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
- package/README.md +5 -9
- package/package.json +2 -2
- package/packages/dashboard/server/index.js +14 -8
- package/src/change-risk-profile.js +5 -207
- package/src/config-schema.js +6 -1
- package/src/constants.js +97 -106
- package/src/db-engine.js +1 -1
- package/src/docs-check.js +34 -5
- package/src/git-helper.js +2 -2
- package/src/hooks/worktree-guard.js +1 -1
- package/src/index.js +96 -13
- package/src/init.js +14 -3
- package/src/machine-interface.js +7 -4
- package/src/progress/stage-machine.js +3 -0
- package/src/quicklog.js +182 -1
- package/src/run/command.js +101 -3
- package/src/run/complete.js +9 -1
- package/src/run/gates.js +67 -1
- package/src/run/prompt.js +721 -713
- package/src/run/quick-audit.js +146 -137
- package/src/run/shared.js +1197 -1109
- package/src/run/stage.js +17 -1
- package/src/scan-diff.js +388 -0
- package/src/scan-staleness.js +18 -13
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-review.js +1 -1
- package/src/stages/brainstorm.js +2 -2
- package/src/stages/execute.js +991 -985
- package/src/stages/plan.js +22 -7
- package/src/stages/quick.js +1 -1
- package/src/stages/scan.js +2 -2
- package/src/stages/verify.js +10 -11
- package/src/sync.js +12 -0
- package/src/task-review.js +12 -4
- package/src/workflow.js +8 -185
- package/src/worktree-apply.js +122 -0
- package/src/worktree.js +2 -2
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -0
- package/templates/prompts/verify-probes.md +2 -2
- package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
package/src/run/stage.js
CHANGED
|
@@ -25,6 +25,7 @@ import { outputStep } from './prompt.js'
|
|
|
25
25
|
import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
|
|
26
26
|
import { createHash } from 'node:crypto'
|
|
27
27
|
import { checkTransition } from '../stage-contract.js'
|
|
28
|
+
import { AUXILIARY_STAGES } from '../constants.js'
|
|
28
29
|
import { completeStageGates } from './gates.js'
|
|
29
30
|
|
|
30
31
|
export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
@@ -107,6 +108,15 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
107
108
|
} catch {}
|
|
108
109
|
if (!currentExecuteRunId) {
|
|
109
110
|
currentExecuteRunId = generateExecuteRunId()
|
|
111
|
+
// D-001#1 主写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在,
|
|
112
|
+
// archive 完成度扫描/漂移兜底不再落到「有 marker 无目录」的空 run)。失败直接 throw——execute
|
|
113
|
+
// 启动即失败优于事后 review 错配(调用方 runCommand 冒到 CLI 顶层 exit 1,给出修复指引)。
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(join(runtimeRoot, 'execute-runs', currentExecuteRunId, 'tasks'), { recursive: true })
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
|
|
118
|
+
`请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
|
|
119
|
+
}
|
|
110
120
|
writeFileSync(runIdFile, currentExecuteRunId + '\n')
|
|
111
121
|
}
|
|
112
122
|
}
|
|
@@ -125,8 +135,12 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
// 用户显式调用 sillyspec run <stage>:把它标记为当前阶段
|
|
138
|
+
// (D-003@v1:auxiliary 阶段不写 currentStage,避免 scan/quick/explore/archive/status/doctor
|
|
139
|
+
// 执行后污染主流程当前阶段;lastActive 心跳与 pm._write/triggerSync 照常)
|
|
128
140
|
if (progress.currentStage !== stageName) {
|
|
129
|
-
|
|
141
|
+
if (!AUXILIARY_STAGES.includes(stageName)) {
|
|
142
|
+
progress.currentStage = stageName
|
|
143
|
+
}
|
|
130
144
|
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
131
145
|
pm._write(cwd, progress, changeName)
|
|
132
146
|
triggerSync(cwd, changeName, platformOpts)
|
|
@@ -375,6 +389,8 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
375
389
|
// 阶段完成收尾共享管线(noAI 末步核心修复 S1:plan postcheck independent-tier review verdict=fail /
|
|
376
390
|
// 平台 scan manifest 此前被绕过)。gate 失败已 rollback 为 in-progress,early-return(不 fall through 到末尾 return)。
|
|
377
391
|
const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText: null })
|
|
392
|
+
// task-04 / A5:gate 失败(stageCompleted===false)设进程退出码 1(与 completeStep/continueStep 同语义)。
|
|
393
|
+
if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
|
|
378
394
|
if (_stageGatesResult) return _stageGatesResult
|
|
379
395
|
// gate 全过:persist completed(task-02 移后;此处无 triggerSync,与同文件 noAI 末步语义一致)。
|
|
380
396
|
pm._write(cwd, progress, changeName)
|
package/src/scan-diff.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scan-diff — scan 文档 vs 源码漂移清单(change: 2026-08-16-scan-diff-command, task-01)
|
|
3
|
+
*
|
|
4
|
+
* 定位:scan 文档是架构快照(基线 = 生成时源码 commit,frontmatter source_commit),源码持续演进。
|
|
5
|
+
* scan-diff 算出「基线之后源码变了什么、哪些模块的 scan 文档需要核对/刷新」,按四分类输出:
|
|
6
|
+
* A 新增文件 → scan 文档缺记(缺文档)
|
|
7
|
+
* D 已删除文件 → scan 文档多记(多文档)
|
|
8
|
+
* M / R / C 修改 / 重命名 / 复制 → scan 文档过时
|
|
9
|
+
* 其余(T 等)→ unknown(不判漂移,只展示)
|
|
10
|
+
* 扫描范围缺省 = module-map paths 覆盖集(parseModuleMapSimple 收集全部 paths 去重),
|
|
11
|
+
* 非 src/-only——范围外的源码变更与模块文档无涉,不计入漂移。
|
|
12
|
+
*
|
|
13
|
+
* 分层:
|
|
14
|
+
* computeScanDiff —— 计算层(git 只读 + frontmatter/module-map 读取;无渲染无落盘)。
|
|
15
|
+
* 硬错误返回 { ok:false, error }(无效 base / git 失败 / 无 source_commit);
|
|
16
|
+
* advisory 问题进 warnings(非祖先 base / rev-list 降级 / module-map 缺失)。
|
|
17
|
+
* runScanDiff —— IO 面(终端聚合渲染 + --report 落盘 scan-diff-report.md),返回退出码。
|
|
18
|
+
*
|
|
19
|
+
* 复用(先读源码确认签名后使用):parseSourceCommit(scan-staleness)、
|
|
20
|
+
* matchFilesToModules(docs-debt,含裸名兜底)、safeGit(git-helper)、
|
|
21
|
+
* parseModuleMapSimple(modules.js);零新依赖。
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { safeGit } from './git-helper.js'
|
|
26
|
+
import { parseSourceCommit } from './scan-staleness.js'
|
|
27
|
+
import { matchFilesToModules } from './docs-debt.js'
|
|
28
|
+
import { parseModuleMapSimple } from './modules.js'
|
|
29
|
+
|
|
30
|
+
/** safeGit 超时(仿 docs-debt 的 GIT_TIMEOUT_MS 模式:大 diff 慢时降级不挂起) */
|
|
31
|
+
const GIT_TIMEOUT_MS = 10000
|
|
32
|
+
|
|
33
|
+
/** 每模块终端渲染默认上限(--full 展开全部) */
|
|
34
|
+
const DEFAULT_LIST_CAP = 5
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 计算层:scan 文档 vs 源码漂移事实。
|
|
38
|
+
* @param {{ projectRoot: string, specBase: string, projectName: string, base?: string|null }} opts
|
|
39
|
+
* projectRoot:git 锚(diff/rev-list 的 -C 目录)
|
|
40
|
+
* specBase / projectName:scan 文档与 module-map 定位(specBase/docs/<projectName>/...)
|
|
41
|
+
* base:基线 commit(短/长哈希、引用均可);缺省读 scan 文档 frontmatter 的 source_commit
|
|
42
|
+
* @returns {{ ok: true, base, baseSource, head, behindCommits, daysSinceScan, scope,
|
|
43
|
+
* added, deleted, modified, unknown, unmapped, outOfScope, renameMap, byModule, warnings, driftCount }
|
|
44
|
+
* | { ok: false, error: string }}
|
|
45
|
+
* ok:false = 硬错误(参数缺失 / 无 source_commit / 无效 base / git diff 失败)
|
|
46
|
+
* added/deleted/modified/unknown:范围内变更文件(POSIX 相对路径;R/C 记新路径)
|
|
47
|
+
* unmapped:范围内未匹配到模块的文件;outOfScope:范围外变更(不计入漂移)
|
|
48
|
+
* renameMap:newPath → oldPath(R 归 modified 且保留 rename 细节)
|
|
49
|
+
* byModule:{ moduleId: { doc, added, deleted, modified, files } }(A/D/M 计数 + 文件清单)
|
|
50
|
+
* driftCount = added + deleted + modified
|
|
51
|
+
*/
|
|
52
|
+
export function computeScanDiff({ projectRoot, specBase, projectName, base } = {}) {
|
|
53
|
+
if (!projectRoot || !specBase || !projectName) {
|
|
54
|
+
return { ok: false, error: 'projectRoot / specBase / projectName 必填,缺一不可' }
|
|
55
|
+
}
|
|
56
|
+
const warnings = []
|
|
57
|
+
let baseSource = '--base 显式指定'
|
|
58
|
+
|
|
59
|
+
// ── 基线:缺省读 specBase/docs/<projectName>/scan/*.md frontmatter 的 source_commit ──
|
|
60
|
+
if (!base) {
|
|
61
|
+
const sourceCommit = readSourceCommit(specBase, projectName)
|
|
62
|
+
if (!sourceCommit) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false, error:
|
|
65
|
+
'scan 文档无 source_commit 字段(绿地项目 / 旧版生成 / 无 scan 文档),无法定基线——请用 --base <commit> 显式指定基线',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
base = sourceCommit
|
|
69
|
+
baseSource = 'scan 文档 frontmatter source_commit'
|
|
70
|
+
}
|
|
71
|
+
const baseDisplay = displayBase(base)
|
|
72
|
+
|
|
73
|
+
// ── base 有效性守卫(仿 computeScanStaleness 的 isAncestor 守卫)──
|
|
74
|
+
// merge-base --is-ancestor 对「无效 commit」与「有效但非祖先」都返回非 0(safeGit 记为 error),
|
|
75
|
+
// 无法直接区分——先用 rev-parse 验有效性:无效 → 报错;有效但非祖先 → 警告(diff 按两快照对比仍可算)。
|
|
76
|
+
const revRes = safeGit(projectRoot, ['rev-parse', '--verify', `${base}^{commit}`])
|
|
77
|
+
if (revRes.error || !revRes.value) {
|
|
78
|
+
return { ok: false, error: `基线 ${baseDisplay} 不是有效 commit(${revRes.error || '无输出'}),跳过 scan-diff` }
|
|
79
|
+
}
|
|
80
|
+
const resolvedBase = revRes.value.trim()
|
|
81
|
+
const headRes = safeGit(projectRoot, ['rev-parse', 'HEAD'])
|
|
82
|
+
const headShort = headRes.error ? 'HEAD' : headRes.value.trim().slice(0, 7)
|
|
83
|
+
const isAncestor = safeGit(projectRoot, ['merge-base', '--is-ancestor', resolvedBase, 'HEAD'])
|
|
84
|
+
if (isAncestor.error) {
|
|
85
|
+
warnings.push(`基线 ${baseDisplay} 不是 HEAD(${headShort})祖先(rebase / 分支切换)——diff 按两快照对比,删除后重建可能呈假新增`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 落后计数(advisory:超时/失败降级 null,不抛)──
|
|
89
|
+
const countRes = safeGit(projectRoot, ['rev-list', '--count', `${resolvedBase}..HEAD`], { timeout: GIT_TIMEOUT_MS })
|
|
90
|
+
let behindCommits = countRes.error ? null : parseInt(countRes.value, 10)
|
|
91
|
+
if (behindCommits === null || Number.isNaN(behindCommits)) {
|
|
92
|
+
behindCommits = null
|
|
93
|
+
warnings.push('rev-list 计数失败(git 降级),behindCommits=null')
|
|
94
|
+
}
|
|
95
|
+
const dateRes = safeGit(projectRoot, ['log', '-1', '--format=%cI', resolvedBase])
|
|
96
|
+
let daysSinceScan = null
|
|
97
|
+
if (!dateRes.error && dateRes.value) {
|
|
98
|
+
daysSinceScan = Math.floor((Date.now() - new Date(dateRes.value.trim()).getTime()) / 86400000)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 扫描范围:module-map paths 覆盖集(默认范围,非 src/-only)──
|
|
102
|
+
const cardsDir = join(specBase, 'docs', projectName, 'modules')
|
|
103
|
+
const mapPath = join(cardsDir, '_module-map.yaml')
|
|
104
|
+
let moduleIndex = null
|
|
105
|
+
if (existsSync(mapPath)) {
|
|
106
|
+
try { moduleIndex = parseModuleMapSimple(readFileSync(mapPath, 'utf8')) } catch { moduleIndex = null }
|
|
107
|
+
}
|
|
108
|
+
const scope = collectScope(moduleIndex)
|
|
109
|
+
if (scope.length === 0) {
|
|
110
|
+
warnings.push('module-map 缺失 / 解析为空(跑 sillyspec modules rebuild 生成)——扫描范围退化=全部变更文件,无法归模块')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── diff:只算路径(--find-renames 检出重命名;超时降级为错误返回)──
|
|
114
|
+
const diffRes = safeGit(projectRoot, ['diff', '--name-status', '--find-renames', `${resolvedBase}..HEAD`], { timeout: GIT_TIMEOUT_MS })
|
|
115
|
+
if (diffRes.error) {
|
|
116
|
+
return { ok: false, error: `git diff ${baseDisplay}..HEAD 失败(${diffRes.error}),跳过 scan-diff` }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 四分类 + 范围过滤 ──
|
|
120
|
+
const added = [], deleted = [], modified = [], unknown = [], outOfScope = []
|
|
121
|
+
const renameMap = {} // newPath → oldPath(R 归 modified 且保留 rename 细节)
|
|
122
|
+
const scopeFilter = scope.length > 0 ? scope : null
|
|
123
|
+
for (const item of parseNameStatus(diffRes.value)) {
|
|
124
|
+
if (scopeFilter && !inScope(item.path, scopeFilter)) { outOfScope.push(item.path); continue }
|
|
125
|
+
switch (item.status) {
|
|
126
|
+
case 'A': added.push(item.path); break
|
|
127
|
+
case 'D': deleted.push(item.path); break
|
|
128
|
+
case 'M': modified.push(item.path); break
|
|
129
|
+
case 'R': modified.push(item.path); renameMap[item.path] = item.oldPath; break
|
|
130
|
+
case 'C': modified.push(item.path); break // 复制目标(新路径)视同过时
|
|
131
|
+
default: unknown.push(item.path); break // T / B 等
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 归模块(matchFilesToModules,含裸名兜底;结果 = 同文件清单直接调用一致)──
|
|
136
|
+
const inScopeFiles = [...added, ...deleted, ...modified, ...unknown]
|
|
137
|
+
let byModuleMap = new Map()
|
|
138
|
+
let unmapped = inScopeFiles
|
|
139
|
+
if (inScopeFiles.length > 0) {
|
|
140
|
+
try {
|
|
141
|
+
const matched = matchFilesToModules(inScopeFiles, moduleIndex || {}, { cardsDir })
|
|
142
|
+
byModuleMap = matched.byModule
|
|
143
|
+
unmapped = matched.unmapped
|
|
144
|
+
} catch {
|
|
145
|
+
warnings.push('matchFilesToModules 归模块失败(降级为全部未归模块)')
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
unmapped = []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── byModule 聚合 A/D/M 计数 ──
|
|
152
|
+
const addedSet = new Set(added)
|
|
153
|
+
const deletedSet = new Set(deleted)
|
|
154
|
+
const modifiedSet = new Set(modified)
|
|
155
|
+
const byModule = {}
|
|
156
|
+
for (const [id, entry] of byModuleMap) {
|
|
157
|
+
let a = 0, d = 0, m = 0
|
|
158
|
+
for (const f of entry.files) {
|
|
159
|
+
if (addedSet.has(f)) a++
|
|
160
|
+
else if (deletedSet.has(f)) d++
|
|
161
|
+
else if (modifiedSet.has(f)) m++
|
|
162
|
+
}
|
|
163
|
+
byModule[id] = { doc: entry.doc || '', added: a, deleted: d, modified: m, files: entry.files }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
base: baseDisplay,
|
|
169
|
+
baseSource,
|
|
170
|
+
head: headShort,
|
|
171
|
+
behindCommits,
|
|
172
|
+
daysSinceScan,
|
|
173
|
+
scope,
|
|
174
|
+
added, deleted, modified, unknown, unmapped, outOfScope,
|
|
175
|
+
renameMap,
|
|
176
|
+
byModule,
|
|
177
|
+
warnings,
|
|
178
|
+
driftCount: added.length + deleted.length + modified.length,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* IO 面:终端渲染 + 可选落盘报告。
|
|
184
|
+
* @param {{ projectRoot: string, specBase: string, projectName: string, base?: string|null,
|
|
185
|
+
* full?: boolean, report?: boolean }} opts
|
|
186
|
+
* full:终端展开每模块全部文件(缺省每模块 ≤5 条);report:落盘 scan-diff-report.md
|
|
187
|
+
* @returns {number} 退出码:0 无漂移 / 1 有漂移 / 2 错误(无效 base、git 失败、无 source_commit)
|
|
188
|
+
*/
|
|
189
|
+
export function runScanDiff(opts = {}) {
|
|
190
|
+
const { projectRoot, specBase, projectName, base = null, full = false, report = false } = opts
|
|
191
|
+
const result = computeScanDiff({ projectRoot, specBase, projectName, base })
|
|
192
|
+
if (!result.ok) {
|
|
193
|
+
console.error(`scan-diff:${result.error}`)
|
|
194
|
+
return 2
|
|
195
|
+
}
|
|
196
|
+
for (const w of result.warnings) console.warn(` ⚠️ ${w}`)
|
|
197
|
+
|
|
198
|
+
console.log(`scan diff: ${result.base}..HEAD(${fmtNum(result.behindCommits)} commit / ${fmtNum(result.daysSinceScan)} 天)`)
|
|
199
|
+
if (result.driftCount === 0) {
|
|
200
|
+
console.log('scan 文档与源码一致(0 漂移)')
|
|
201
|
+
if (result.unknown.length > 0) {
|
|
202
|
+
console.log(` 无法归类(unknown)${result.unknown.length} 条:${result.unknown.join(', ')}`)
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
renderTerminal(result, { full })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (report) {
|
|
209
|
+
const p = writeReport(result, { specBase, projectName })
|
|
210
|
+
if (p) console.log(`scan-diff 报告已落盘:${p}`)
|
|
211
|
+
else console.warn(' ⚠️ scan-diff-report.md 写入失败')
|
|
212
|
+
}
|
|
213
|
+
return result.driftCount > 0 ? 1 : 0
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 终端聚合渲染:每模块计数 + 文件清单(缺省 ≤5 条,--full 展开全部) */
|
|
217
|
+
function renderTerminal(r, { full }) {
|
|
218
|
+
const cap = full ? Number.MAX_SAFE_INTEGER : DEFAULT_LIST_CAP
|
|
219
|
+
console.log(`扫描范围:module-map paths 覆盖集(${r.scope.length} 条路径)——范围外的变更不计入漂移`)
|
|
220
|
+
const moduleIds = Object.keys(r.byModule).sort()
|
|
221
|
+
if (moduleIds.length > 0) {
|
|
222
|
+
console.log('按模块聚合漂移(A=缺文档 D=多文档 M=过时,--full 展开全部):')
|
|
223
|
+
for (const id of moduleIds) {
|
|
224
|
+
const m = r.byModule[id]
|
|
225
|
+
const doc = m.doc ? `(${m.doc})` : ''
|
|
226
|
+
console.log(` [${id}] A${m.added} D${m.deleted} M${m.modified}${doc}`)
|
|
227
|
+
for (const f of m.files.slice(0, cap)) {
|
|
228
|
+
console.log(` - ${statusLetter(r, f)} ${f}${r.renameMap[f] ? `(原 ${r.renameMap[f]})` : ''}`)
|
|
229
|
+
}
|
|
230
|
+
if (m.files.length > cap) console.log(` ... 其余 ${m.files.length - cap} 条,--full 展开`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (r.unmapped.length > 0) {
|
|
234
|
+
console.log(`未归模块(unmapped,${r.unmapped.length} 条):`)
|
|
235
|
+
for (const f of r.unmapped.slice(0, cap)) {
|
|
236
|
+
console.log(` - ${statusLetter(r, f)} ${f}`)
|
|
237
|
+
}
|
|
238
|
+
if (r.unmapped.length > cap) console.log(` ... 其余 ${r.unmapped.length - cap} 条,--full 展开`)
|
|
239
|
+
}
|
|
240
|
+
if (r.unknown.length > 0) {
|
|
241
|
+
console.log(`无法归类(unknown,${r.unknown.length} 条):${r.unknown.slice(0, cap).join(', ')}`)
|
|
242
|
+
}
|
|
243
|
+
console.log(`漂移合计:${r.driftCount} 条(A${r.added.length} / D${r.deleted.length} / M${r.modified.length})`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 文件归属状态字母(渲染用):R(renameMap 命中)> A > D > M */
|
|
247
|
+
function statusLetter(r, f) {
|
|
248
|
+
if (r.renameMap[f]) return 'R'
|
|
249
|
+
if (r.added.includes(f)) return 'A'
|
|
250
|
+
if (r.deleted.includes(f)) return 'D'
|
|
251
|
+
return 'M'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 落盘 scan-diff-report.md(markdown 完整清单,不截断) */
|
|
255
|
+
function writeReport(r, { specBase, projectName }) {
|
|
256
|
+
const dir = join(specBase, 'docs', projectName, 'scan')
|
|
257
|
+
try { mkdirSync(dir, { recursive: true }) } catch { return null }
|
|
258
|
+
const lines = [
|
|
259
|
+
'# scan-diff 报告',
|
|
260
|
+
'',
|
|
261
|
+
`- 生成时间:${new Date().toISOString()}`,
|
|
262
|
+
`- 项目:${projectName}`,
|
|
263
|
+
`- 基线:${r.base}(${r.baseSource})`,
|
|
264
|
+
`- 对比:\`${r.base}..HEAD\``,
|
|
265
|
+
`- 落后:${fmtNum(r.behindCommits)} commit / ${fmtNum(r.daysSinceScan)} 天`,
|
|
266
|
+
`- 扫描范围:module-map paths 覆盖集(${r.scope.length} 条路径)`,
|
|
267
|
+
'',
|
|
268
|
+
'## 漂移总览',
|
|
269
|
+
'',
|
|
270
|
+
'| 分类 | 含义 | 数量 |',
|
|
271
|
+
'| --- | --- | --- |',
|
|
272
|
+
`| added | 源码新增,scan 文档缺记 | ${r.added.length} |`,
|
|
273
|
+
`| deleted | 源码已删,scan 文档多记 | ${r.deleted.length} |`,
|
|
274
|
+
`| modified | 源码已变,scan 文档过时 | ${r.modified.length} |`,
|
|
275
|
+
`| unknown | 其他状态 | ${r.unknown.length} |`,
|
|
276
|
+
`| unmapped | 范围内未匹配模块 | ${r.unmapped.length} |`,
|
|
277
|
+
'',
|
|
278
|
+
'## 按模块聚合',
|
|
279
|
+
'',
|
|
280
|
+
]
|
|
281
|
+
if (Object.keys(r.byModule).length === 0) {
|
|
282
|
+
lines.push('(无模块命中)', '')
|
|
283
|
+
} else {
|
|
284
|
+
for (const id of Object.keys(r.byModule).sort()) {
|
|
285
|
+
const m = r.byModule[id]
|
|
286
|
+
lines.push(`### ${id}(${m.doc || '无 doc'})`, '')
|
|
287
|
+
lines.push(`- added:${m.added}`, `- deleted:${m.deleted}`, `- modified:${m.modified}`, '- 文件:')
|
|
288
|
+
for (const f of m.files) {
|
|
289
|
+
const ren = r.renameMap[f] ? `,原 ${r.renameMap[f]}` : ''
|
|
290
|
+
lines.push(` - \`${f}\`(${statusLetter(r, f)}${ren})`)
|
|
291
|
+
}
|
|
292
|
+
lines.push('')
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (r.unmapped.length > 0) {
|
|
296
|
+
lines.push('## 未归模块(unmapped)', '')
|
|
297
|
+
for (const f of r.unmapped) lines.push(`- \`${f}\``)
|
|
298
|
+
lines.push('')
|
|
299
|
+
}
|
|
300
|
+
if (r.outOfScope.length > 0) {
|
|
301
|
+
lines.push('## 范围外变更(不计入漂移)', '')
|
|
302
|
+
for (const f of r.outOfScope) lines.push(`- \`${f}\``)
|
|
303
|
+
lines.push('')
|
|
304
|
+
}
|
|
305
|
+
if (r.warnings.length > 0) {
|
|
306
|
+
lines.push('## 提示', '')
|
|
307
|
+
for (const w of r.warnings) lines.push(`- ${w}`)
|
|
308
|
+
lines.push('')
|
|
309
|
+
}
|
|
310
|
+
lines.push('## 结论', '')
|
|
311
|
+
lines.push(`- 漂移合计:${r.driftCount} 条(A${r.added.length} / D${r.deleted.length} / M${r.modified.length})`)
|
|
312
|
+
if (r.driftCount === 0) {
|
|
313
|
+
lines.push('- scan 文档与源码一致(0 漂移)')
|
|
314
|
+
} else {
|
|
315
|
+
lines.push('- 存在漂移:请按需核对并刷新对应 scan 文档(sillyspec run scan)')
|
|
316
|
+
}
|
|
317
|
+
lines.push('')
|
|
318
|
+
lines.push('> 由 sillyspec scan-diff 自动生成;改动源码后请按需核对 scan 文档。')
|
|
319
|
+
const outPath = join(dir, 'scan-diff-report.md')
|
|
320
|
+
try {
|
|
321
|
+
writeFileSync(outPath, lines.join('\n') + '\n', 'utf8')
|
|
322
|
+
return outPath
|
|
323
|
+
} catch {
|
|
324
|
+
return null
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 读 scan 目录任一 .md 的 source_commit(同 modules.js 写入同值,取首个命中;全失败 → null) */
|
|
329
|
+
function readSourceCommit(specBase, projectName) {
|
|
330
|
+
const scanDir = join(specBase, 'docs', projectName, 'scan')
|
|
331
|
+
if (!existsSync(scanDir)) return null
|
|
332
|
+
try {
|
|
333
|
+
for (const f of readdirSync(scanDir)) {
|
|
334
|
+
if (!f.endsWith('.md')) continue
|
|
335
|
+
const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
|
|
336
|
+
if (c) return c
|
|
337
|
+
}
|
|
338
|
+
} catch { /* 读失败 → null */ }
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 收集 module-map 全部 paths 去重(归一 Windows 反斜杠 + 去尾部斜杠;无 paths → 空数组) */
|
|
343
|
+
function collectScope(moduleIndex) {
|
|
344
|
+
const set = new Set()
|
|
345
|
+
for (const m of Object.values(moduleIndex || {})) {
|
|
346
|
+
if (!Array.isArray(m.paths)) continue
|
|
347
|
+
for (const raw of m.paths) {
|
|
348
|
+
if (typeof raw !== 'string' || !raw) continue
|
|
349
|
+
set.add(raw.replace(/\\/g, '/').replace(/\/+$/, ''))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return [...set]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 路径是否落在范围(p 可为目录或文件:精确相等或目录前缀) */
|
|
356
|
+
function inScope(filePath, scope) {
|
|
357
|
+
return scope.some((p) => p && (filePath === p || filePath.startsWith(p + '/')))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 解析 `git diff --name-status --find-renames` 输出 → [{ status, path, oldPath? }](R/C 双路径) */
|
|
361
|
+
function parseNameStatus(out) {
|
|
362
|
+
const items = []
|
|
363
|
+
if (!out) return items
|
|
364
|
+
for (const line of out.split('\n')) {
|
|
365
|
+
const l = line.replace(/\r$/, '')
|
|
366
|
+
if (!l) continue
|
|
367
|
+
const tab = l.indexOf('\t')
|
|
368
|
+
if (tab === -1) continue
|
|
369
|
+
const m = l.slice(0, tab).match(/^([A-Z])(\d*)$/)
|
|
370
|
+
if (!m) continue
|
|
371
|
+
const status = m[1]
|
|
372
|
+
const parts = l.slice(tab + 1).split('\t')
|
|
373
|
+
const item = { status, path: parts[parts.length - 1] }
|
|
374
|
+
if ((status === 'R' || status === 'C') && parts.length >= 2) item.oldPath = parts[0]
|
|
375
|
+
items.push(item)
|
|
376
|
+
}
|
|
377
|
+
return items
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 40 位全哈希缩成 7 位显示;引用/短哈希原样 */
|
|
381
|
+
function displayBase(b) {
|
|
382
|
+
return /^[0-9a-f]{40}$/i.test(b) ? b.slice(0, 7) : b
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** null/NaN → '?'(降级显示) */
|
|
386
|
+
function fmtNum(n) {
|
|
387
|
+
return n === null || Number.isNaN(n) ? '?' : String(n)
|
|
388
|
+
}
|
package/src/scan-staleness.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scan-staleness — scan
|
|
2
|
+
* scan-staleness — scan 文档新鲜度提示(债单 D-7 方案 A,change: 2026-08-15 ql-20260815-013;
|
|
3
|
+
* 2026-08-16 ql-20260816-009-fb44 修正判定语义)
|
|
3
4
|
*
|
|
4
5
|
* 原则(doc-consistency-debt 第六节用户裁决):CLI 算事实注入——git 算出 scan 文档落后程度,
|
|
5
6
|
* 注入给 agent 的是一行结论不是一段劝说指令。brainstorm「加载项目上下文」读 scan 文档前
|
|
6
|
-
* 就知道"
|
|
7
|
+
* 就知道"我读的 scan 文档可能没跟上近期结构变更",按提示核对或重扫,而非盲信。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 判定语义(2026-08-16 修正,与第七节「behind 计数不参与判定」对齐):source_commit vs HEAD
|
|
10
|
+
* 的 `rev-list --count` 落后数只是「建议核对/重扫」的提示信号(旧快照可能未收录近期新增模块),
|
|
11
|
+
* 不是「文档失效/失真」的判据——文档引用是否失效由 docs-check(直接信号,src/docs-check.js)
|
|
12
|
+
* 负责,与 docs-gate 原则一致。status 三态 fresh / needs-refresh / unknown:needs-refresh =
|
|
13
|
+
* 该刷新/核对,不是文档判错。
|
|
14
|
+
*
|
|
15
|
+
* 纯读操作(git 只读 + frontmatter 解析),任何失败降级 null/unknown(advisory 信号不阻断流程)。
|
|
11
16
|
*/
|
|
12
17
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
13
18
|
import { join } from 'node:path'
|
|
14
19
|
import { safeGit } from './git-helper.js'
|
|
15
20
|
|
|
16
|
-
/**
|
|
21
|
+
/** 刷新提示阈值(commit 数 / 天数,超过任一即 needs-refresh;advisory 提示允许保守默认) */
|
|
17
22
|
export const STALENESS_THRESHOLDS = { commits: 50, days: 14 }
|
|
18
23
|
|
|
19
24
|
/**
|
|
@@ -31,7 +36,7 @@ export function parseSourceCommit(content) {
|
|
|
31
36
|
/**
|
|
32
37
|
* 计算单个 project 的 scan 文档漂移事实。
|
|
33
38
|
* @param {{ projectRoot: string, specBase: string, projectName: string, thresholds?: { commits: number, days: number } }} opts
|
|
34
|
-
* @returns {{ status: 'fresh'|'
|
|
39
|
+
* @returns {{ status: 'fresh'|'needs-refresh'|'unknown', behindCommits: number|null, daysSinceScan: number|null, sourceCommit: string|null, message: string }|null}
|
|
35
40
|
* null = 无 scan 文档目录(绿地项目等),调用方直接跳过;unknown = 有文档但算不出(无
|
|
36
41
|
* source_commit 字段 / git 失败),message 说明降级原因。
|
|
37
42
|
*/
|
|
@@ -53,7 +58,7 @@ export function computeScanStaleness(opts) {
|
|
|
53
58
|
if (checked === 0) return null
|
|
54
59
|
if (!sourceCommit) {
|
|
55
60
|
return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: null,
|
|
56
|
-
message: 'scan 文档无 source_commit
|
|
61
|
+
message: 'scan 文档无 source_commit 字段(旧版生成),无法算漂移提示——文档引用是否失效以 docs check 为准;如架构已大改建议重跑 sillyspec run scan --standard' }
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
const head = safeGit(projectRoot, ['rev-parse', 'HEAD'])
|
|
@@ -79,12 +84,12 @@ export function computeScanStaleness(opts) {
|
|
|
79
84
|
const stale = behindCommits >= thresholds.commits || (daysSinceScan !== null && daysSinceScan >= thresholds.days)
|
|
80
85
|
if (!stale) {
|
|
81
86
|
return { status: 'fresh', behindCommits, daysSinceScan, sourceCommit,
|
|
82
|
-
message: `scan
|
|
87
|
+
message: `scan 文档无需刷新(落后 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天,低于提示阈值)` }
|
|
83
88
|
}
|
|
84
89
|
return {
|
|
85
|
-
status: '
|
|
86
|
-
message: `⚠️ scan
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
status: 'needs-refresh', behindCommits, daysSinceScan, sourceCommit,
|
|
91
|
+
message: `⚠️ scan 文档已落后源码 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天(基线 ${sourceCommit.slice(0, 7)})。` +
|
|
92
|
+
`落后数≠文档错误——文档引用是否失效由 docs check 判定;旧快照可能未收录近期新增模块/流程,` +
|
|
93
|
+
`涉及结构/模块判断建议核对源码,或跑 sillyspec run scan --standard --force-rescan 刷新基线`,
|
|
89
94
|
}
|
|
90
95
|
}
|
|
@@ -39,7 +39,7 @@ import { hasAllSections, hasNoPlaceholderLine, lineCount, meetsMinLines, missing
|
|
|
39
39
|
import { SCAN_REQUIRED_DOCS } from './constants.js'
|
|
40
40
|
|
|
41
41
|
// custom kind 集合:判定算法留在各 validator(复杂条件),引擎跳过不计入 errors/warnings。
|
|
42
|
-
|
|
42
|
+
const CUSTOM_KINDS = new Set([
|
|
43
43
|
'lifecycle-exemption', // design.md 生命周期关键词触发 + 多层豁免(validateBrainstormOutputs)
|
|
44
44
|
'entry-point-wiring', // design.md 入口实例化 → task allowed_paths 覆盖对账(validatePlanOutputs)
|
|
45
45
|
'id-traceability', // FR/D 引用追踪(extractIds / warnMissingIds)
|
package/src/stage-review.js
CHANGED
|
@@ -21,7 +21,7 @@ import { detectSpecDirTypo } from './spec-dir-typo.js'
|
|
|
21
21
|
import { resolveRuntimeRoot } from './run/shared.js'
|
|
22
22
|
|
|
23
23
|
// 文档型 stage review 的合法 reviewType
|
|
24
|
-
|
|
24
|
+
const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
|
|
25
25
|
|
|
26
26
|
// checklist 每项 result 的合法值
|
|
27
27
|
const CHECKLIST_RESULTS = ['pass', 'gap', 'fail']
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -330,8 +330,8 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
|
|
|
330
330
|
- 长期术语只在 archive/scan 时再提升到 \`{SPEC_ROOT}/docs/<project>/glossary.md\`
|
|
331
331
|
4. 格式自检(只查章节齐全;语义一致性/可行性/YAGNI 不在本步查,交给下一步 Design Grill 独立审查):
|
|
332
332
|
- design.md 含全部必填章节(背景/设计目标/非目标/总体方案/文件变更清单/接口定义/风险登记)
|
|
333
|
-
- design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale
|
|
334
|
-
- design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review
|
|
333
|
+
- design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale 等)——「生成规范文件」步完成契约硬要求,审查前就补齐避免后续 docHash 漂移
|
|
334
|
+
- design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——「生成规范文件」步完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
|
|
335
335
|
- 如存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
|
|
336
336
|
- 涉及 session/lease/agent_run/daemon/lifecycle 等关键词时,是否含「生命周期契约表」
|
|
337
337
|
5. 缺章节 → 补齐后重检;章节齐全 → 进入下一步(Design Grill 做语义层交叉审查)
|