sillyspec 3.30.0 → 3.32.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.
Files changed (73) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +10 -6
  5. package/.claude/skills/sillyspec-quick/SKILL.md +116 -18
  6. package/.zcodeignore +66 -0
  7. package/AGENTS.md +15 -15
  8. package/assets/command-cards/flow.md +9 -3
  9. package/assets/command-cards/run-quick.md +4 -1
  10. package/package.json +2 -2
  11. package/src/agent-session-log.js +27 -5
  12. package/src/config-schema.js +21 -0
  13. package/src/datetime.js +51 -0
  14. package/src/db.js +10 -3
  15. package/src/decision-distill.js +21 -12
  16. package/src/design-facts.js +46 -6
  17. package/src/flow-draft.js +468 -95
  18. package/src/flow-parity.js +62 -4
  19. package/src/flow-review.js +136 -6
  20. package/src/flow.js +729 -125
  21. package/src/foreign-declared.js +71 -3
  22. package/src/fr-index.js +148 -162
  23. package/src/git-helper.js +16 -3
  24. package/src/hooks/worktree-guard.js +16 -5
  25. package/src/hunk-attribution.js +28 -5
  26. package/src/index.js +184 -17
  27. package/src/knowledge-classify.js +3 -1
  28. package/src/knowledge-digest.js +55 -26
  29. package/src/knowledge-match.js +135 -7
  30. package/src/knowledge-stats.js +66 -4
  31. package/src/knowledge-vector.js +160 -0
  32. package/src/machine-interface.js +90 -2
  33. package/src/module-impact.js +22 -1
  34. package/src/modules.js +9 -0
  35. package/src/progress/change-registry.js +20 -0
  36. package/src/progress/stage-machine.js +4 -7
  37. package/src/progress.js +8 -1
  38. package/src/quicklog.js +20 -0
  39. package/src/redomain.js +23 -7
  40. package/src/route-hindsight.js +276 -0
  41. package/src/run/archive-distill.js +3 -1
  42. package/src/run/command.js +65 -12
  43. package/src/run/complete-handlers.js +219 -5
  44. package/src/run/complete.js +77 -11
  45. package/src/run/gate-snapshot.js +15 -0
  46. package/src/run/gates.js +60 -4
  47. package/src/run/prompt.js +159 -31
  48. package/src/run/quick-audit.js +66 -7
  49. package/src/run/shared.js +6 -11
  50. package/src/run/stage.js +5 -1
  51. package/src/run/verify-quality-scan.js +84 -0
  52. package/src/scan-facts.js +2 -1
  53. package/src/scope-audit.js +9 -2
  54. package/src/sentinel-assertions.js +67 -16
  55. package/src/stage-contract-engine.js +4 -1
  56. package/src/stage-contract-spec.js +103 -4
  57. package/src/stage-contract.js +55 -7
  58. package/src/stages/brainstorm.js +27 -14
  59. package/src/stages/knowledge.js +4 -3
  60. package/src/stages/plan-postcheck.js +98 -3
  61. package/src/stages/plan.js +22 -2
  62. package/src/stages/verify.js +2 -1
  63. package/src/task-review.js +1 -1
  64. package/src/task-tick.js +121 -0
  65. package/src/taskcard-frontmatter.js +31 -0
  66. package/src/ui-visual.js +43 -8
  67. package/src/verify-postcheck.js +509 -37
  68. package/src/verify-probes.js +216 -5
  69. package/src/watcher.js +1067 -1031
  70. package/src/worktree-apply.js +9 -1
  71. package/src/worktree.js +21 -6
  72. package/templates/agents-instruction.md +15 -15
  73. package/templates/prompts/taskcard-rules.md +1 -0
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { existsSync, readFileSync } from 'fs'
13
13
  import { join } from 'path'
14
+ import { frTitleOverlap } from './fr-index.js'
14
15
 
15
16
  /**
16
17
  * 从 INDEX.md 解析所有知识条目
@@ -115,7 +116,13 @@ function parseDecisionFile(filePath, file) {
115
116
  const hits = []
116
117
  let cur = null
117
118
  const flush = () => {
118
- if (cur) hits.push(cur)
119
+ if (cur) {
120
+ // 死路注记识别(2026-09-28-knowledge-inject-ranking):「理由」含「死路:」字面标记的教训型
121
+ // 条目——记录它的决策本体是 accepted/implemented(死路只是理由里的注记),按状态过滤会被
122
+ // 压制到组外,恰丢最该防复潮的(实测:D-001@v1 枚举开放世界排 148/188 位永不回显)。
123
+ cur.deathPath = /死路[::]/.test(cur.reason || '')
124
+ hits.push(cur)
125
+ }
119
126
  cur = null
120
127
  }
121
128
  for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
@@ -209,6 +216,15 @@ export function matchDecisionsByFiles(indexDir, files) {
209
216
  return result
210
217
  }
211
218
 
219
+ /** 死路注记短句提取(消费方渲染用):理由里「死路:…」到首个句读为止;无标记时截前 60 字。
220
+ * 单一源供 flow 知识注入段与 complete.js knowledge-gate 回显共用(2026-09-28-knowledge-inject-ranking)。 */
221
+ export function deathPathNote(reason) {
222
+ const r = String(reason || '')
223
+ const m = r.match(/死路[::]\s*([^。;;,,\n]+)/)
224
+ if (m) return m[1].trim()
225
+ return r.slice(0, 60)
226
+ }
227
+
212
228
  function escapeRegex(s) {
213
229
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
214
230
  }
@@ -226,17 +242,104 @@ function keywordMatchesContext(keyword, contextLower) {
226
242
  return new RegExp(`(^|[^a-z0-9])${escapeRegex(kw)}([^a-z0-9]|$)`).test(contextLower)
227
243
  }
228
244
 
245
+ /** 查询词片抽取:CJK 连续段 bigram + ≥4 字符 ASCII 词(小写;短词与标点数字不参与;
246
+ * 纯 ASCII 查询不产词片——中文知识库,英文查询维持既有零命中行为)。 */
247
+ function queryShingles(taskContext) {
248
+ const raw = String(taskContext || '')
249
+ const shingles = new Set()
250
+ if (/[\u4e00-\u9fff]/.test(raw)) {
251
+ for (const w of raw.toLowerCase().match(/[a-z][a-z0-9-]{3,}/g) || []) shingles.add(w)
252
+ }
253
+ for (const seg of raw.match(/[\u4e00-\u9fff]+/g) || []) {
254
+ for (let i = 0; i < seg.length - 1; i++) shingles.add(seg.slice(i, i + 2))
255
+ }
256
+ return [...shingles]
257
+ }
258
+
259
+ /** ASCII 词片词边界命中(CJK 词片 includes 即可);转义防正则注入。 */
260
+ function asciiWordHit(sh, text) {
261
+ if (!/^[a-z]/.test(sh)) return text.includes(sh)
262
+ return new RegExp(`(^|[^a-z0-9-])${sh.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}([^a-z0-9-]|$)`, 'i').test(text)
263
+ }
264
+
265
+ /**
266
+ * 零命中查询侧词片回退(2026-09-29-decision-route-vocab):路由行零命中时,只测「查询自己的
267
+ * 词片」——逐 decisions 域文件数词片在条目文本(标题∪理由)的出现次数,落在「跨条目复现但
268
+ * 非泛在」窗(count ∈ [2, max(2, floor(5%·条目数))])→ 文件级命中(合成路由条目进 entries,CLI
269
+ * search/digest 知识命中/门条目行三面共享);条目级命中 = 该文件内含 ≥1 复现词片的条目
270
+ * (score=复现词片数,消费方 score>0 资格判定兼容)。
271
+ * 设计依据:入库侧词表派生死于「同窗词片按序截断=抽签」(unmapped n∈[2,3] 窗 1176 个、
272
+ * 目标词排 759);查询侧只测自身几十词片,无挑选无 cap。机制词栖息在理由文本(模板标题不
273
+ * 含词形——Mouse B 实测四查全零的根因)。无可回退返回 null(保持既有零命中行为)。
274
+ */
275
+ export function fallbackByQueryShingles(indexDir, taskContext) {
276
+ const shingles = queryShingles(taskContext)
277
+ if (shingles.length === 0) return null
278
+ const all = parseDecisionEntries(indexDir)
279
+ if (all.length === 0) return null
280
+ const byFile = new Map()
281
+ for (const h of all) {
282
+ let st = byFile.get(h.file)
283
+ if (!st) { st = { entries: [], texts: [] }; byFile.set(h.file, st) }
284
+ st.entries.push(h)
285
+ st.texts.push(`${h.title} ${h.reason || ''}`)
286
+ }
287
+ const corroborated = new Map()
288
+ for (const [file, st] of byFile) {
289
+ const text = st.texts.join('\n')
290
+ const ceil = Math.max(2, Math.floor(st.entries.length * 0.05))
291
+ const hits = new Set()
292
+ for (const sh of shingles) {
293
+ if (!asciiWordHit(sh, text)) continue
294
+ const n = text.split(sh).length - 1
295
+ if (n >= 2 && n <= ceil) hits.add(sh)
296
+ }
297
+ if (hits.size > 0) corroborated.set(file, hits)
298
+ }
299
+ if (corroborated.size === 0) return null
300
+ const fbEntries = [...corroborated.entries()].slice(0, 3).map(([file, hits]) => ({
301
+ category: 'Decisions',
302
+ keywords: [...hits].slice(0, 5),
303
+ file,
304
+ anchor: '',
305
+ display: `${file}(查询词片复现命中)`,
306
+ }))
307
+ const decisionHits = []
308
+ for (const [file, hits] of corroborated) {
309
+ for (const h of byFile.get(file).entries) {
310
+ const text = `${h.title} ${h.reason || ''}`
311
+ let mc = 0
312
+ for (const sh of hits) if (asciiWordHit(sh, text)) mc++
313
+ if (mc > 0) decisionHits.push({ ...h, score: mc })
314
+ }
315
+ }
316
+ decisionHits.sort((a, b) =>
317
+ (b.score - a.score) ||
318
+ (((b.status === 'rejected' || b.deathPath) ? 1 : 0) - ((a.status === 'rejected' || a.deathPath) ? 1 : 0))
319
+ )
320
+ return {
321
+ matched: true,
322
+ entries: fbEntries,
323
+ report: `Status: fallback matched (${[...corroborated.keys()].join(', ')} by query shingles)`,
324
+ json: {
325
+ matched: true, entry_count: fbEntries.length, fallback: true,
326
+ entries: fbEntries.map((e) => ({ file: e.file, anchor: e.anchor, keywords: e.keywords, category: e.category })),
327
+ },
328
+ decisionHits,
329
+ }
330
+ }
331
+
229
332
  /**
230
333
  * 用任务上下文匹配知识条目
231
334
  * @param {string} indexDir - knowledge 目录路径
232
335
  * @param {string} taskContext - 任务上下文(task 名称 + 描述,用于关键词匹配)
233
336
  * @returns {{ matched: boolean, entries: Array, report: string, json: object,
234
- * decisionHits: Array<{ file: string, id: string, title: string, status: string, reason: string, revisitWhen: string }> }}
337
+ * decisionHits: Array<{ file: string, id: string, title: string, status: string, reason: string, revisitWhen: string, deathPath?: boolean, score?: number }}> }}
235
338
  * matched/entries/report/json 既有四字段结构与语义不变;decisionHits(task-04 新增)= 任务上下文
236
339
  * 命中的 Decisions 路由行所指向 decisions/<域>.md 内的全部 D-xxx@vN 条目,rejected 优先排序;
237
340
  * 无 decisions 库 / 路由行未命中 → decisionHits: [](其余字段行为与现状一致)。
238
341
  */
239
- export function matchKnowledge(indexDir, taskContext) {
342
+ export function matchByRouting(indexDir, taskContext) {
240
343
  const indexPath = join(indexDir, 'INDEX.md')
241
344
 
242
345
  // INDEX.md 不存在
@@ -302,14 +405,39 @@ export function matchKnowledge(indexDir, taskContext) {
302
405
  }
303
406
 
304
407
  // decisionHits(task-04):任务上下文命中的 Decisions 路由行 → 解析其指向的
305
- // decisions/<域>.md 内全部条目;rejected 优先排序(防复潮信息最先可见,组内保持文件序)。
408
+ // decisions/<域>.md 内全部条目;防复潮优先(rejected ∪ 死路注记)+组内按查询×标题
409
+ // bigram 重叠率降序(2026-09-28-knowledge-inject-ranking:此前 rejected 优先+文件序,
410
+ // 空标题无关条目以文件序霸占回显前 N、相关条目沉底——实测 枚举/开放世界 查询下
411
+ // D-001@v1 排 148/188 位)。重叠判据复用 fr-index frTitleOverlap(字符 bigram,
412
+ // 封闭面非语义判定)。
306
413
  // 不引入新顶层 hits 字段,不改 matched/entries/report/json 四个既有键。
307
414
  const decisionRoutes = matched.filter(isDecisionRoute)
308
415
  const allDecisionHits = decisionRoutes.length > 0 ? parseDecisionEntries(indexDir, decisionRoutes) : []
416
+ // 评分文本 = id+标题;评分前剥除数字与标点(2026-09-28-knowledge-score-denoise:{DECISION_HITS}
417
+ // 场景 taskContext=ASCII 变更名,日期「2026-09-28」与 id「D-009@v1」共享 -0/09 数字 bigram 即
418
+ // 反超死路条目——数字噪音在无内容词场景主导排序;只计内容字符后该场景全零分 → 平局 →
419
+ // 死路先验接管)。近义措辞(查询词只在理由不在标题)同样由零分平局裁决兜底。
420
+ // 刻意不把理由并进评分文本:Jaccard 比率偏爱短文本——短理由无关条目(「与需求直接冲突」共享
421
+ // 一个 bigram 即得高比率)会压过长理由被稀释的相关条目,实测复现;宁用零分平局先验。
422
+ const contentChars = (s) => String(s || '').replace(/[\d\p{P}\s_]+/gu, ' ')
423
+ const relScore = (h) => frTitleOverlap(contentChars(taskContext), contentChars(`${h.id} ${h.title}`))
424
+ const byOverlapDesc = (a, b) => (relScore(b) - relScore(a)) || ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0))
309
425
  const decisionHits = [
310
- ...allDecisionHits.filter(h => h.status === 'rejected'),
311
- ...allDecisionHits.filter(h => h.status !== 'rejected')
312
- ]
426
+ ...allDecisionHits.filter((h) => h.status === 'rejected' || h.deathPath).sort(byOverlapDesc),
427
+ ...allDecisionHits.filter((h) => h.status !== 'rejected' && !h.deathPath).sort(byOverlapDesc),
428
+ ].map((h) => ({ ...h, score: relScore(h) }))
313
429
 
314
430
  return { matched: true, entries: matched, report, json, decisionHits }
315
431
  }
432
+
433
+ /**
434
+ * 同步检索入口(既有行为不变):路由 tag 命中 →(零命中)本地词片复现回退 → 空。
435
+ * 异步三层入口(平台向量优先于本地回退)见 knowledge-vector.js matchKnowledgeHybrid
436
+ * (2026-09-29-knowledge-vector-recall)——同步调用方与既有测试全部走本函数。
437
+ */
438
+ export function matchKnowledge(indexDir, taskContext) {
439
+ const r = matchByRouting(indexDir, taskContext)
440
+ if (r.matched) return r
441
+ const fb = fallbackByQueryShingles(indexDir, taskContext)
442
+ return fb || r
443
+ }
@@ -12,6 +12,8 @@
12
12
  * - lastHitAt = 引用该文件的记录 at 最大值(ISO 原样保留;sinceDays 窗口启时时幸存记录
13
13
  * 必有可解析 at——readKnowledgeHits 契约滤掉 at 缺失/坏值,'' 兜底仅防御性);
14
14
  * - totalInjects / totalClassifies = 窗口内两类记录条数(按记录计,与 matrix 按条目计独立);
15
+ * - lastEventAt = 全量流(不加窗口)最新可解析 at 的原样 ISO(resolveLastEventAt,与
16
+ * matrix 的窗口内 lastHitAt 口径分离——「数据截至」不随窗口缩小回退);无有效记录 null;
15
17
  * - 未知 type 不进矩阵不进计数(前向兼容:新事件类型不炸聚合)。
16
18
  *
17
19
  * neverHit:parseKnowledgeIndex(src/knowledge-match.js 同款解析)得 INDEX.md 唯一 file 全集
@@ -128,6 +130,15 @@ export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } =
128
130
  const frInjectBySource = new Map()
129
131
  const rotSuspectByDomain = new Map()
130
132
  const rotSuspectChanges = new Set()
133
+ // 条目级聚合(2026-10-03-fr-governance-telemetry):frIds/ids 事件 → id → {suspect, unreferenced}
134
+ // ——L3 裁决候选视图数据面;存量无字段事件自然跳过(向后兼容零破坏)。
135
+ const adjudicationById = new Map()
136
+ const bumpId = (id, key) => {
137
+ if (typeof id !== 'string' || !id) return
138
+ const row = adjudicationById.get(id) || { id, suspect: 0, unreferenced: 0 }
139
+ row[key] += 1
140
+ adjudicationById.set(id, row)
141
+ }
131
142
  let frInject = 0
132
143
  let frSupersede = 0
133
144
  let frDuplicateWarning = 0
@@ -155,6 +166,7 @@ export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } =
155
166
  row.count += Number.isFinite(Number(r.count)) ? Number(r.count) : 0
156
167
  rotSuspectByDomain.set(domain, row)
157
168
  }
169
+ for (const id of Array.isArray(r.frIds) ? r.frIds : []) bumpId(id, 'suspect')
158
170
  if (r.change) rotSuspectChanges.add(r.change)
159
171
  } else if (r.type === 'fr-unreferenced') {
160
172
  const domain = r.domain || 'unknown'
@@ -162,6 +174,7 @@ export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } =
162
174
  row.events += 1
163
175
  row.count += Number.isFinite(Number(r.count)) ? Number(r.count) : 0
164
176
  unreferencedByDomain.set(domain, row)
177
+ for (const id of Array.isArray(r.ids) ? r.ids : []) bumpId(id, 'unreferenced')
165
178
  }
166
179
  }
167
180
 
@@ -174,8 +187,18 @@ export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } =
174
187
  const denominator = sourceChanges.size
175
188
  const supersedeRate = denominator > 0 ? supersedeChanges.size / denominator : null
176
189
 
190
+ // 裁决候选(2026-10-03-fr-governance-telemetry):id 级聚合 → 候选清单(附来源变更名——
191
+ // scanFrIndex entries 的 id→change join)。候选非裁决:高 suspect 天然来自热文件高频触达,
192
+ // 处置出口在渲染指引(承接翻链/人工退休)。存量无 id 事件不进聚合(视图零噪音)。
193
+ const changeById = new Map()
194
+ for (const e of entries) if (e.id && e.change) changeById.set(e.id, e.change)
195
+ const adjudicationCandidates = [...adjudicationById.values()]
196
+ .map((row) => ({ ...row, change: changeById.get(row.id) || null }))
197
+ .sort((a, b) => ((b.suspect + b.unreferenced) - (a.suspect + a.unreferenced)) || a.id.localeCompare(b.id))
198
+
177
199
  return {
178
200
  present: frDirExists || frEvents.length > 0,
201
+ adjudicationCandidates,
179
202
  events: {
180
203
  frInject,
181
204
  frInjectBySource: Object.fromEntries(frInjectBySource),
@@ -200,6 +223,33 @@ export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } =
200
223
  }
201
224
  }
202
225
 
226
+ // ── 新鲜度读数(2026-10-05-knowledge-stats-freshness)──
227
+
228
+ /**
229
+ * 全量遥测流最后写入时间(数据新鲜度读数)。
230
+ *
231
+ * 独立于 sinceDays 窗口口径:窗口问「近 N 天统计了多少」,本函数问「这条流最后一次写入
232
+ * 是什么时候」——窗口缩小不该让「数据截至」跟着回退,故只收 runtimeDir 不收窗口参数。
233
+ * at 缺失/不可解析的记录不参与取最大(与 readKnowledgeHits 窗口过滤跳过同类);全部无效
234
+ * 或零记录 → null(消费方以此区分「有遥测」与「无遥测」两个展示分支)。
235
+ *
236
+ * @param {string} runtimeDir - .runtime 目录(knowledge-hits.jsonl 落点)
237
+ * @returns {string|null} 最新记录 at 的原样 ISO 字符串;无有效记录 null
238
+ */
239
+ export function resolveLastEventAt(runtimeDir) {
240
+ let best = null
241
+ let bestTs = -Infinity
242
+ for (const record of readKnowledgeHits(runtimeDir)) {
243
+ const ts = Date.parse(record.at)
244
+ // >= 同刻取落盘更后者,与 buildHitMatrix touch 的 lastTs 口径一致
245
+ if (Number.isFinite(ts) && ts >= bestTs) {
246
+ bestTs = ts
247
+ best = String(record.at)
248
+ }
249
+ }
250
+ return best
251
+ }
252
+
203
253
  // ── CLI 入口(形态对齐 src/stages/knowledge.js cmdSearch / src/knowledge-classify.js cmdKnowledgeClassify)──
204
254
 
205
255
  function outputJson(ok, data, error) {
@@ -213,7 +263,7 @@ function outputJson(ok, data, error) {
213
263
  *
214
264
  * 用法:stats [--since-days N] [--json]
215
265
  * 默认人类可读输出(命中矩阵表 + 从未命中清单 + 遥测计数);--json 输出结构化结果
216
- * ({ ok, sinceDays, hasTelemetry, matrix, neverHit, totalInjects, totalClassifies })。
266
+ * ({ ok, sinceDays, hasTelemetry, lastEventAt, matrix, neverHit, totalInjects, totalClassifies })。
217
267
  * 纯只读。
218
268
  *
219
269
  * @param {string} dir - 项目根目录
@@ -239,15 +289,18 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
239
289
  }
240
290
  sinceDays = n
241
291
  }
242
- const asJson = args.includes('--json')
292
+ // --json 双路判定(对齐 stages/knowledge.js digest 惯例):全局旗标被 index.js 顶层吞进
293
+ // opts.json(filteredArgs 不含 --json),args.includes 仅兜底直调/测试场景
294
+ const asJson = args.includes('--json') || opts.json === true
243
295
 
244
296
  // 遥测在场判定用全量读取(不加窗口):文件缺失/只有坏行 → 无遥测
245
297
  const hasTelemetry = readKnowledgeHits(runtimeDir).length > 0
298
+ const lastEventAt = resolveLastEventAt(runtimeDir)
246
299
  const result = buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays })
247
300
  const frIndex = buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays })
248
301
 
249
302
  if (asJson) {
250
- outputJson(true, { sinceDays, hasTelemetry, ...result, frIndex })
303
+ outputJson(true, { sinceDays, hasTelemetry, lastEventAt, ...result, frIndex })
251
304
  return
252
305
  }
253
306
 
@@ -281,7 +334,7 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
281
334
  lines.push('')
282
335
  }
283
336
 
284
- lines.push(`遥测计数(窗口内):注入 ${result.totalInjects} 次 | 归类 ${result.totalClassifies} 次`)
337
+ lines.push(`遥测计数(窗口内):注入 ${result.totalInjects} 次 | 归类 ${result.totalClassifies} 次${lastEventAt ? ` | 数据截至 ${lastEventAt.slice(0, 10)}` : ''}`)
285
338
 
286
339
  // FR 索引实验(L3 裁决仪表盘——证伪条款出数面;裁决时建议 --since-days 大窗口读全口径)
287
340
  if (frIndex.present) {
@@ -296,6 +349,15 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
296
349
  }
297
350
  lines.push(` 索引面:${i.entries} 条(active ${i.active} / superseded ${i.superseded})| 来源变更 ${i.sourceChanges} 个 | 域 ${i.domains.join(', ') || '—'}`)
298
351
  lines.push(` 承接引用率:${frIndex.supersedeRate === null ? '—(索引空)' : `${(frIndex.supersedeRate * 100).toFixed(0)}%(${e.frSupersedeChanges}/${i.sourceChanges},分子窗口内/分母全量——裁决用大窗口)`}`)
352
+ // 裁决候选视图(2026-10-03-fr-governance-telemetry):条目级 id 聚合(事件 frIds/ids,帽 20 抽样
353
+ // 为下界)。候选非裁决——高 suspect 天然来自热文件;处置出口:确认无人承接 → 承接翻链或人工退休。
354
+ const cand = frIndex.adjudicationCandidates || []
355
+ if (cand.length > 0) {
356
+ lines.push(' 裁决候选 Top-10(suspect×unref 条目级聚合——候选非裁决,确认无人承接→承接翻链或人工退休):')
357
+ for (const c of cand.slice(0, 10)) {
358
+ lines.push(` - ${c.id} suspect×${c.suspect} unref×${c.unreferenced}${c.change ? `(来源 ${c.change})` : ''}`)
359
+ }
360
+ }
299
361
  }
300
362
 
301
363
  console.log(lines.join('\n'))
@@ -0,0 +1,160 @@
1
+ /**
2
+ * knowledge-vector.js — 平台向量召回层(CLI 侧,2026-09-29-knowledge-vector-recall)
3
+ *
4
+ * 检索三层:路由 tag 命中 →(零命中)平台向量召回(本模块,已连接平台时)→ 本地词片
5
+ * 复现窗口(knowledge-match.js fallbackByQueryShingles)→ 空。
6
+ *
7
+ * 原则(用户裁决 2026-09-29):平台只做语义召回——返回「哪个文件哪个条目」的候选
8
+ * (spec_path+anchor+score);条目 status/deathPath/回显资格等全部策略面在本地解析
9
+ * (本地文件是真相源,向量结果不携带策略语义)。CLI 本地零模型约束不变。
10
+ *
11
+ * 端点契约(SillyHub 侧实现规格,测试夹具按此 mock):
12
+ * POST {platform.url}/api/spec/knowledge/vector-search
13
+ * Headers: Authorization: Bearer <token>; Content-Type: application/json
14
+ * Body: { "query": "<检索文本>", "limit": 10 }
15
+ * 200 → { "ok": true, "results": [
16
+ * { "spec_path": "knowledge/decisions/unmapped.md", "anchor": "D-001@v1",
17
+ * "change": "2026-09-26-thin-agent-tasks", "score": 0.83 } ] }
18
+ * anchor = 决策条目 id(## D-xxx@vN);change = 条目「变更:」字段——同号条目跨变更常见
19
+ * (runtime.md 七个 D-001@v1 先例),change 在场时消歧;缺省时同号条目全量带回(候选诚实)。
20
+ *
21
+ * 降级纪律:任何失败(未连接/开关关闭/网络/404/5xx/超时/响应形态不符)静默降级本地层
22
+ * ——检索是 advisory 面且高频(每方案步 --done),失败刷屏即狼来了;debug 排障可设
23
+ * SILLYSPEC_KNOWLEDGE_VECTOR_DEBUG=1 打单行stderr。
24
+ */
25
+ import { readFileSync } from 'fs'
26
+ import { join, dirname } from 'path'
27
+ import { readPlatformConfig } from './sync.js'
28
+ import { matchByRouting, fallbackByQueryShingles, parseDecisionEntries } from './knowledge-match.js'
29
+
30
+ const VECTOR_TIMEOUT_MS = 3000
31
+ const ENDPOINT_PATH = '/api/spec/knowledge/vector-search'
32
+
33
+ /** local.yaml knowledge.vector_search 开关(行级扫描 knowledge: 段下 vector_search: off)。 */
34
+ function vectorSwitchFromLocalYaml(cwd) {
35
+ try {
36
+ const lines = readFileSync(join(cwd, '.sillyspec', 'local.yaml'), 'utf-8').split(/\r?\n/)
37
+ let inKnowledge = false
38
+ for (const line of lines) {
39
+ const top = line.match(/^([A-Za-z_][\w-]*):\s*$/)
40
+ if (top) { inKnowledge = top[1] === 'knowledge'; continue }
41
+ if (!inKnowledge) continue
42
+ const kv = line.match(/^\s+vector_search\s*:\s*(\S+)/)
43
+ if (kv) return kv[1].toLowerCase()
44
+ }
45
+ } catch { /* 无 local.yaml = 缺省 auto */ }
46
+ return 'auto'
47
+ }
48
+
49
+ function vectorEnabled(cwd) {
50
+ const env = String(process.env.SILLYSPEC_KNOWLEDGE_VECTOR || '').toLowerCase()
51
+ if (env === 'off' || env === 'auto') return env === 'auto'
52
+ return vectorSwitchFromLocalYaml(cwd) !== 'off'
53
+ }
54
+
55
+ /**
56
+ * 平台向量召回(纯 HTTP 层)。未连接/失败/形态不符 → null(调用方降级)。
57
+ * @returns {Promise<{results: Array<{spec_path: string, anchor: string, score: number}>}|null>}
58
+ */
59
+ export async function platformVectorRecall({ cwd, query, limit = 10, timeoutMs = VECTOR_TIMEOUT_MS }) {
60
+ const cfg = readPlatformConfig(cwd)
61
+ if (!cfg || !cfg.url || !cfg.token) return null
62
+ const q = String(query || '').trim().slice(0, 4000)
63
+ if (!q) return null
64
+ try {
65
+ const res = await fetch(`${String(cfg.url).replace(/\/+$/, '')}${ENDPOINT_PATH}`, {
66
+ method: 'POST',
67
+ headers: { Authorization: `Bearer ${cfg.token}`, 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ query: q, limit }),
69
+ signal: AbortSignal.timeout(timeoutMs),
70
+ })
71
+ if (!res.ok) {
72
+ if (process.env.SILLYSPEC_KNOWLEDGE_VECTOR_DEBUG) console.error(`[knowledge-vector] ${res.status} ${ENDPOINT_PATH}——降级本地`)
73
+ return null
74
+ }
75
+ const body = await res.json().catch(() => null)
76
+ const results = body && Array.isArray(body.results)
77
+ ? body.results.filter((r) => r && typeof r.spec_path === 'string' && typeof r.anchor === 'string')
78
+ : []
79
+ return results.length > 0 ? { results } : null
80
+ } catch {
81
+ if (process.env.SILLYSPEC_KNOWLEDGE_VECTOR_DEBUG) console.error(`[knowledge-vector] 请求失败/超时——降级本地`)
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** spec_path(knowledge/decisions/x.md)→ INDEX 相对 file(decisions/x.md)。 */
87
+ function specPathToFile(specPath) {
88
+ return String(specPath).replace(/^knowledge\//, '').replace(/\\/g, '/')
89
+ }
90
+
91
+ /** 平台候选 → matchKnowledge 同构结果(策略面全本地解析)。 */
92
+ function buildResultFromPlatform(indexDir, pvResults) {
93
+ const byFile = new Map()
94
+ for (const r of pvResults) {
95
+ const file = specPathToFile(r.spec_path)
96
+ if (!file.startsWith('decisions/')) continue
97
+ if (!byFile.has(file)) byFile.set(file, [])
98
+ byFile.get(file).push(r)
99
+ }
100
+ if (byFile.size === 0) return null
101
+ const all = parseDecisionEntries(indexDir)
102
+ const entries = []
103
+ const decisionHits = []
104
+ for (const [file, rs] of byFile) {
105
+ const wanted = new Set(rs.map((r) => r.anchor))
106
+ const fileEntries = all.filter((h) => h.file === file && wanted.has(h.id))
107
+ if (fileEntries.length === 0) continue
108
+ for (const r of rs) {
109
+ // 同号消歧:change 在场取精确条目;缺省同号全量带回(跨变更同号是已知形态)
110
+ const matched = r.change
111
+ ? fileEntries.filter((e) => e.id === r.anchor && e.change === r.change)
112
+ : fileEntries.filter((e) => e.id === r.anchor)
113
+ for (const h of matched.length > 0 ? matched : []) {
114
+ decisionHits.push({ ...h, score: typeof r.score === 'number' ? r.score : 1 })
115
+ }
116
+ }
117
+ entries.push({
118
+ category: 'Decisions',
119
+ keywords: ['vector'],
120
+ file,
121
+ anchor: rs[0].anchor,
122
+ display: `${file}(平台向量召回)`,
123
+ })
124
+ }
125
+ if (decisionHits.length === 0) return null
126
+ decisionHits.sort((a, b) =>
127
+ (b.score - a.score) ||
128
+ (((b.status === 'rejected' || b.deathPath) ? 1 : 0) - ((a.status === 'rejected' || a.deathPath) ? 1 : 0))
129
+ )
130
+ // 缺 change 的同号锚点全量带回可放大(unmapped 实测 65 个 D-001@v1)——score 序封顶 20
131
+ if (decisionHits.length > 20) decisionHits.length = 20
132
+ return {
133
+ matched: true,
134
+ entries: entries.slice(0, 3),
135
+ report: `Status: platform-vector matched (${decisionHits.length} entries)`,
136
+ json: {
137
+ matched: true, entry_count: Math.min(entries.length, 3), vector: true,
138
+ entries: entries.slice(0, 3).map((e) => ({ file: e.file, anchor: e.anchor, keywords: e.keywords, category: e.category })),
139
+ },
140
+ decisionHits,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 异步三层检索入口(四消费方使用:flow 注入段/complete 门/prompt {DECISION_HITS}/
146
+ * knowledge search CLI):路由 → 平台向量(已连接且开关开)→ 本地词片 → 空。
147
+ * opts.cwd = 仓根(平台配置与 local.yaml 定位用);缺省由 indexDir 上推两级。
148
+ */
149
+ export async function matchKnowledgeHybrid(indexDir, taskContext, opts = {}) {
150
+ const r = matchByRouting(indexDir, taskContext)
151
+ if (r.matched) return r
152
+ const cwd = opts.cwd || dirname(dirname(String(indexDir)))
153
+ if (vectorEnabled(cwd)) {
154
+ const pv = await platformVectorRecall({ cwd, query: taskContext, limit: opts.limit || 10 })
155
+ const built = pv ? buildResultFromPlatform(indexDir, pv.results) : null
156
+ if (built) return built
157
+ }
158
+ const fb = fallbackByQueryShingles(indexDir, taskContext)
159
+ return fb || r
160
+ }
@@ -141,7 +141,7 @@ function greenCachedCheck({ kind, greenRoot, cwd, specBase, changeName }, runRea
141
141
  return result;
142
142
  }
143
143
 
144
- export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor, ctx = null, full = false } = {}) {
144
+ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor, ctx = null, full = false, docsOnly = false } = {}) {
145
145
  const specRoot = specBase || resolveSpecDir(cwd);
146
146
  // A4:pm 用 specRoot(而非默认 resolveSpecDir(cwd))——--spec-dir/平台模式下 specBase 是真实
147
147
  // specDir,默认解析会指向本地孤儿库,与下方 planContent/changeDir 的 specRoot 两套事实源混拼。
@@ -312,7 +312,28 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
312
312
  }
313
313
 
314
314
  // ── d. verify 阶段追加 verify-test(CLI 实测 local.yaml commands.test)──
315
- if (stage === 'verify') {
315
+ // docsOnly 预检档(2026-10-06-verify-docs-prefill task-01):纯文档契约缺项(visual-evidence/
316
+ // 结论槽/移交项等)毫秒级可查,却因 gate verify 含测试执行而整体变贵(postmortem 实证
317
+ // --json 超时转后台)——本档跳过 test/lint 执行(informational 占位明示),其余检查照跑;
318
+ // 测试客观核验仍以完整 gate verify / verify --done 为准。
319
+ if (stage === 'verify' && docsOnly) {
320
+ checks.push({
321
+ id: 'verify-test',
322
+ code: checkCode('verify-test'),
323
+ ok: true,
324
+ informational: true,
325
+ warnings: ['verify-test 跳过(--docs-only 预检档)——本档只核文档/契约面不执行测试;测试客观核验以完整 gate verify(不带 --docs-only)或 verify --done 为准'],
326
+ data: { status: 'docs-only-skip' },
327
+ });
328
+ checks.push({
329
+ id: 'verify-lint',
330
+ code: checkCode('verify-lint'),
331
+ ok: true,
332
+ informational: true,
333
+ warnings: ['verify-lint 跳过(--docs-only 预检档)——同上,lint 客观核验以完整 gate 或 verify --done 为准'],
334
+ data: { status: 'docs-only-skip' },
335
+ });
336
+ } else if (stage === 'verify') {
316
337
  // 绿缓存根:与 runtimeRoot 解析同源(drift 锚主仓口径);解析失败 null → 不缓存不命中
317
338
  let greenRoot = null;
318
339
  try { greenRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot); } catch { greenRoot = null; }
@@ -473,6 +494,32 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
473
494
  }
474
495
  }
475
496
 
497
+ // ── e3. stage-review 默认档提示(2026-10-07-flow-friction-batch3 task-01)── postmortem
498
+ // (provider-model-list):gate execute 默认档不含 Stage Review 检查(在 --full 档),
499
+ // agent 跑 gate 见 ok、--done 却被 Stage Review Gate 拦。默认档补 informational 探测
500
+ // (同一 getLatestStageReviewRunId 只读 marker 存在性,秒级):缺 review → warning 带
501
+ // register 指引;在场 → 静默 ok。独立 check id 不与 --full 的 full-stage-review 碰撞,
502
+ // error 语义(--done 拦)仍归 --full 档与 --done 本体。
503
+ if (!full && (stage === 'verify' || stage === 'execute')) {
504
+ try {
505
+ const { getLatestStageReviewRunId } = await import('./stage-review.js');
506
+ const rrRoot = runtimeRoot || join(specRoot, '.runtime');
507
+ const runId = getLatestStageReviewRunId(rrRoot, stage, changeName);
508
+ checks.push({
509
+ id: 'stage-review-hint',
510
+ code: 'stage_review_hint',
511
+ ok: true,
512
+ informational: true,
513
+ warnings: runId ? [] : [
514
+ `缺 ${stage} 阶段的 stage review.json——verify/execute --done 的 Stage Review Gate 会拦(本提示不改变 gate 结论)。先产出评审再注册:sillyspec register-stage-review --change ${changeName} --stage ${stage}(骨架生成后填,或 --from review.json 过继独立评审产出)`,
515
+ ],
516
+ data: { runId: runId || null },
517
+ });
518
+ } catch (e) {
519
+ // fail-open:探测装配失败不留痕(默认档零行为变化承诺)
520
+ }
521
+ }
522
+
476
523
  // ── 综合结论:所有非 informational check 均 ok ──
477
524
  const ok = checks.filter((c) => !c.informational).every((c) => c.ok);
478
525
  const exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
@@ -854,3 +901,44 @@ export function writeGateResultArtifact({ specBase, stage, changeName, envelope
854
901
  return null // fail-soft:落盘是审计附件,失败只缺附件不误门
855
902
  }
856
903
  }
904
+
905
+ /**
906
+ * gate last 的读取面(2026-10-06-verify-friction-fix task-02):writeVerifyGatePointer(gates.js
907
+ * quick-D)只写不读——看上轮 verify --done blocked 原因只能重跑全量 gate(超时转后台)或手翻
908
+ * 两层 JSON。本函数读稳定指针并带上其指向取证目录里的 reconcile/probe-consistency 明细。
909
+ * run 目录用指针内 latest_run_dir 的 basename 在当前根下重推导(指针里的绝对路径可能来自
910
+ * 另一机器/根,跨环境直接拼会静默指空)。fail-soft:指针缺失 → found:false;损坏 → unreadable。
911
+ * @param {{ specBase: string, changeName: string }} opts
912
+ * @returns {{ found: boolean, pointerPath: string, unreadable?: boolean, error?: string } &
913
+ * Partial<{ blocked: boolean, note: string, latest_run_dir: string, run_dir_resolved: string,
914
+ * written_at: string, reconcile: object, probeConsistency: object }>}
915
+ */
916
+ export function summarizeVerifyGatePointer({ specBase, changeName }) {
917
+ const runsDir = join(specBase, '.runtime', 'verify-runs')
918
+ const pointerPath = join(runsDir, `gate-last-${changeName || 'change'}.json`)
919
+ if (!existsSync(pointerPath)) return { found: false, pointerPath }
920
+ let pointer
921
+ try {
922
+ pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
923
+ } catch (e) {
924
+ return { found: true, unreadable: true, pointerPath, error: e.message }
925
+ }
926
+ const out = { found: true, pointerPath, ...pointer }
927
+ const runDirName = pointer.latest_run_dir
928
+ ? String(pointer.latest_run_dir).split(/[\\/]/).filter(Boolean).pop()
929
+ : null
930
+ if (runDirName) {
931
+ const runDir = join(runsDir, runDirName)
932
+ out.run_dir_resolved = runDir
933
+ for (const [key, file] of [['reconcile', 'reconcile-result.json'], ['probeConsistency', 'probe-consistency-result.json']]) {
934
+ const p = join(runDir, file)
935
+ if (!existsSync(p)) continue
936
+ try {
937
+ out[key] = JSON.parse(readFileSync(p, 'utf8'))
938
+ } catch (e) {
939
+ out[key] = { unreadable: true, error: e.message }
940
+ }
941
+ }
942
+ }
943
+ return out
944
+ }
@@ -26,9 +26,12 @@ import { resolveSpecDir } from './run/shared.js'
26
26
  export function parseModuleMapPaths(yamlText) {
27
27
  const map = new Map()
28
28
  if (!yamlText) return map
29
+ // CRLF 归一(Windows 生成的 map:块式行靠 \s*$/.trim() 蒙混,内联数组正则恒失配——
30
+ // sillyhub-daemon 整图内联 CRLF 实证漏 343 条)
31
+ const text = String(yamlText).replace(/\r\n/g, '\n')
29
32
  let currentModule = null
30
33
  let inPaths = false
31
- for (const line of yamlText.split('\n')) {
34
+ for (const line of text.split('\n')) {
32
35
  if (!line.trim() || line.trim().startsWith('#')) continue
33
36
  if (!/^\s/.test(line)) {
34
37
  inPaths = line.startsWith('modules:')
@@ -41,6 +44,18 @@ export function parseModuleMapPaths(yamlText) {
41
44
  inPaths = false
42
45
  continue
43
46
  }
47
+ // 内联 paths 数组(ground truth 审计实证:sillyhub-daemon 整图内联写法,本解析器此前
48
+ // 恒漏 343 条 paths=归属全盲——对齐 parseModulePathsSubset / parseModuleMapSimple 的既有内联支持)
49
+ const inlinePaths = currentModule && line.match(/^ paths: \[(.*)\]$/)
50
+ if (inlinePaths) {
51
+ if (!map.has(currentModule)) map.set(currentModule, [])
52
+ for (const v of inlinePaths[1].split(',').map(s => s.trim()).filter(Boolean)) {
53
+ const unquoted = v.replace(/^(['"])([\s\S]*)\1$/, '$2')
54
+ map.get(currentModule).push(unquoted.replace(/\\/g, '/'))
55
+ }
56
+ inPaths = false
57
+ continue
58
+ }
44
59
  if (currentModule && /^ paths:\s*$/.test(line)) {
45
60
  inPaths = true
46
61
  if (!map.has(currentModule)) map.set(currentModule, [])
@@ -56,8 +71,14 @@ export function parseModuleMapPaths(yamlText) {
56
71
  const raw = item[1].trim()
57
72
  const unquoted = raw.replace(/^(['"])([\s\S]*)\1$/, '$2')
58
73
  map.get(currentModule).push(unquoted.replace(/\\/g, '/'))
74
+ continue
59
75
  }
60
76
  }
77
+ // 结构守卫(坑 module-map-list-leak,2026-10-06-wallclock-entry 实测发现):paths 收集只在
78
+ // 「6 缩进项行 / 空行 / 注释」上存活,其余任何行——字段头(字段名开放集不枚举)、顶层段
79
+ // (span_risk:/blast:)、异缩进项(2 缩进 "- prefixes:")——终结收集,防 tags/aliases/
80
+ // depends_on 字段项与顶层 blast prefixes 漏进 paths 污染 classifyFile 归属分类。
81
+ inPaths = false
61
82
  }
62
83
  return map
63
84
  }