sillyspec 3.32.8 → 3.32.10
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 +2 -2
- package/.claude/skills/sillyspec-execute/SKILL.md +2 -2
- package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +2 -2
- package/AGENTS.md +5 -4
- package/assets/command-cards/flow.md +9 -4
- package/package.json +1 -1
- package/src/change-list.js +13 -0
- package/src/commit-guard.js +70 -2
- package/src/commit-suggest.js +45 -4
- package/src/config-schema.js +14 -0
- package/src/cross-repo-reconcile.js +54 -1
- package/src/decision-distill.js +77 -21
- package/src/decision-renumber.js +157 -0
- package/src/deleted-ledger.js +75 -0
- package/src/design-facts.js +5 -3
- package/src/dispatch/backends/local-agent.js +1 -1
- package/src/dispatch/backends/sillyhub-mcp.js +1 -1
- package/src/docs-check.js +30 -15
- package/src/docs-debt.js +3 -2
- package/src/doctor-diagnostics.js +3 -1
- package/src/flow-draft.js +24 -2
- package/src/flow-parity.js +32 -2
- package/src/flow.js +427 -69
- package/src/fr-index.js +15 -1
- package/src/hooks/worktree-guard.js +3 -3
- package/src/index.js +22 -10
- package/src/knowledge-digest.js +23 -6
- package/src/knowledge-graph.js +92 -14
- package/src/knowledge-match.js +34 -9
- package/src/knowledge-stats.js +12 -13
- package/src/knowledge-vector.js +98 -7
- package/src/machine-interface.js +26 -0
- package/src/progress/change-registry.js +7 -0
- package/src/progress.js +67 -0
- package/src/quicklog.js +6 -1
- package/src/redomain.js +27 -0
- package/src/route-hindsight.js +34 -2
- package/src/run/archive-distill.js +3 -0
- package/src/run/command.js +40 -44
- package/src/run/complete-handlers.js +8 -3
- package/src/run/complete.js +129 -63
- package/src/run/gates.js +94 -90
- package/src/run/prompt.js +13 -3
- package/src/run/shared.js +19 -0
- package/src/run/stage.js +1 -1
- package/src/scope-audit.js +2 -2
- package/src/sentinel-assertions.js +43 -1
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +44 -11
- package/src/stage-templates.js +2 -0
- package/src/stages/brainstorm.js +24 -12
- package/src/stages/execute.js +1 -1
- package/src/stages/explore.js +3 -5
- package/src/stages/knowledge.js +56 -5
- package/src/sync.js +98 -2
- package/src/task-tick.js +12 -0
- package/src/timeline.js +60 -19
- package/src/verify-postcheck.js +83 -6
- package/src/verify-probes.js +61 -18
- package/templates/agents-instruction.md +5 -4
- package/templates/prompts/testcase-design.md +5 -0
- package/src/stages/brainstorm-auto.js +0 -286
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decision-renumber.js — 存量决策号一次性迁移(2026-10-14-decision-legacy-renumber)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:epoch 全局化(2026-10-14-decision-global-id-epoch)落地后新蒸馏条目自动发全局号,但存量
|
|
5
|
+
* 701 条 legacy 局部号节段无跨变更承接机制、不会自然退役。用户裁决一次性迁移(翻「存量不回填」
|
|
6
|
+
* 旧案——fr-index D-007 是设计取舍非技术禁忌,翻案理由随本变更蒸馏留痕)。epoch 读面(双形态解析/
|
|
7
|
+
* 原编号字段/originId 匹配)已就位——迁移产物所有消费方零改动即认识。
|
|
8
|
+
*
|
|
9
|
+
* 纯函数两段(CLI 层 src/stages/knowledge.js case 'renumber' 负责 fs 与原子写):
|
|
10
|
+
* planRenumber(knowledgeRoot)——扫描全部域文件产出迁移计划:
|
|
11
|
+
* · legacy 节段(局部号标题 D-\d+ 且无原编号字段)按「文件字母序 × 节段序」发号
|
|
12
|
+
* D-<域>-NNN(域=所在文件名去 .md;NNN=该域全域既有 epoch 号 max+1 起批内递增);
|
|
13
|
+
* · fanout 组(同变更同号同版本跨文件副本)单一身份——全组共用一号,号域=字母序首文件域;
|
|
14
|
+
* · 无变更行条目不分组(同号无变更 ≠ 同一决策——backend.md 双 D-001@v1 无变更行实证是
|
|
15
|
+
* 两条不同决策),逐节独立发号;
|
|
16
|
+
* · epoch 形态节段(全局号标题或原编号在场)零触碰——幂等判定面。
|
|
17
|
+
* applyRenumberPlan(contentByFile, plan)——按计划重写:标题行旧 id → 新全局号、
|
|
18
|
+
* 「原编号:<旧号>」插入变更行后(无变更行则状态行后,再退标题行后)、其余行逐字节保留、
|
|
19
|
+
* 节段次序不变;joinKnowledgeFile 复用既有归一落盘形态。
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { splitKnowledgeSections, joinKnowledgeFile } from './decision-distill.js'
|
|
24
|
+
|
|
25
|
+
const EPOCH_NUMBER_RE = /^D-[A-Za-z0-9_\-\u4e00-\u9fff]+-\d+$/
|
|
26
|
+
const LOCAL_NUMBER_RE = /^D-\d+$/
|
|
27
|
+
|
|
28
|
+
/** 扫描 decisions 域文件集(files 元素含 splitKnowledgeSections 产物,零写盘) */
|
|
29
|
+
function scanDomainFiles(knowledgeRoot) {
|
|
30
|
+
const decisionsDir = join(knowledgeRoot, 'decisions')
|
|
31
|
+
const files = []
|
|
32
|
+
if (!existsSync(decisionsDir)) return files
|
|
33
|
+
for (const f of readdirSync(decisionsDir).sort()) {
|
|
34
|
+
if (!f.endsWith('.md')) continue
|
|
35
|
+
const content = readFileSync(join(decisionsDir, f), 'utf8')
|
|
36
|
+
const { preamble, sections } = splitKnowledgeSections(content)
|
|
37
|
+
files.push({ file: `decisions/${f}`, domain: f.replace(/\.md$/, ''), content, preamble, sections })
|
|
38
|
+
}
|
|
39
|
+
return files
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 纯函数(只读扫描):产出迁移计划。
|
|
44
|
+
* @param {string} knowledgeRoot knowledge 目录
|
|
45
|
+
* @returns {{ files: Array<{file, domain, content, ops: Array<{secIdx, fromId, toNumber}>}>,
|
|
46
|
+
* stats: { legacySections, fanoutGroups, fanoutCopies, mintedNumbers, noChangeEntries, untouchedEpoch, perDomainStart: object } }}
|
|
47
|
+
* perDomainStart = 各发号域的起始号(既有 epoch max+1);ops 为空的文件也保留(content 供 CLI 指纹校验)
|
|
48
|
+
*/
|
|
49
|
+
export function planRenumber(knowledgeRoot) {
|
|
50
|
+
const files = scanDomainFiles(knowledgeRoot)
|
|
51
|
+
// 各域既有 epoch 号 max(发号起点——不与已发号冲突)
|
|
52
|
+
const domainMax = new Map()
|
|
53
|
+
let untouchedEpoch = 0
|
|
54
|
+
for (const { sections } of files) {
|
|
55
|
+
for (const s of sections) {
|
|
56
|
+
const m = /^D-([A-Za-z0-9_\-\u4e00-\u9fff]+)-(\d+)$/.exec(s.number)
|
|
57
|
+
if (m) domainMax.set(m[1], Math.max(domainMax.get(m[1]) || 0, parseInt(m[2], 10)))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const isLegacy = (s) => LOCAL_NUMBER_RE.test(s.number) && !s.origin && !EPOCH_NUMBER_RE.test(s.number)
|
|
61
|
+
for (const { sections } of files) for (const s of sections) if (!isLegacy(s)) untouchedEpoch++
|
|
62
|
+
// fanout 分组:同变更(非空)同号同版本跨文件 → 单一身份(组号=字母序首文件域——files 已按文件名排序)
|
|
63
|
+
const groupKeyOf = (s) => `${s.change}#${s.number}@v${s.version}`
|
|
64
|
+
const groups = new Map()
|
|
65
|
+
files.forEach((f) => f.sections.forEach((s) => {
|
|
66
|
+
if (!isLegacy(s) || !s.change) return
|
|
67
|
+
const k = groupKeyOf(s)
|
|
68
|
+
if (!groups.has(k)) groups.set(k, { domain: f.domain, members: [] })
|
|
69
|
+
groups.get(k).members.push({ file: f.file, secIdx: f.sections.indexOf(s) })
|
|
70
|
+
}))
|
|
71
|
+
const fanoutGroups = [...groups.values()].filter((g) => g.members.length >= 2)
|
|
72
|
+
const fanoutCopies = fanoutGroups.reduce((a, g) => a + g.members.length, 0)
|
|
73
|
+
// 组号预分配(确定性:组键排序)
|
|
74
|
+
const minted = new Map()
|
|
75
|
+
const nextFor = (domain) => {
|
|
76
|
+
const cur = Math.max(domainMax.get(domain) || 0, minted.get(domain) || 0) + 1
|
|
77
|
+
minted.set(domain, cur)
|
|
78
|
+
return `D-${domain}-${String(cur).padStart(3, '0')}`
|
|
79
|
+
}
|
|
80
|
+
const groupNumber = new Map()
|
|
81
|
+
for (const k of [...groups.keys()].sort()) {
|
|
82
|
+
const g = groups.get(k)
|
|
83
|
+
if (g.members.length >= 2) groupNumber.set(k, nextFor(g.domain))
|
|
84
|
+
}
|
|
85
|
+
// 逐文件逐节段:组员复用组号,其余 legacy 独立发号(域=所在文件域)
|
|
86
|
+
const perFileOps = new Map()
|
|
87
|
+
let legacySections = 0
|
|
88
|
+
let mintedNumbers = 0
|
|
89
|
+
let noChangeEntries = 0
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
const ops = []
|
|
92
|
+
f.sections.forEach((s, si) => {
|
|
93
|
+
if (!isLegacy(s)) return
|
|
94
|
+
legacySections++
|
|
95
|
+
if (!s.change) noChangeEntries++
|
|
96
|
+
const fromId = `${s.number}@v${s.version}`
|
|
97
|
+
let toNumber
|
|
98
|
+
if (s.change && groups.get(groupKeyOf(s))?.members.length >= 2) {
|
|
99
|
+
toNumber = groupNumber.get(groupKeyOf(s))
|
|
100
|
+
} else {
|
|
101
|
+
toNumber = nextFor(f.domain)
|
|
102
|
+
mintedNumbers++
|
|
103
|
+
}
|
|
104
|
+
ops.push({ secIdx: si, fromId, toNumber })
|
|
105
|
+
})
|
|
106
|
+
perFileOps.set(f.file, ops)
|
|
107
|
+
}
|
|
108
|
+
mintedNumbers += groupNumber.size
|
|
109
|
+
const perDomainStart = {}
|
|
110
|
+
for (const d of new Set([...files.map((f) => f.domain), ...domainMax.keys(), ...minted.keys()])) {
|
|
111
|
+
perDomainStart[d] = (domainMax.get(d) || 0) + 1
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
files: files.map((f) => ({ file: f.file, domain: f.domain, content: f.content, ops: perFileOps.get(f.file) || [] })),
|
|
115
|
+
stats: { legacySections, fanoutGroups: fanoutGroups.length, fanoutCopies, mintedNumbers, noChangeEntries, untouchedEpoch, perDomainStart },
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 纯函数(零 fs):按计划重写。contentByFile 为计划时点内容(CLI 层先做指纹校验防他侧写入被覆盖)。
|
|
121
|
+
* @param {Map<string,string>} contentByFile file → 全文
|
|
122
|
+
* @param {object} plan planRenumber 产物
|
|
123
|
+
* @returns {Map<string,string>} 仅有 op 的 file → 重写后全文
|
|
124
|
+
*/
|
|
125
|
+
export function applyRenumberPlan(contentByFile, plan) {
|
|
126
|
+
const out = new Map()
|
|
127
|
+
for (const fe of plan.files) {
|
|
128
|
+
if (!fe.ops || fe.ops.length === 0) continue
|
|
129
|
+
const content = contentByFile.get(fe.file)
|
|
130
|
+
if (content == null) continue
|
|
131
|
+
const { preamble, sections } = splitKnowledgeSections(content)
|
|
132
|
+
const byIdx = new Map(fe.ops.map((o) => [o.secIdx, o]))
|
|
133
|
+
for (let i = 0; i < sections.length; i++) {
|
|
134
|
+
const op = byIdx.get(i)
|
|
135
|
+
if (!op) continue
|
|
136
|
+
const s = sections[i]
|
|
137
|
+
// 标题行:首个旧 id 出现即节头 id(其余行不动)
|
|
138
|
+
s.lines[0] = s.lines[0].replace(op.fromId, `${op.toNumber}@v${s.version}`)
|
|
139
|
+
// 原编号行:变更行后 > 状态行后 > 标题行后(不破「文件:紧跟锚点」行序红线)
|
|
140
|
+
let at = 1
|
|
141
|
+
const vi = s.lines.findIndex((l) => /^变更[::]/.test(l))
|
|
142
|
+
if (vi > -1) at = vi + 1
|
|
143
|
+
else {
|
|
144
|
+
const si = s.lines.findIndex((l) => /^状态[::]/.test(l))
|
|
145
|
+
if (si > -1) at = si + 1
|
|
146
|
+
}
|
|
147
|
+
s.lines.splice(at, 0, `原编号:${op.fromId}`)
|
|
148
|
+
s.id = `${op.toNumber}@v${s.version}`
|
|
149
|
+
s.number = op.toNumber
|
|
150
|
+
s.origin = op.fromId
|
|
151
|
+
}
|
|
152
|
+
out.set(fe.file, joinKnowledgeFile(preamble, sections))
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default { planRenumber, applyRenumberPlan }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 删除台账(deleted-changes.jsonl)——终态转换的追加式账本。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-10-13-new-change-e5a27813 幽灵变更事故,multi-agent-platform 仓实证):
|
|
5
|
+
* X1 墓碑链(sync.js 对 DB status='deleted'/'archived' 行追加墓碑 POST)依赖 DB 行存活;
|
|
6
|
+
* DB 行被外部手段物理清除后(rm + 裸 SQL,任何 sillyspec 官方路径都不物理删行),后续
|
|
7
|
+
* platform sync --change 一律「变更无进度数据」空转,平台行永挂 in_progress/active。
|
|
8
|
+
* 台账让「存在过且已达终态」这一事实脱离 DB 行存活而可查:deleteChange/unregisterChange
|
|
9
|
+
* 在事务提交后追加一行;sync() 的行缺失分支据此合成墓碑补推(见 sync.js 台账分支)。
|
|
10
|
+
*
|
|
11
|
+
* 形态契约:`.sillyspec/.runtime/deleted-changes.jsonl`,每行一个 JSON 对象
|
|
12
|
+
* { name, status: 'deleted'|'archived', at: ISO };追加-only(读取侧 latest-wins),
|
|
13
|
+
* 不旋转不清空——每行百余字节、终态转换频度=归档/删除频度,增长可忽略。
|
|
14
|
+
* 坏行容错:单行 JSON 解析失败跳过该行(外部编辑/半写截断不拖垮读取)。
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const LEDGER_FILENAME = 'deleted-changes.jsonl'
|
|
20
|
+
|
|
21
|
+
function ledgerPath(specDir) {
|
|
22
|
+
return join(specDir, '.runtime', LEDGER_FILENAME)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 追加一条终态台账。fail-open:任何写入失败只 warn 不抛——台账是 DB 行存活时的
|
|
27
|
+
* 冗余信息(X1 链已闭环),它的缺失最多让「行被外部清除」这一非官方场景退回平台
|
|
28
|
+
* 7 天 reparse 兜底,不值得阻断删除/归档主流程。
|
|
29
|
+
* @param {string} specDir 已解析的 .sillyspec 根(与 sillyspec.db 同根)
|
|
30
|
+
* @param {string} changeName 变更名
|
|
31
|
+
* @param {'deleted'|'archived'} status 终态
|
|
32
|
+
* @param {string} [at] ISO 时间戳(缺省当前时间)
|
|
33
|
+
*/
|
|
34
|
+
export function appendDeletedLedger(specDir, changeName, status, at = new Date().toISOString()) {
|
|
35
|
+
if (!specDir || !changeName || (status !== 'deleted' && status !== 'archived')) {
|
|
36
|
+
console.warn(`⚠️ appendDeletedLedger: 参数不完整(name=${changeName} status=${status}),跳过台账追加`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
appendFileSync(ledgerPath(specDir), JSON.stringify({ name: changeName, status, at }) + '\n', 'utf8')
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn(`⚠️ 删除台账追加失败(fail-open 不阻断主流程): ${changeName}: ${(err && err.message) || err}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 查询变更的最新终态台账条目(同名多条取最后一条——latest-wins)。
|
|
48
|
+
* 容错:文件不存在 → null;坏行跳过不抛。
|
|
49
|
+
* @param {string} specDir 已解析的 .sillyspec 根
|
|
50
|
+
* @param {string} changeName 变更名
|
|
51
|
+
* @returns {{name: string, status: string, at: string} | null}
|
|
52
|
+
*/
|
|
53
|
+
export function lookupDeletedLedger(specDir, changeName) {
|
|
54
|
+
if (!specDir || !changeName) return null
|
|
55
|
+
const p = ledgerPath(specDir)
|
|
56
|
+
if (!existsSync(p)) return null
|
|
57
|
+
let text
|
|
58
|
+
try {
|
|
59
|
+
text = readFileSync(p, 'utf8')
|
|
60
|
+
} catch {
|
|
61
|
+
return null // 读失败按无台账处理(消费侧退回普通「无进度数据」路径)
|
|
62
|
+
}
|
|
63
|
+
let hit = null
|
|
64
|
+
for (const line of text.split('\n')) {
|
|
65
|
+
const trimmed = line.trim()
|
|
66
|
+
if (!trimmed) continue
|
|
67
|
+
try {
|
|
68
|
+
const obj = JSON.parse(trimmed)
|
|
69
|
+
if (obj && obj.name === changeName && (obj.status === 'deleted' || obj.status === 'archived')) {
|
|
70
|
+
hit = { name: obj.name, status: obj.status, at: obj.at }
|
|
71
|
+
}
|
|
72
|
+
} catch { /* 坏行跳过:半写截断/外部编辑不拖垮整本台账 */ }
|
|
73
|
+
}
|
|
74
|
+
return hit
|
|
75
|
+
}
|
package/src/design-facts.js
CHANGED
|
@@ -296,8 +296,10 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
296
296
|
//「骨架优先,仅手写补文档才手填元数据」的依据(validateMetadata 未来可据此只认 CLI 戳)
|
|
297
297
|
lines.push(`generated_by: sillyspec-design-init`)
|
|
298
298
|
// scale 不预填(2026-09-29-brainstorm-exit-thin-default):此前预填 large 是静默厚默认——
|
|
299
|
-
// 不主动改就是五阶段;现由 brainstorm Step 8 规模评估按复杂度/上下文轴落值,拿不准默认 small
|
|
300
|
-
|
|
299
|
+
// 不主动改就是五阶段;现由 brainstorm Step 8 规模评估按复杂度/上下文轴落值,拿不准默认 small。
|
|
300
|
+
// 反判据口径(2026-10-14-scale-large-evidence):跨端/多仓/顺序依赖不算 Wave 证据,判 large
|
|
301
|
+
// 须写明「可真并行任务簇+单上下文吃不下」两条同时成立——deploy-release-notes 镜像误判实证
|
|
302
|
+
lines.push('scale: "" # TODO:Step 8 规模评估落值——单上下文可吞吐=small(flow start 收编);需 Wave 真并行编排/上下文分片/多阶段治理=large(run plan,须写明 Wave 证据:可真并行任务簇+单上下文吃不下;跨端/多仓/顺序依赖不算);拿不准 small')
|
|
301
303
|
lines.push('---')
|
|
302
304
|
lines.push('')
|
|
303
305
|
lines.push(`# 设计文档(Design)— ${change}`)
|
|
@@ -385,7 +387,7 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
385
387
|
lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
|
|
386
388
|
lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
|
|
387
389
|
lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
|
|
388
|
-
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html
|
|
390
|
+
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记)')
|
|
389
391
|
lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
|
|
390
392
|
lines.push('')
|
|
391
393
|
|
|
@@ -95,7 +95,7 @@ export function renderLocalInstruction(contract) {
|
|
|
95
95
|
|
|
96
96
|
const modeLine = readOnly
|
|
97
97
|
? '5. **只读模式(readOnly=true)**:本子代理只读 / 审查 / 写 review.json,**不改源码**'
|
|
98
|
-
: '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 →
|
|
98
|
+
: '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 → 写实现,按层适用——纯逻辑严格先行、行为契约断用户所见、样式/纯配置不写,判据见「测试用例设计」)'
|
|
99
99
|
|
|
100
100
|
return `### 派发后端:Local(本机 Agent tool)— 默认与降级路径
|
|
101
101
|
|
|
@@ -218,7 +218,7 @@ dispatch_worker 的 \`worker_prompt\` 参数必须传以下覆写文本(路径
|
|
|
218
218
|
|
|
219
219
|
\`\`\`
|
|
220
220
|
你是 SillySpec execute 的 worker,在指定 worktree 干活。铁律:
|
|
221
|
-
1. 按 objective + 蓝图(tasks/task-XX.md / design.md)实现任务(TDD:先读后写 → 写测试 →
|
|
221
|
+
1. 按 objective + 蓝图(tasks/task-XX.md / design.md)实现任务(TDD:先读后写 → 写测试 → 写实现,按层适用——纯逻辑严格先行、行为契约断用户所见、样式/纯配置不写,判据见「测试用例设计」)
|
|
222
222
|
2. **绝不执行 git add / git commit / git push**:改动留在工作区(staged 或 unstaged 都行),
|
|
223
223
|
交 SillySpec 主体 git diff 回收(D-004)
|
|
224
224
|
3. 不超出 allowedPaths,不碰其他 task 的文件
|
package/src/docs-check.js
CHANGED
|
@@ -1038,12 +1038,20 @@ export function readDecisionRulesConfig(projectRoot) {
|
|
|
1038
1038
|
}
|
|
1039
1039
|
}
|
|
1040
1040
|
|
|
1041
|
-
/**
|
|
1042
|
-
*
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1041
|
+
/** 豁免键匹配(键匹配非子串):域限定键 `decisions.<域>/<id>.<kind>`(规则级精确)与
|
|
1042
|
+
* `decisions.<域>/<id>`(条目级伞形)——2026-10-14-decision-id-p2-hardening:裸键无域限定,
|
|
1043
|
+
* 一条 decisions.<id> 可静默豁免全库同号条目(D-001@v1 ×296 潜伏面),域限定键收窄命中面;
|
|
1044
|
+
* 裸键 `decisions.<id>[.<kind>]` 保留兼容(消费仓既有配置不失效)。id 与 originId(原编号,
|
|
1045
|
+
* 2026-10-14-decision-legacy-renumber)双匹配——存量迁移后旧号键继续命中。
|
|
1046
|
+
* 无 decisions. 前缀的键不参与。导出(测试面先例 anchorFilePath)。 */
|
|
1047
|
+
export function isDecisionExempt(knownFailures, entryId, kind, domain = null, originId = null) {
|
|
1048
|
+
const ids = originId ? [entryId, originId] : [entryId]
|
|
1049
|
+
const keys = []
|
|
1050
|
+
for (const id of ids) {
|
|
1051
|
+
keys.push(`decisions.${id}.${kind}`, `decisions.${id}`)
|
|
1052
|
+
if (domain) keys.push(`decisions.${domain}/${id}.${kind}`, `decisions.${domain}/${id}`)
|
|
1053
|
+
}
|
|
1054
|
+
return knownFailures.some(k => keys.includes(k))
|
|
1047
1055
|
}
|
|
1048
1056
|
|
|
1049
1057
|
/**
|
|
@@ -1051,25 +1059,32 @@ function isDecisionExempt(knownFailures, entryId, kind) {
|
|
|
1051
1059
|
* 的写入契约对偶:producer=decision-distill → consumer=本规则族;字段行非列表项,顶层
|
|
1052
1060
|
* 「状态:/锚点:/最近确认:」标签精确匹配,防误吞正文)。旧格式字段缺失容错为 null。
|
|
1053
1061
|
* @param {string} md 域文件全文
|
|
1054
|
-
* @returns {Array<{ id: string, title: string, status: string|null, anchor: string|null, lastConfirmed: string|null }>}
|
|
1062
|
+
* @returns {Array<{ id: string, title: string, status: string|null, anchor: string|null, lastConfirmed: string|null, change: string|null }>}
|
|
1055
1063
|
*/
|
|
1056
1064
|
export function parseDecisionEntries(md) {
|
|
1057
1065
|
const entries = []
|
|
1058
1066
|
let cur = null
|
|
1059
1067
|
const flush = () => { if (cur) entries.push(cur) }
|
|
1060
1068
|
for (const line of String(md || '').replace(/\r\n?/g, '\n').split('\n')) {
|
|
1061
|
-
const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
|
|
1069
|
+
const h = line.match(/^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)(?:@v(\d+))?\s*(.*)$/)
|
|
1062
1070
|
if (h) {
|
|
1063
1071
|
flush()
|
|
1064
1072
|
const version = h[2] ? parseInt(h[2], 10) : 1
|
|
1065
|
-
cur = { id: `${h[1]}@v${version}`, title: h[3].trim(), status: null, anchor: null, lastConfirmed: null }
|
|
1073
|
+
cur = { id: `${h[1]}@v${version}`, title: h[3].trim(), status: null, anchor: null, lastConfirmed: null, change: null, originId: null }
|
|
1066
1074
|
continue
|
|
1067
1075
|
}
|
|
1068
1076
|
if (!cur) continue
|
|
1069
|
-
|
|
1077
|
+
// 变更/来源/原编号:标签读入(变更:2026-10-14-supersedes-mislink-and-change-display 加法字段——
|
|
1078
|
+
// docs-debt 触碰事实行渲染变更名限定词消歧需读入;来源:2026-10-14-decision-id-p2-hardening legacy
|
|
1079
|
+
// 兜底——变更行无条件覆盖在前、来源仅补缺;原编号:2026-10-14-decision-legacy-renumber——存量迁移
|
|
1080
|
+
// 后条目 id 为全局号,消费仓旧豁免键(decisions.D-002@vN)经原编号继续命中;旧条目缺行容错 null)
|
|
1081
|
+
const f = line.match(/^(状态|锚点|最近确认|变更|来源|原编号)\s*[::]\s*(.*)$/)
|
|
1070
1082
|
if (!f) continue
|
|
1071
1083
|
if (f[1] === '状态') cur.status = f[2].trim().split(/[\s((]/)[0] || null
|
|
1072
1084
|
else if (f[1] === '锚点') cur.anchor = f[2].trim()
|
|
1085
|
+
else if (f[1] === '变更') cur.change = f[2].trim() || null
|
|
1086
|
+
else if (f[1] === '来源') cur.change = cur.change || (f[2].trim() || null)
|
|
1087
|
+
else if (f[1] === '原编号') cur.originId = f[2].trim() || null
|
|
1073
1088
|
else cur.lastConfirmed = f[2].trim()
|
|
1074
1089
|
}
|
|
1075
1090
|
flush()
|
|
@@ -1148,8 +1163,8 @@ export async function runDecisionRules(opts) {
|
|
|
1148
1163
|
let implementedTotal = 0
|
|
1149
1164
|
const treeCache = new Map() // resolveCandidates 全树扫描复用(同 docs check 主链路口径)
|
|
1150
1165
|
|
|
1151
|
-
const emit = (id, domain, file, kind, extra, message) => {
|
|
1152
|
-
if (isDecisionExempt(failures, id, kind)) {
|
|
1166
|
+
const emit = (id, domain, file, kind, extra, message, originId = null) => {
|
|
1167
|
+
if (isDecisionExempt(failures, id, kind, domain, originId)) {
|
|
1153
1168
|
exempted.push({ key: `decisions.${id}.${kind}`, id, kind })
|
|
1154
1169
|
return
|
|
1155
1170
|
}
|
|
@@ -1169,10 +1184,10 @@ export async function runDecisionRules(opts) {
|
|
|
1169
1184
|
// 规则 1:锚点存在性(未记录 → 补录提示;路径不存在 → 失效提示)
|
|
1170
1185
|
if (!anchorPath) {
|
|
1171
1186
|
emit(e.id, domain, rel, 'anchor', { anchor: e.anchor || null },
|
|
1172
|
-
`「${e.id}」锚点未记录(${rel})——旧格式条目,补「锚点:src/…」后可机械校验(advisory
|
|
1187
|
+
`「${e.id}」锚点未记录(${rel})——旧格式条目,补「锚点:src/…」后可机械校验(advisory)`, e.originId)
|
|
1173
1188
|
} else if (resolveCandidates(projectRoot, anchorPath, treeCache).length === 0) {
|
|
1174
1189
|
emit(e.id, domain, rel, 'anchor', { anchor: e.anchor },
|
|
1175
|
-
`「${e.id}」锚点文件不存在:${anchorPath}(${rel})——决策落点已漂移,人工复核(advisory
|
|
1190
|
+
`「${e.id}」锚点文件不存在:${anchorPath}(${rel})——决策落点已漂移,人工复核(advisory)`, e.originId)
|
|
1176
1191
|
}
|
|
1177
1192
|
// 规则 2:behind 复核(「最近确认」后锚定模块源码前进数超阈值 → 决策待复核;
|
|
1178
1193
|
// 「最近确认」未记录/域无源码集(无 map 无锚点)→ 无法计算,静默跳过——advisory 不产噪声)
|
|
@@ -1185,7 +1200,7 @@ export async function runDecisionRules(opts) {
|
|
|
1185
1200
|
const { behind } = computeModuleBehind(domain, e.lastConfirmed, { projectRoot, srcPaths })
|
|
1186
1201
|
if (behind !== null && behind > threshold) {
|
|
1187
1202
|
emit(e.id, domain, rel, 'behind', { behind, threshold },
|
|
1188
|
-
`「${e.id}」决策待复核:锚定模块 ${domain} 在最近确认 ${e.lastConfirmed} 后源码已前进 ${behind} commit,超阈值 ${threshold}(${rel}
|
|
1203
|
+
`「${e.id}」决策待复核:锚定模块 ${domain} 在最近确认 ${e.lastConfirmed} 后源码已前进 ${behind} commit,超阈值 ${threshold}(${rel})——复核后更新「最近确认」`, e.originId)
|
|
1189
1204
|
}
|
|
1190
1205
|
}
|
|
1191
1206
|
}
|
package/src/docs-debt.js
CHANGED
|
@@ -15,6 +15,7 @@ import { join, relative } from 'node:path'
|
|
|
15
15
|
import { safeGit } from './git-helper.js'
|
|
16
16
|
import { parseModuleMapSimple } from './modules.js'
|
|
17
17
|
import { runDocsCheck, readDocsCheckConfig, parseDecisionEntries, anchorFilePath } from './docs-check.js'
|
|
18
|
+
import { decisionChangeQualifier } from './knowledge-match.js'
|
|
18
19
|
|
|
19
20
|
/** safeGit 超时(大仓 git log 慢时降级该模块,不挂起 Wave 渲染) */
|
|
20
21
|
const GIT_TIMEOUT_MS = 5000
|
|
@@ -323,7 +324,7 @@ export function computeDecisionTouches(changedFiles, knowledgeRoot) {
|
|
|
323
324
|
for (const c of changed) {
|
|
324
325
|
// 前缀匹配粒度 = 文件或其子路径(R-03:锚点是文件级非目录级,防同前缀不同文件误报)
|
|
325
326
|
if (c === anchorPath || c.startsWith(anchorPath + '/')) {
|
|
326
|
-
touches.push({ id: e.id, title: e.title, anchorFile: anchorPath, touchedFile: c, file })
|
|
327
|
+
touches.push({ id: e.id, title: e.title, anchorFile: anchorPath, touchedFile: c, file, change: e.change || '' })
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
}
|
|
@@ -344,7 +345,7 @@ export function renderDecisionTouchFacts(touches) {
|
|
|
344
345
|
const shown = touches.slice(0, DECISION_TOUCH_RENDER_LIMIT)
|
|
345
346
|
const lines = [`[decision-touch] 本次变更触碰 ${touches.length} 条决策锚点(改动可能使该决策需复核):`]
|
|
346
347
|
for (const t of shown) {
|
|
347
|
-
lines.push(` - ${t.id}(${t.title || '无标题'},锚点 ${t.anchorFile})← 触碰文件 ${t.touchedFile}(${t.file})`)
|
|
348
|
+
lines.push(` - ${t.id}(${t.title || '无标题'},锚点 ${t.anchorFile})← 触碰文件 ${t.touchedFile}(${t.file}|${decisionChangeQualifier(t.change)})`)
|
|
348
349
|
}
|
|
349
350
|
if (touches.length > DECISION_TOUCH_RENDER_LIMIT) {
|
|
350
351
|
lines.push(` …另有 ${touches.length - DECISION_TOUCH_RENDER_LIMIT} 条`)
|
|
@@ -946,7 +946,9 @@ function detectDecisionDrift(cwd) {
|
|
|
946
946
|
let count = 0
|
|
947
947
|
for (const f of readdirSync(decDir)) {
|
|
948
948
|
if (!f.endsWith('.md')) continue
|
|
949
|
-
|
|
949
|
+
// 双形态计数(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1;
|
|
950
|
+
// 旧正则 @\d+ 漏 @v 前缀恒零——随本单修正
|
|
951
|
+
count += (readFileSync(join(decDir, f), 'utf8').match(/^## D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+/gm) || []).length
|
|
950
952
|
}
|
|
951
953
|
return { ...base, findings: [`decisions 库 ${count} 条(behind 精确复核在 verify evidence-auto 与 docs-check 决策规则族)`], pass: true, severity: CHECK_SEVERITY.PASSED }
|
|
952
954
|
} catch (e) {
|
package/src/flow-draft.js
CHANGED
|
@@ -156,6 +156,11 @@ export function extractSuccessCriteria(input, opts = {}) {
|
|
|
156
156
|
const lines = []
|
|
157
157
|
for (const l of rawLines) {
|
|
158
158
|
const prev = lines[lines.length - 1]
|
|
159
|
+
// 节标题合并守卫(2026-10-13-extract-head-guard 实证:动机行尾未闭合左括号 → 续行合并把
|
|
160
|
+
// 「成功标准:」并入动机、合并行以冒号收尾继续触发合并雪崩吞并全部条目行、提取 0 条。
|
|
161
|
+
// 节标题是结构边界——即使 prev 残缺也无条件独立成行。守卫只判当前行属性,不改
|
|
162
|
+
// needsContinuationMerge 本体(其括号判定是 R17 括号跨行并回的承重面)。
|
|
163
|
+
if (SECTION_HEAD_RE.test(l)) { lines.push(l); continue }
|
|
159
164
|
if (prev != null && needsContinuationMerge(prev)) lines[lines.length - 1] = prev + l
|
|
160
165
|
else lines.push(l)
|
|
161
166
|
}
|
|
@@ -342,12 +347,29 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
|
|
|
342
347
|
let dLines = dText.split('\n')
|
|
343
348
|
// 回填通道:改写先到不了归档(回填后进归档的就是锚文本,与人工恢复等价),写盘失败降级
|
|
344
349
|
// 回带全文的拒收文案(不抛异常阻断 CLI)。
|
|
350
|
+
// 问句在场判定(坑 flow-done-design-question-line-injection,2026-10-13 实证):回答行
|
|
351
|
+
// 前缀形态「N. <问句>:<答>」/「- <问句>:…」——问句锚保留在行内时视为在场,不注入
|
|
352
|
+
// 不拒收。此前只认整行相等:锚折进回答行 → 相似度低于阈值 → 四条全走节首兜底插入,
|
|
353
|
+
// 同位反复 splice 产出倒序重复问句块。改写形态(行内无锚原文)仍走既有回填/拒收链。
|
|
354
|
+
const questionPresent = (lines, q) => lines.some((raw) => {
|
|
355
|
+
const t = String(raw || '').trim()
|
|
356
|
+
if (!t || t === q) return t === q
|
|
357
|
+
// 包含判定只对自带编号的锚生效(四问节「N. 问句」形态——回答行「N. 问句:答」折叠
|
|
358
|
+
// 是惯例书写面,注入即倒序重复块,坑 flow-done-design-question-line-injection)。
|
|
359
|
+
// 无编号锚(做法/契约/风险节单行问句)不做包含判定:⑦c 契约要求引用原文的作答行
|
|
360
|
+
// 仍注入锚在引用行上方(锚独立成行是审计面)——包含跳过会吃掉该注入。
|
|
361
|
+
if (!/^\d+[.、)]\s/.test(q)) return false
|
|
362
|
+
// 只剥一层编号/列表前缀(锚自带「N.」编号,多层剥会把锚的编号也吃掉致包含
|
|
363
|
+
// 判定假阴性——① 用例实证)
|
|
364
|
+
const stripped = t.replace(/^(?:\d+[.、)]\s*|[-*•]\s*)/, '')
|
|
365
|
+
return stripped.includes(q)
|
|
366
|
+
})
|
|
345
367
|
const allQuestionLines = new Set(anchorSections.flatMap((s) => s.lines))
|
|
346
368
|
const repairedLines = [...dLines]
|
|
347
369
|
const repairEntries = []
|
|
348
370
|
for (const sec of anchorSections) {
|
|
349
371
|
for (const q of sec.lines) {
|
|
350
|
-
if (repairedLines
|
|
372
|
+
if (questionPresent(repairedLines, q)) continue
|
|
351
373
|
const hIdx = repairedLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
|
|
352
374
|
if (hIdx === -1) continue // 节整删 → 下方校验环拒收(文案带全文)
|
|
353
375
|
let end = repairedLines.findIndex((l, i) => i > hIdx && /^##\s/.test(l))
|
|
@@ -383,7 +405,7 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
|
|
|
383
405
|
continue
|
|
384
406
|
}
|
|
385
407
|
for (const q of sec.lines) {
|
|
386
|
-
if (!dLines
|
|
408
|
+
if (!questionPresent(dLines, q)) violations.push(`design「${sec.heading}」问题文本被改写——原文:${q}(问题钉死,答案写在下方;折进回答行前缀亦算在场)`)
|
|
387
409
|
}
|
|
388
410
|
// 实质作答:节内非空行 - 标题行 - 问题行 - 引导行(> 开头)≥1
|
|
389
411
|
const start = dLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
|
package/src/flow-parity.js
CHANGED
|
@@ -26,10 +26,12 @@ import { splitOwnVsForeignDiffFiles, commitAttributionForChange, isForeignByComm
|
|
|
26
26
|
import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
|
|
27
27
|
import { computeDocsDebt } from './docs-debt.js'
|
|
28
28
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
29
|
+
import { normalizePath } from './change-list.js'
|
|
29
30
|
import { readV2SectionAnswer } from './flow-review.js'
|
|
30
31
|
import { DESIGN_QUESTIONS } from './flow-draft.js'
|
|
31
32
|
import { pathMatches } from './change-list.js'
|
|
32
33
|
import { classifyToolScaffold, filterDeliverableFiles } from './worktree-apply.js'
|
|
34
|
+
import { deriveChangeTitle } from './quicklog.js'
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* 模块文档对账(advisory)+ 结构化落盘面(2026-09-27-thin-module-scope-persist)。
|
|
@@ -132,6 +134,19 @@ export function evaluateModuleGate({ moduleScope, ignore } = {}) {
|
|
|
132
134
|
return { block: maps.length > 0 && dirs.length > 0, dirs, maps }
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* ⑥-0 motiveFromInput:模块变更索引摘要提取(2026-10-15-title-label-skip)。与变更标题
|
|
139
|
+
* 同源走 deriveChangeTitle——此前 flow.js 裸取 --input 首行,「动机与背景:」段头行整行
|
|
140
|
+
* 入摘要(stages.changelog 实证)。段头行跳过/标签剥离随 deriveChangeTitle 单源;提取为
|
|
141
|
+
* 空回退变更名(可辨识不达意优于空串;appendChangeIndexEntries 侧另有 '轻量变更' 二级兜底)。
|
|
142
|
+
* @returns {string} 摘要(≤50 字)
|
|
143
|
+
*/
|
|
144
|
+
export function motiveFromInput(input, change = '') {
|
|
145
|
+
let derived = ''
|
|
146
|
+
try { derived = deriveChangeTitle(input) } catch { derived = '' }
|
|
147
|
+
return derived || String(change || '')
|
|
148
|
+
}
|
|
149
|
+
|
|
135
150
|
/**
|
|
136
151
|
* ⑥ appendChangeIndexEntries:命中模块变更索引机械回写(二档)。零语义判断——日期/变更名/
|
|
137
152
|
* 动机首行全是已知量,与 decision-distill 降 noAI 同性质。目标判定遵守 split-changelog 契约
|
|
@@ -308,7 +323,9 @@ export function harvestSlot4Decision({ changeDir, change }) {
|
|
|
308
323
|
'---',
|
|
309
324
|
`# 决策记录(Decisions)— ${change}`,
|
|
310
325
|
'',
|
|
311
|
-
|
|
326
|
+
// 标题自解释(2026-10-15-harvest-title-clarify):读者面是 decisions 文档与 knowledge,
|
|
327
|
+
// 机制出处(槽4 收割)由 frontmatter author: flow-machine-draft 承载,标题只说内容与性质。
|
|
328
|
+
`## D-001@v1: 设计期风险与放弃方案(底稿)`,
|
|
312
329
|
`- 类型:process`,
|
|
313
330
|
`- 状态:confirmed`,
|
|
314
331
|
// 类型/状态字段(2026-09-26-slot4-distill-fix):蒸馏链入选要 status∈{confirmed|accepted|rejected}
|
|
@@ -325,7 +342,7 @@ export function harvestSlot4Decision({ changeDir, change }) {
|
|
|
325
342
|
return { harvested: true }
|
|
326
343
|
}
|
|
327
344
|
|
|
328
|
-
export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary }
|
|
345
|
+
export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary, motiveFromInput }
|
|
329
346
|
|
|
330
347
|
/**
|
|
331
348
|
* patch 冻结面的提交面过滤(2026-09-27-tool-debt-cleanup 抽出为纯函数供单测锁定):
|
|
@@ -563,6 +580,19 @@ export function buildThinSnapshotRows({ ownFiles, stats, planEntries, crossRepoR
|
|
|
563
580
|
// + pathMatches 双判——对账行是真实三态或 degraded ⊘,覆盖即不再补恒 ⊘ 行);对账行缺失
|
|
564
581
|
// (fail-soft 退路/降级仓无行)→ 照旧补 ⊘ 行
|
|
565
582
|
if (e.repo && crossRows.some((r) => r.crossRepo === e.repo && pathMatches(String(r.path || ''), e.path))) continue
|
|
583
|
+
// 兜底合并(2026-10-13-declared-paren-strip):declared 路径剥括号残片(normalizePath,
|
|
584
|
+
// 与解析端同源)后与实际行精确等值 → 不补占位行;实际行归因升 planned(取更准一行),
|
|
585
|
+
// totals 按唯一路径计。防他源污染形态致同文件双行/文件数虚高(缺陷档案实证形态的兜底层)。
|
|
586
|
+
const clean = normalizePath(String(e.path || ''))
|
|
587
|
+
const hitRow = clean ? rows.find((r) => r.path === clean && !r.crossRepo) : null
|
|
588
|
+
if (hitRow) {
|
|
589
|
+
matched.add(e.path)
|
|
590
|
+
if (hitRow.verdict !== 'planned') {
|
|
591
|
+
hitRow.planned = e.operation || null
|
|
592
|
+
hitRow.verdict = 'planned'
|
|
593
|
+
}
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
566
596
|
rows.push({
|
|
567
597
|
path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
|
|
568
598
|
kind: 'modified', verdict: 'untouched',
|