sillyspec 3.29.5 → 3.31.0
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-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-brainstorm.md +1 -1
- package/assets/command-cards/run-quick.md +8 -25
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +18 -5
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +2 -1
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +570 -16
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +107 -5
- package/src/mcp-server.js +1 -0
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +123 -10
- package/src/run/complete-handlers.js +327 -72
- package/src/run/complete.js +350 -71
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +250 -34
- package/src/run/gates.js +370 -233
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +185 -12
- package/src/run/shared.js +120 -3
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +221 -84
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +352 -1
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +860 -158
- package/src/verify-probes.js +59 -3
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
|
@@ -101,7 +101,7 @@ function resolveKeywords(keywords, title) {
|
|
|
101
101
|
* @returns {{ start: number, end: number, headingText: string, qlIdPrefix: string|null,
|
|
102
102
|
* title: string, body: string, text: string }[]}
|
|
103
103
|
*/
|
|
104
|
-
function parseUncategorizedEntries(normalizedContent) {
|
|
104
|
+
export function parseUncategorizedEntries(normalizedContent) {
|
|
105
105
|
const lines = normalizedContent.split('\n')
|
|
106
106
|
const boundaryRe = /^#{1,3}\s+\S/
|
|
107
107
|
const entryHeadingRe = /^#{2,3}\s+\S/
|
|
@@ -380,3 +380,49 @@ export async function cmdKnowledgeClassify(dir, args, opts = {}) {
|
|
|
380
380
|
}, result.error)
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* `sillyspec knowledge inbox` 子命令——知识收件箱按需视图(2026-09-23 知识可见性 quick)。
|
|
386
|
+
*
|
|
387
|
+
* 背景:uncategorized 待审条目与 quick 资产尾蒸馏产出此前只落文件、不在聊天面露头,
|
|
388
|
+
* 用户全程无感(2026-09-23 用户反馈「聊天过程中完全不知道」)。收件箱给出待审清单
|
|
389
|
+
* (标题 + ql 前缀 + 一行摘要)与基线状态;--json 结构化供面板/脚本消费。纯读零副作用。
|
|
390
|
+
*/
|
|
391
|
+
export async function cmdKnowledgeInbox(dir, args, opts = {}) {
|
|
392
|
+
const base = opts.specDir || join(dir, '.sillyspec')
|
|
393
|
+
const knowledgeDir = join(base, 'knowledge')
|
|
394
|
+
const wantJson = args.includes('--json')
|
|
395
|
+
const uncPath = join(knowledgeDir, 'uncategorized.md')
|
|
396
|
+
let entries = []
|
|
397
|
+
if (existsSync(uncPath)) {
|
|
398
|
+
entries = parseUncategorizedEntries(readFileSync(uncPath, 'utf8').replace(/\r\n/g, '\n'))
|
|
399
|
+
}
|
|
400
|
+
let baseline = null
|
|
401
|
+
try {
|
|
402
|
+
const bp = join(base, 'knowledge-baseline')
|
|
403
|
+
if (existsSync(bp)) {
|
|
404
|
+
const n = parseInt(readFileSync(bp, 'utf8').trim(), 10)
|
|
405
|
+
if (Number.isFinite(n) && n >= 0) baseline = n
|
|
406
|
+
}
|
|
407
|
+
} catch { /* 基线读失败按未启用 */ }
|
|
408
|
+
const over = baseline !== null && entries.length > baseline
|
|
409
|
+
const items = entries.map((e) => ({
|
|
410
|
+
title: e.title,
|
|
411
|
+
qlId: e.qlIdPrefix || null,
|
|
412
|
+
summary: String(e.body || '').replace(/\s+/g, ' ').slice(0, 80),
|
|
413
|
+
}))
|
|
414
|
+
if (wantJson) {
|
|
415
|
+
output(true, { inbox: { count: entries.length, baseline, over, items } })
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
if (entries.length === 0) {
|
|
419
|
+
console.log('📚 知识收件箱:空——uncategorized 无待审条目')
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
console.log(`📚 知识收件箱:待审 ${entries.length} 条${baseline !== null ? `(基线 ${baseline}${over ? ',已超 ⚠️' : ''})` : ''}`)
|
|
423
|
+
items.forEach((it, i) => {
|
|
424
|
+
console.log(` ${i + 1}. 《${it.title}》${it.qlId ? `(${it.qlId})` : ''}`)
|
|
425
|
+
if (it.summary) console.log(` ${it.summary}`)
|
|
426
|
+
})
|
|
427
|
+
console.log(' 归类:sillyspec knowledge classify --title "…" --file conventions|patterns|known-issues.md')
|
|
428
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-digest.js — 知识资产治理信号收集器(2026-09-27-knowledge-digest)
|
|
3
|
+
*
|
|
4
|
+
* 现状:通用知识走 propose→平台人工合并闭环,规格资产(FR 索引/绑定)全自动入库零人审——
|
|
5
|
+
* 四类信号沉睡库内无人工出口(R23-full 载体仓收件箱积压 40 条实证)。本模块把沉睡数据
|
|
6
|
+
* 变结构化摘要:`sillyspec knowledge digest`(人读文本)/ `--json`(平台 RPC 消费)。
|
|
7
|
+
*
|
|
8
|
+
* 四信号(阈值内静默、超阈才进摘要——安静即健康态,防仪式化):
|
|
9
|
+
* ① rot 待复核标记:knowledge/fr/*.md 条目内「待复核:」行,按域计数(阈值 100)
|
|
10
|
+
* ② 收件箱积压:knowledge/uncategorized.md 的 `## <qlId> | <标题>` 条目(阈值 20)
|
|
11
|
+
* ③ 伪域落库:auto-* 域文件条目数 + unmapped 池条目数(阈值 0——伪域本不该增长)
|
|
12
|
+
* ④ 绑定路径解析失败:fr/*.md 绑定行经 resolveTestFileRel(与 repair-paths/门禁读侧
|
|
13
|
+
* 同口径单源)无法自仓根解析(阈值 0)
|
|
14
|
+
*
|
|
15
|
+
* 只读扫描,零写入——动作(迁移/清账/repair)由消费方另行执行。
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { readFrBindings, resolveTestFileRel, testAnchorFile } from './test-bindings.js'
|
|
20
|
+
|
|
21
|
+
/** 伪域判定:auto- 前缀(scaffold 时代机器蒸馏域)或 unmapped 池 */
|
|
22
|
+
const isPseudoDomain = (domain) => /^auto[-_]/.test(domain) || domain === 'unmapped'
|
|
23
|
+
|
|
24
|
+
/** 交付路径 → 建议域(落域机械改进的建议器,与 indexRequirements 的告警共用单源) */
|
|
25
|
+
export function suggestDomainFromFiles(files) {
|
|
26
|
+
for (const raw of files || []) {
|
|
27
|
+
const p = String(raw || '').replace(/\\/g, '/')
|
|
28
|
+
let m
|
|
29
|
+
if ((m = /^backend\/app\/modules\/([\w-]+)\//.exec(p))) return m[1] // backend 模块归属最强
|
|
30
|
+
if ((m = /^sillyhub-daemon\//.exec(p))) return 'daemon'
|
|
31
|
+
if ((m = /^frontend\//.exec(p))) return 'frontend'
|
|
32
|
+
if ((m = /^backend\//.exec(p))) return 'backend'
|
|
33
|
+
if ((m = /^([\w-]+)\/src\//.exec(p))) return m[1]
|
|
34
|
+
if ((m = /^([\w-]+)\//.exec(p))) return m[1]
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 条目级解析:fr/<域>.md 的 `## <id> <标题>` 段头与「待复核:」行 */
|
|
40
|
+
function scanFrFile(text) {
|
|
41
|
+
const entries = []
|
|
42
|
+
let cur = null
|
|
43
|
+
for (const line of text.split(/\r?\n/)) {
|
|
44
|
+
const h = /^## (FR-[\w.-]+-\d+|FR-\d+)\s*(.*)$/.exec(line)
|
|
45
|
+
if (h) { cur = { id: h[1], needsReview: null }; entries.push(cur); continue }
|
|
46
|
+
if (cur) {
|
|
47
|
+
const r = /^待复核:(.+)$/.exec(line)
|
|
48
|
+
if (r) cur.needsReview = r[1].trim()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return entries
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 收集治理信号(纯函数,只读)。
|
|
56
|
+
* @returns {{ generated_at: string, healthy: boolean, signals: Array<{kind, level, title, count, detail, suggestion?}>, totals: object }}
|
|
57
|
+
*/
|
|
58
|
+
export function collectKnowledgeDigest({ specBase, projectRoot }) {
|
|
59
|
+
const knowledgeRoot = join(specBase, 'knowledge')
|
|
60
|
+
const frDir = join(knowledgeRoot, 'fr')
|
|
61
|
+
const signals = []
|
|
62
|
+
const totals = { rot: 0, inbox: 0, pseudo: 0, unresolvedBindings: 0 }
|
|
63
|
+
|
|
64
|
+
const rotByDomain = new Map()
|
|
65
|
+
const pseudoByDomain = new Map()
|
|
66
|
+
const unresolved = []
|
|
67
|
+
const frFiles = existsSync(frDir) ? readdirSync(frDir).filter(f => f.endsWith('.md')).sort() : []
|
|
68
|
+
// unmapped 基线消音(与 fr-index 落库告警同口径):跨仓历史基线不刷信号,只报增量
|
|
69
|
+
let unmappedBaseline = 0
|
|
70
|
+
try {
|
|
71
|
+
const raw = readFileSync(join(specBase, 'local.yaml'), 'utf8')
|
|
72
|
+
const m = raw.match(/^fr_unmapped_baseline:\s*(\d+)\s*$/m)
|
|
73
|
+
if (m) unmappedBaseline = parseInt(m[1], 10)
|
|
74
|
+
} catch { /* 无 local.yaml = 无基线 */ }
|
|
75
|
+
|
|
76
|
+
for (const f of frFiles) {
|
|
77
|
+
const domain = f.replace(/\.md$/, '')
|
|
78
|
+
const text = readFileSync(join(frDir, f), 'utf8')
|
|
79
|
+
const entries = scanFrFile(text)
|
|
80
|
+
const rot = entries.filter(e => e.needsReview).length
|
|
81
|
+
if (rot > 0) { rotByDomain.set(domain, rot); totals.rot += rot }
|
|
82
|
+
if (isPseudoDomain(domain) && entries.length > 0) {
|
|
83
|
+
// 基线内的 unmapped 池不计(历史跨仓基线,非新增堆积);auto-* 伪域无基线语义恒计
|
|
84
|
+
const count = domain === 'unmapped' ? Math.max(0, entries.length - unmappedBaseline) : entries.length
|
|
85
|
+
if (count > 0) { pseudoByDomain.set(domain, count); totals.pseudo += count }
|
|
86
|
+
}
|
|
87
|
+
// 绑定解析失败(与 repair-paths 同口径:先根相对直取,再 resolveTestFileRel)
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
const rows = readFrBindings({ knowledgeRoot, frId: e.id })
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
for (const t of row.tests || []) {
|
|
92
|
+
// 锚剥离单源(评审 P1 清偿):testAnchorFile 吃全部分隔形态(#/::/「」/>)——
|
|
93
|
+
// 手搓 split('#').split('::') 漏「」锚致 7/8 假阳性(目标文件全在场),与 repair-paths 口径对拍实证
|
|
94
|
+
const filePart = testAnchorFile(String(t))
|
|
95
|
+
const rel = resolveTestFileRel(filePart, { projectRoot })
|
|
96
|
+
if (!rel && !existsSync(join(projectRoot, filePart.replace(/\\/g, '/')))) {
|
|
97
|
+
totals.unresolvedBindings++
|
|
98
|
+
unresolved.push(`${e.id}:${t}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ② 收件箱(uncategorized.md 的 `## <qlId> | <标题>` 行)
|
|
106
|
+
let inboxTitles = []
|
|
107
|
+
const uncPath = join(knowledgeRoot, 'uncategorized.md')
|
|
108
|
+
if (existsSync(uncPath)) {
|
|
109
|
+
inboxTitles = readFileSync(uncPath, 'utf8').split(/\r?\n/)
|
|
110
|
+
.map(l => /^## (.+)$/.exec(l)?.[1]).filter(Boolean)
|
|
111
|
+
totals.inbox = inboxTitles.length
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const mk = (kind, level, title, count, detail, suggestion) =>
|
|
115
|
+
signals.push({ kind, level, title, count, detail, ...(suggestion ? { suggestion } : {}) })
|
|
116
|
+
|
|
117
|
+
if (totals.rot > 100) {
|
|
118
|
+
mk('rot', 'warn', `rot 待复核批量标记(${totals.rot} 条 > 100)`, totals.rot,
|
|
119
|
+
[...rotByDomain.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([d, n]) => `${d} ${n}`).join('、'),
|
|
120
|
+
'批量复核:文案漂移类可批量承接,行为类逐条对账')
|
|
121
|
+
}
|
|
122
|
+
if (totals.inbox > 20) {
|
|
123
|
+
mk('inbox', 'warn', `知识收件箱积压(${totals.inbox} 条 > 20)`, totals.inbox,
|
|
124
|
+
inboxTitles.slice(0, 5).join(';') + (inboxTitles.length > 5 ? ' 等' : ''),
|
|
125
|
+
'sillyspec knowledge inbox 逐条 classify 清账')
|
|
126
|
+
}
|
|
127
|
+
if (totals.pseudo > 0) {
|
|
128
|
+
mk('pseudo-domain', 'warn', `伪域在库(auto-*/unmapped 共 ${totals.pseudo} 条)`, totals.pseudo,
|
|
129
|
+
[...pseudoByDomain.entries()].map(([d, n]) => `${d} ${n}`).join('、'),
|
|
130
|
+
'按 suggestDomainFromFiles 建议迁移域(平台信号卡一键/手工 redomain)')
|
|
131
|
+
}
|
|
132
|
+
if (totals.unresolvedBindings > 0) {
|
|
133
|
+
mk('binding-unresolved', 'warn', `绑定路径解析失败(${totals.unresolvedBindings} 个)`, totals.unresolvedBindings,
|
|
134
|
+
unresolved.slice(0, 5).join('、') + (unresolved.length > 5 ? ' 等' : ''),
|
|
135
|
+
'sillyspec tests repair-paths(干跑预览 → --write 归一)')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
generated_at: new Date().toISOString(),
|
|
140
|
+
healthy: signals.length === 0,
|
|
141
|
+
signals,
|
|
142
|
+
totals,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 人读文本渲染(CLI 出口;平台走 --json) */
|
|
147
|
+
export function renderKnowledgeDigestText(d) {
|
|
148
|
+
const lines = []
|
|
149
|
+
lines.push(`📬 知识资产治理摘要(${d.generated_at.slice(0, 16).replace('T', ' ')})`)
|
|
150
|
+
if (d.healthy) {
|
|
151
|
+
lines.push('✅ 四类信号全部在阈内(rot/inbox/伪域/坏绑定)——安静即健康态,无需动作')
|
|
152
|
+
} else {
|
|
153
|
+
lines.push(`⚠️ ${d.signals.length} 类信号超阈:`)
|
|
154
|
+
for (const s of d.signals) {
|
|
155
|
+
lines.push(` [${s.kind}] ${s.title}`)
|
|
156
|
+
if (s.detail) lines.push(` 明细:${s.detail}`)
|
|
157
|
+
if (s.suggestion) lines.push(` 处置:${s.suggestion}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
lines.push(`底数:rot 待复核 ${d.totals.rot}|收件箱 ${d.totals.inbox}|伪域条目 ${d.totals.pseudo}|坏绑定 ${d.totals.unresolvedBindings}`)
|
|
161
|
+
return lines.join('\n')
|
|
162
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-quicklog.js — quicklog 历史检索面(知识可见性导线)
|
|
3
|
+
*
|
|
4
|
+
* 缺口(2026-09-21 R5R 实证):quick 产出(quicklog 条目 + patch)不在任何检索面——
|
|
5
|
+
* knowledge-match 只扫 INDEX 路由与 decisions;主仓唯一核验过键名的先例
|
|
6
|
+
* (ql-20260920-007-d0dd)因 linked 变更未归档而 distill 缺席,FR 检索面零覆盖,
|
|
7
|
+
* 后续三个独立会话全部猜错同一外部键。本模块把 quicklog 历史拉进注入面:
|
|
8
|
+
* 纯读、fail-open、top-3 一行制——注入的是「发生过什么」的历史事实(条目说的是
|
|
9
|
+
* 当时做了什么),不是权威断言;消费方要用契约细节按 ql-ID 回源读全文或读代码。
|
|
10
|
+
*
|
|
11
|
+
* 与 knowledge-match.js 分工:那边消费 INDEX/decisions(结构化知识),这边消费双信号源——
|
|
12
|
+
* quicklog/QUICKLOG-*.md(append-only 历史)+ changes/archive/<变更>/flow-state.yaml(flow 族归档
|
|
13
|
+
* 变更伪条目,2026-09-23 扩源:flow 变更不产生 quicklog 条目,扩源后检索面才看得见)。
|
|
14
|
+
* 条目头与字段行是 allocateQuicklogEntry
|
|
15
|
+
* 的机械解析契约:`## ql-<id> | <时间> | <标题>` + `文件:` 段(`- path(注记)` 行)。
|
|
16
|
+
* run/prompt.js(execute 确认步 + quick step1)与 stages/execute.js(Wave 孪生)两处
|
|
17
|
+
* 注入点共用本模块——格式等价由 test/knowledge-inject.test.mjs 锁定,改渲染必同步两处。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
|
|
23
|
+
const QUICKLOG_ENTRY_RE = /^##\s+(ql-[\w-]+)\s*\|\s*([^|]+)\s*\|\s*(.+)$/
|
|
24
|
+
// 文件注记行:`- path(中文括注)` / `- path(ascii)` / 裸 `- path`(allocateQuicklogEntry
|
|
25
|
+
// 均可产出;path 本身不含空白,故止于首个空白或括号)
|
|
26
|
+
const QUICKLOG_FILE_LINE_RE = /^-\s+([^\s((]+)/
|
|
27
|
+
// 文件:段结束边界——出现其他字段标签行即收段(状态/需求/根因/方案/结果/审计/关联变更)
|
|
28
|
+
const QUICKLOG_FIELD_RE = /^(状态|关联变更|需求|根因|方案|结果|审计)\s*[::]/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 解析 quicklog 全量条目——双信号源(资产三小件①,2026-09-23 扩源):
|
|
32
|
+
* ① quicklog/QUICKLOG-*.md(append-only 历史条目,含按日归档的分片文件);
|
|
33
|
+
* ② .sillyspec/changes/archive/<变更>/flow-state.yaml(flow 族归档变更)——flow 变更不产生
|
|
34
|
+
* quicklog 条目,检索面此前看不见 flow 变更的知识(与 ql-011 治的病同族)。伪条目
|
|
35
|
+
* 三料合成:qlId=变更目录名 / date=目录名日期前缀(缺前缀退 flow-state.yaml mtime)/
|
|
36
|
+
* title=proposal.md 首个 `# ` 标题行(缺文件退变更名);solution 恒空、files 恒空。
|
|
37
|
+
* 双源命中时 quicklog 条目排前(matchQuicklogContext 排序 source 键)。
|
|
38
|
+
* @param {string} specBase - .sillyspec 根
|
|
39
|
+
* @returns {{ qlId: string, date: string, title: string, solution: string, files: string[], source: 'quicklog'|'flow' }[]}
|
|
40
|
+
* 两源都不存在 / 全部不可读 → [](调用方零注入零阻断)
|
|
41
|
+
*/
|
|
42
|
+
export function parseQuicklogEntries(specBase) {
|
|
43
|
+
return [...parseQuicklogFileEntries(specBase), ...parseFlowArchiveEntries(specBase)]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 源①:quicklog/QUICKLOG-*.md 条目解析(原 parseQuicklogEntries 主体,行为不变)。 */
|
|
47
|
+
function parseQuicklogFileEntries(specBase) {
|
|
48
|
+
const dir = join(specBase, 'quicklog')
|
|
49
|
+
let files
|
|
50
|
+
try {
|
|
51
|
+
files = readdirSync(dir).filter((f) => /^QUICKLOG-.*\.md$/i.test(f))
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
const entries = []
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
let content
|
|
58
|
+
try {
|
|
59
|
+
content = readFileSync(join(dir, f), 'utf8')
|
|
60
|
+
} catch {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
let cur = null
|
|
64
|
+
let inFiles = false
|
|
65
|
+
const flush = () => {
|
|
66
|
+
if (cur) entries.push(cur)
|
|
67
|
+
cur = null
|
|
68
|
+
}
|
|
69
|
+
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
|
|
70
|
+
const h = line.match(QUICKLOG_ENTRY_RE)
|
|
71
|
+
if (h) {
|
|
72
|
+
flush()
|
|
73
|
+
cur = { qlId: h[1].trim(), date: h[2].trim(), title: h[3].trim(), solution: '', files: [], source: 'quicklog' }
|
|
74
|
+
inFiles = false
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (!cur) continue
|
|
78
|
+
if (QUICKLOG_FIELD_RE.test(line)) {
|
|
79
|
+
inFiles = false
|
|
80
|
+
const m = line.match(/^方案\s*[::]\s*(.*)$/)
|
|
81
|
+
if (m) cur.solution = m[1].trim()
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (/^文件\s*[::]/.test(line)) {
|
|
85
|
+
inFiles = true
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (inFiles) {
|
|
89
|
+
const m = line.match(QUICKLOG_FILE_LINE_RE)
|
|
90
|
+
if (m) cur.files.push(m[1].replace(/\\/g, '/'))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
flush()
|
|
94
|
+
}
|
|
95
|
+
return entries
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 源②:flow 归档变更伪条目——目录内 flow-state.yaml 在场即认(不校验 yaml 内容:伪条目
|
|
99
|
+
* 只需「这曾是一个 flow 变更」的存在信号,字段全来自目录名与 proposal.md,缺件逐项降级)。 */
|
|
100
|
+
function parseFlowArchiveEntries(specBase) {
|
|
101
|
+
const archiveDir = join(specBase, 'changes', 'archive')
|
|
102
|
+
let dirs
|
|
103
|
+
try {
|
|
104
|
+
dirs = readdirSync(archiveDir, { withFileTypes: true })
|
|
105
|
+
} catch {
|
|
106
|
+
return []
|
|
107
|
+
}
|
|
108
|
+
const out = []
|
|
109
|
+
for (const d of dirs) {
|
|
110
|
+
if (!d.isDirectory()) continue
|
|
111
|
+
try {
|
|
112
|
+
const dir = join(archiveDir, d.name)
|
|
113
|
+
if (!existsSync(join(dir, 'flow-state.yaml'))) continue
|
|
114
|
+
const dm = d.name.match(/^(\d{4}-\d{2}-\d{2})-/)
|
|
115
|
+
let date = dm ? dm[1] : ''
|
|
116
|
+
if (!date) {
|
|
117
|
+
try { date = new Date(statSync(join(dir, 'flow-state.yaml')).mtime).toISOString().slice(0, 10) } catch { /* mtime 不可得 → 空日期(排序自然靠后) */ }
|
|
118
|
+
}
|
|
119
|
+
let title = ''
|
|
120
|
+
try {
|
|
121
|
+
const prop = readFileSync(join(dir, 'proposal.md'), 'utf8')
|
|
122
|
+
const h = prop.replace(/\r\n/g, '\n').split('\n').find((l) => /^#\s+/.test(l))
|
|
123
|
+
if (h) title = h.replace(/^#\s+/, '').trim()
|
|
124
|
+
} catch { /* 无 proposal.md → 标题退变更名 */ }
|
|
125
|
+
out.push({ qlId: d.name, date, title: title || d.name, solution: '', files: [], source: 'flow' })
|
|
126
|
+
} catch { /* 单目录读取异常 → 跳过该目录(fail-open,不影响其余源) */ }
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 匹配打分 ─────────────────────────────────────────────────────────────────
|
|
132
|
+
// 查询侧 token:ASCII 词(≥3 字符,词边界匹配)+ CJK 连续段(≥2 字,子串匹配)——
|
|
133
|
+
// 与 knowledge-match.keywordMatchesContext 同判法,方向相反(那边条目出关键词查上下文,
|
|
134
|
+
// 这边上下文出 token 查条目——quicklog 条目没有 INDEX 关键词行,只能反向)。
|
|
135
|
+
const ASCII_TOKEN_RE = /[a-z][a-z0-9._-]{2,}/g
|
|
136
|
+
const CJK_RUN_RE = /[\u4e00-\u9fff]{2,}/g
|
|
137
|
+
|
|
138
|
+
function contextTokens(taskContext) {
|
|
139
|
+
const s = String(taskContext || '').toLowerCase()
|
|
140
|
+
const out = new Set()
|
|
141
|
+
for (const t of s.match(ASCII_TOKEN_RE) || []) out.add(t)
|
|
142
|
+
for (const t of s.match(CJK_RUN_RE) || []) out.add(t)
|
|
143
|
+
return [...out]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function escapeRegex(s) {
|
|
147
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 文件命中权重:路径/文件名是强信号(同一文件被改过 ≈ 同域修补) */
|
|
151
|
+
const FILE_HIT_WEIGHT = 5
|
|
152
|
+
/** 命中门槛:≥1 文件命中,或 ≥2 个不同 token 命中(单 token 命中多为噪音词,不注入) */
|
|
153
|
+
const MIN_TOKEN_HITS = 2
|
|
154
|
+
|
|
155
|
+
/** 源排序权重:quicklog 条目 0(排前),flow 伪条目 1——双源命中时 quicklog 优先(更新鲜)。 */
|
|
156
|
+
function sourceRank(entry) {
|
|
157
|
+
return entry && entry.source === 'flow' ? 1 : 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 任务上下文 × quicklog 条目匹配(双源条目统一打分,排序 source 键优先)。
|
|
162
|
+
* @param {string} specBase - .sillyspec 根
|
|
163
|
+
* @param {string} taskContext - 任务描述串(与 matchKnowledge 的 query 同源)
|
|
164
|
+
* @param {{ limit?: number }} [opts] - 注入上限(默认 3)
|
|
165
|
+
* @returns {{ hits: Array<{ qlId: string, date: string, title: string, files: string[] }>, report: string }}
|
|
166
|
+
* 无 quicklog / 无命中 / 任何异常 → { hits: [], report: '' }(fail-open,永不抛)
|
|
167
|
+
*/
|
|
168
|
+
export function matchQuicklogContext(specBase, taskContext, { limit = 3 } = {}) {
|
|
169
|
+
try {
|
|
170
|
+
if (!specBase || !taskContext) return { hits: [], report: '' }
|
|
171
|
+
const entries = parseQuicklogEntries(specBase)
|
|
172
|
+
if (entries.length === 0) return { hits: [], report: '' }
|
|
173
|
+
const tokens = contextTokens(taskContext)
|
|
174
|
+
if (tokens.length === 0) return { hits: [], report: '' }
|
|
175
|
+
|
|
176
|
+
const scored = []
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
const hay = [e.title, e.solution, e.files.join(' ')].join(' ').toLowerCase()
|
|
179
|
+
const basenames = new Set(e.files.map((f) => f.split('/').pop().toLowerCase()))
|
|
180
|
+
let score = 0
|
|
181
|
+
let tokenHits = 0
|
|
182
|
+
let fileHit = 0
|
|
183
|
+
for (const t of tokens) {
|
|
184
|
+
let hit = false
|
|
185
|
+
if (/[^\x00-\x7f]/.test(t)) {
|
|
186
|
+
hit = hay.includes(t)
|
|
187
|
+
} else {
|
|
188
|
+
hit = new RegExp(`(^|[^a-z0-9])${escapeRegex(t)}([^a-z0-9]|$)`).test(hay)
|
|
189
|
+
if (!hit && t.length >= 4) {
|
|
190
|
+
for (const b of basenames) {
|
|
191
|
+
if (b.includes(t)) {
|
|
192
|
+
hit = true
|
|
193
|
+
fileHit++
|
|
194
|
+
score += FILE_HIT_WEIGHT
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (hit) {
|
|
201
|
+
score += 1
|
|
202
|
+
tokenHits++
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (fileHit >= 1 || tokenHits >= MIN_TOKEN_HITS) {
|
|
206
|
+
scored.push({ entry: e, score, fileHit })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
scored.sort((a, b) =>
|
|
210
|
+
(sourceRank(a.entry) - sourceRank(b.entry)) ||
|
|
211
|
+
(b.fileHit - a.fileHit) ||
|
|
212
|
+
(b.score - a.score) ||
|
|
213
|
+
String(b.entry.date).localeCompare(String(a.entry.date))
|
|
214
|
+
)
|
|
215
|
+
const top = scored.slice(0, Math.max(1, limit)).map((s) => ({
|
|
216
|
+
qlId: s.entry.qlId,
|
|
217
|
+
date: String(s.entry.date).slice(0, 10),
|
|
218
|
+
title: s.entry.title,
|
|
219
|
+
files: s.entry.files,
|
|
220
|
+
}))
|
|
221
|
+
return { hits: top, report: renderQuicklogSection(top) }
|
|
222
|
+
} catch {
|
|
223
|
+
return { hits: [], report: '' }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 注入段渲染(prompt.js 与 execute.js 孪生共用——仅段头层级不同)。
|
|
229
|
+
* 一行一条:ql-ID · 标题(截 60)· 日期 · 至多 2 个触碰文件。零正文注入——
|
|
230
|
+
* quicklog 条目正文可能很长,注入面只做「知道有这回事 + 回源指针」。
|
|
231
|
+
* @param {Array<{qlId,date,title,files}>} hits
|
|
232
|
+
* @param {{ h3?: boolean }} [opts] - execute.js Wave prompt 内用 h3(### )对齐知识段层级
|
|
233
|
+
*/
|
|
234
|
+
export function renderQuicklogSection(hits, { h3 = false } = {}) {
|
|
235
|
+
if (!Array.isArray(hits) || hits.length === 0) return ''
|
|
236
|
+
const lines = [
|
|
237
|
+
`${h3 ? '### ' : ''}🕘 近期 quick 修补(quicklog 机械匹配,top-${hits.length}——历史记录非权威断言,细节按 ql-ID 回源 .sillyspec/quicklog/ 或读代码)`,
|
|
238
|
+
]
|
|
239
|
+
for (const h of hits) {
|
|
240
|
+
const files = (h.files || []).slice(0, 2).join(', ')
|
|
241
|
+
const title = String(h.title || '').slice(0, 60)
|
|
242
|
+
const date = String(h.date || '').slice(0, 10)
|
|
243
|
+
lines.push(` - ${h.qlId} · ${title} · ${date}${files ? ` · ${files}` : ''}`)
|
|
244
|
+
}
|
|
245
|
+
return lines.join('\n')
|
|
246
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* machine-draft.js — MACHINE-DRAFT 指纹三件套泛化(R7 切片三 task-04 / D-004 / FR-08)。
|
|
3
|
+
*
|
|
4
|
+
* 从 verify-draft.js(verify 填槽制 v2 件二)抽出通用原语,供 flow 四件机器稿(flow-draft)
|
|
5
|
+
* 复用;verify-draft 改为消费方(导出签名与行为逐字不动,零回归硬门)。
|
|
6
|
+
*
|
|
7
|
+
* 三件套语义(护栏#2:守卫必须是验收侧的机制,永远不是生成侧的劝说):
|
|
8
|
+
* - wrapSection:机器预填段以 MACHINE-DRAFT begin/end 注释对包裹,begin 内嵌段内容
|
|
9
|
+
* sha256(CRLF 归一);amendCmd 按消费方参数化(verify-result 指向 verify-probes
|
|
10
|
+
* --amend-draft,flow 稿指向 flow amend-draft——勿把 agent 引向错误命令);
|
|
11
|
+
* - verifyMarkers:三态拒收判定——标记缺失(被删/整份重写)/内容哈希失配(改写)/
|
|
12
|
+
* 手工重锚未审计(标记哈希与台账不一致);
|
|
13
|
+
* - reanchorText:按当前内容重锚哈希的纯文本变换(amend 留痕通道的机械半边;
|
|
14
|
+
* 台账审计与落盘归消费方)。
|
|
15
|
+
*
|
|
16
|
+
* 本模块纯函数(无 fs)——md 文本与台账对象由消费方读写;段 key 命名空间归消费方
|
|
17
|
+
* (verify-result 单文件段键 / flow 按文件名+段键组合)。
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
export const MARK_BEGIN_RE = /^<!--\s*MACHINE-DRAFT:([\w.-]+):([0-9a-f]{64}):begin.*-->\s*$/
|
|
22
|
+
const MARK_END_RE = /^<!--\s*MACHINE-DRAFT:([\w.-]+):end.*-->\s*$/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 任务勾选态归一(2026-09-25-thin-done-gate-calibration 坑1):勾选是 flow start 横幅教的
|
|
26
|
+
* 预期写面(收口哨兵的证据面),不该参与「被改写」判定——哈希前把 `- [x] task-NN` 归一回
|
|
27
|
+
* `- [ ]`(draft 产出恒 [ ],存量台账两侧归一后同哈希,兼容;真实改写任务文本/增删行仍失配)。
|
|
28
|
+
* 口径只认 task-NN 行,普通清单勾选行不归一(不扩大书写面)。
|
|
29
|
+
*/
|
|
30
|
+
const TASK_CHECK_STATE_RE = /^([-*] \[)[ xX]\](?= task-\d)/gm
|
|
31
|
+
function normalizeTaskCheckboxState(s) {
|
|
32
|
+
return String(s).replace(TASK_CHECK_STATE_RE, '$1 ]')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 段内容哈希(CRLF 归一——跨平台写盘行尾漂移不炸指纹;task-NN 勾选态归一——见上)。 */
|
|
36
|
+
export function bodyHash(s) {
|
|
37
|
+
return createHash('sha256').update(normalizeTaskCheckboxState(String(s).replace(/\r\n/g, '\n'))).digest('hex')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 包裹机器段(begin 标记 + body + end 标记 + 尾空行)。标记格式与既有 MARK_*_RE 逐字兼容。
|
|
42
|
+
* @returns {string} 四行块(含尾空行,消费方按原位替换)
|
|
43
|
+
*/
|
|
44
|
+
export function wrapSection({ key, body, amendCmd, guardNote = '整段改写会被 verify --done 拒收' }) {
|
|
45
|
+
return [
|
|
46
|
+
`<!-- MACHINE-DRAFT:${key}:${bodyHash(body)}:begin 机器预填段——${guardNote};确要修改:${amendCmd} 留痕重锚 -->`,
|
|
47
|
+
body,
|
|
48
|
+
`<!-- MACHINE-DRAFT:${key}:end -->`,
|
|
49
|
+
'',
|
|
50
|
+
].join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 解析 md 中全部标记段(CRLF 归一)。
|
|
55
|
+
* @returns {{key:string, markerHash:string, contentLines:string[]}[]}
|
|
56
|
+
*/
|
|
57
|
+
export function parseMarkerBlocks(text) {
|
|
58
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n')
|
|
59
|
+
const blocks = []
|
|
60
|
+
let current = null
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const b = line.match(MARK_BEGIN_RE)
|
|
63
|
+
if (b) { current = { key: b[1], markerHash: b[2], contentLines: [] }; blocks.push(current); continue }
|
|
64
|
+
const e = line.match(MARK_END_RE)
|
|
65
|
+
if (e) { current = null; continue }
|
|
66
|
+
if (current) current.contentLines.push(line)
|
|
67
|
+
}
|
|
68
|
+
return blocks
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 三态拒收判定(--done 验收侧承重件的纯函数半边)。
|
|
73
|
+
* @param {object} p
|
|
74
|
+
* @param {string} p.text md 全文
|
|
75
|
+
* @param {Object<string,{hash:string}>} p.sections 台账段哈希(sidecar.sections 形态)
|
|
76
|
+
* @param {string} p.amendCmd 修复指引命令文案(消费方参数化)
|
|
77
|
+
* @returns {string[]} violations(空=干净;文案含修复指引)
|
|
78
|
+
*/
|
|
79
|
+
export function verifyMarkers({ text, sections, amendCmd }) {
|
|
80
|
+
const blocks = parseMarkerBlocks(text)
|
|
81
|
+
const byKey = new Map(blocks.map((b) => [b.key, b]))
|
|
82
|
+
const violations = []
|
|
83
|
+
const fix = (key) => `还原机器段原文,或确要修改时跑 ${amendCmd} 留痕重锚(段键 ${key})`
|
|
84
|
+
for (const [key, rec] of Object.entries(sections || {})) {
|
|
85
|
+
const b = byKey.get(key)
|
|
86
|
+
if (!b) {
|
|
87
|
+
violations.push(`机器段「${key}」的 MACHINE-DRAFT 标记缺失(被删除或整份重写)——${fix(key)}`)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const actual = bodyHash(b.contentLines.join('\n'))
|
|
91
|
+
if (actual !== rec.hash) {
|
|
92
|
+
violations.push(`机器段「${key}」内容与指纹失配(被改写)——${fix(key)}`)
|
|
93
|
+
} else if (b.markerHash !== rec.hash) {
|
|
94
|
+
// 内容未动但标记哈希与台账不一致 = 标记被手工重锚而未经 amend 审计
|
|
95
|
+
violations.push(`机器段「${key}」标记指纹与 sidecar 台账不一致(未经 --amend-draft 的手工重锚)——${fix(key)}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return violations
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 按当前内容重锚全部标记段(纯文本变换;不落盘不记台账——消费方持 mdPath/台账)。
|
|
103
|
+
* @returns {{text:string, keys:string[], contentByKey:Object<string,string>}}
|
|
104
|
+
*/
|
|
105
|
+
export function reanchorText({ text, amendCmd, guardNote = '整段改写会被 verify --done 拒收' }) {
|
|
106
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n')
|
|
107
|
+
const contentLinesByKey = new Map()
|
|
108
|
+
let current = null
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const b = line.match(MARK_BEGIN_RE)
|
|
111
|
+
if (b) { current = b[1]; contentLinesByKey.set(current, []); continue }
|
|
112
|
+
const e = line.match(MARK_END_RE)
|
|
113
|
+
if (e) { current = null; continue }
|
|
114
|
+
if (current) contentLinesByKey.get(current).push(line)
|
|
115
|
+
}
|
|
116
|
+
const keys = [...contentLinesByKey.keys()]
|
|
117
|
+
const next = lines.map((line) => {
|
|
118
|
+
const b = line.match(MARK_BEGIN_RE)
|
|
119
|
+
if (!b) return line
|
|
120
|
+
const content = (contentLinesByKey.get(b[1]) || []).join('\n')
|
|
121
|
+
return `<!-- MACHINE-DRAFT:${b[1]}:${bodyHash(content)}:begin 机器预填段——${guardNote};确要修改:${amendCmd} 留痕重锚 -->`
|
|
122
|
+
}).join('\n')
|
|
123
|
+
const contentByKey = {}
|
|
124
|
+
for (const k of keys) contentByKey[k] = (contentLinesByKey.get(k) || []).join('\n')
|
|
125
|
+
return { text: next, keys, contentByKey }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default { wrapSection, parseMarkerBlocks, verifyMarkers, reanchorText, bodyHash, MARK_BEGIN_RE }
|