sillyspec 3.19.2 → 3.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -297,45 +297,55 @@ const fixedSuffix = [
297
297
  先检查当前 worktree 的隔离模式:
298
298
  \`\`\`bash
299
299
  node -e "import('./src/worktree.js').then(w => { const wm = new w.WorktreeManager(); const m = wm.getMeta('<change-name>'); console.log(m ? JSON.stringify({mode: m.mode, path: m.worktreePath}) : 'no meta'); })"
300
- # 或从 DB 读取:
301
- sqlite3 -json .sillyspec/.runtime/sillyspec.db "SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name='<change-name>'" 2>/dev/null
302
300
  \`\`\`
303
301
 
304
302
  ### 操作(mode = worktree,SillySpec 创建的隔离 worktree)
305
- 1. 运行 \`sillyspec worktree apply --check-only <change-name>\`
306
- 2. 展示 diff 摘要(文件列表 + 变更统计)
307
- 3. 检查结果说明(是否通过文件清单校验)
308
- 4. 用户确认后运行 \`sillyspec worktree apply <change-name>\`
309
- 5. apply 成功 → 运行 \`sillyspec worktree cleanup <change-name>\` → 输出 Worktree: cleaned
310
- 6. apply 失败 → 展示错误详情,用户选择重试或手动处理
311
- 7. 如果用户不想 apply → 运行 \`sillyspec worktree cleanup <change-name>\` 丢弃
312
- 8. 建议下一步:\`sillyspec run verify\`
303
+
304
+ **自动审计流程(不需要用户确认代码):**
305
+
306
+ 1. 运行 \`sillyspec worktree assess <change-name>\` 自动风险审计
307
+ 2. 系统自动检查:
308
+ - patch --check 是否通过
309
+ - 变更是否在 allowed_paths
310
+ - 主工作区 baseline 是否变化
311
+ - 是否有高风险文件(lockfile/migration/配置/入口)
312
+ - diff 规模是否异常
313
+ 3. 输出 Apply Decision:
314
+
315
+ \`\`\`
316
+ Worktree Apply Decision
317
+ ────────────────────────
318
+ Decision: SAFE | WARNING | BLOCKED
319
+ Changed files: N
320
+ Additions: +N Deletions: -N
321
+ Risky files: none | <list>
322
+ Action: auto-applied | blocked
323
+ \`\`\`
324
+
325
+ 4. **SAFE** → 自动 \`sillyspec worktree apply <change-name>\` + cleanup
326
+ 5. **WARNING** → 自动 apply(有警告但不阻断)+ cleanup
327
+ 6. **BLOCKED** → 不 apply,输出原因,提示用户检查:
328
+ - \`sillyspec worktree diff <change-name>\` 查看具体变更
329
+ - \`sillyspec worktree cleanup <change-name>\` 丢弃
330
+ 7. 建议下一步:\`sillyspec run verify\`
313
331
 
314
332
  ### 操作(mode = native-worktree,用户已有的 linked worktree)
315
- 1. 运行 \`sillyspec worktree apply --check-only <change-name>\`
316
- 2. 展示 diff 摘要
317
- 3. 用户确认后运行 \`sillyspec worktree apply <change-name>\`
318
- 4. **不要运行 cleanup** — 这是用户自己的 worktree,SillySpec 不能删除
319
- 5. 输出 Worktree: kept(SillySpec 未创建此 worktree,保留不动)
320
- 6. 建议下一步:\`sillyspec run verify\`
333
+ 1. 同上自动审计流程
334
+ 2. SAFE/WARNING \`sillyspec worktree apply <change-name>\`
335
+ 3. **不要运行 cleanup**
336
+ 4. 输出 Worktree: kept
337
+ 5. 建议下一步:\`sillyspec run verify\`
321
338
 
322
339
  ### 操作(mode = in-place-fallback,降级模式无隔离目录)
323
340
  1. 展示本次执行摘要(\`git diff\` 查看变更)
324
- 2. 跳过 apply 和 cleanup(没有隔离 worktree)
325
- 3. 输出 Worktree: none(降级为 in-place,无隔离目录需要清理)
326
- 4. 建议下一步:\`sillyspec run verify\`
327
-
328
- ### 操作(无 worktree / --no-worktree 模式)
329
- 1. 展示本次执行摘要
330
- 2. 输出 Worktree: none
331
- 3. 提示用户直接使用 \`git diff\` 查看变更
341
+ 2. 跳过 apply 和 cleanup
342
+ 3. 输出 Worktree: none
332
343
  4. 建议下一步:\`sillyspec run verify\`
333
344
 
334
345
  ### 输出
335
- apply 结果 + 下一步建议(或执行摘要)
346
+ Apply Decision + 下一步建议
336
347
 
337
348
  ### 注意
338
- - 如果用户不想 apply → 运行 cleanup 丢弃
339
349
  - 完成后运行 \`sillyspec run execute --done\` 即可自动推进阶段`,
340
350
  outputHint: 'apply 结果',
341
351
  optional: false
@@ -0,0 +1,498 @@
1
+ /**
2
+ * knowledge.js — agent-safe knowledge 管理命令
3
+ *
4
+ * 设计原则:
5
+ * - 所有输出为 JSON(--json 是默认行为,不需要显式传)
6
+ * - agent 可安全调用,不打开编辑器,不直接覆盖人工区
7
+ * - 失败有明确错误码
8
+ *
9
+ * 子命令:
10
+ * search — 关键词搜索知识库
11
+ * inspect — 读取单条知识详情
12
+ * validate — 校验知识库完整性
13
+ * refresh — 从 scan 文档刷新自动知识(仅写 generated/)
14
+ * propose — 提议新知识(写入 proposed/)
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, statSync } from 'fs'
18
+ import { join, basename } from 'path'
19
+ import { parseKnowledgeIndex, matchKnowledge } from '../knowledge-match.js'
20
+
21
+ // ── 工具函数 ──
22
+
23
+ function resolveKnowledgeDir(dir, specDir) {
24
+ const base = specDir || join(dir, '.sillyspec')
25
+ return join(base, 'knowledge')
26
+ }
27
+
28
+ function output(ok, data, error) {
29
+ const result = { ok, ...data }
30
+ if (error) result.error = error
31
+ console.log(JSON.stringify(result, null, 2))
32
+ }
33
+
34
+ /**
35
+ * 从文件路径生成知识 ID
36
+ * knowledge/conventions.md → conventions
37
+ * knowledge/generated/patterns.md → generated/patterns
38
+ */
39
+ function pathToId(basePath, knowledgeDir) {
40
+ let rel = basePath
41
+ if (rel.startsWith(knowledgeDir + '/')) rel = rel.slice(knowledgeDir.length + 1)
42
+ if (rel.endsWith('.md')) rel = rel.slice(0, -3)
43
+ return rel
44
+ }
45
+
46
+ /**
47
+ * 从知识条目提取摘要(取第一段非空非标题文本)
48
+ */
49
+ function extractSummary(content, maxLen = 120) {
50
+ const lines = content.split('\n')
51
+ for (const line of lines) {
52
+ const trimmed = line.trim()
53
+ if (!trimmed) continue
54
+ if (trimmed.startsWith('#')) continue
55
+ if (trimmed.startsWith('---')) continue
56
+ if (trimmed.startsWith('author:') || trimmed.startsWith('created_at:')) continue
57
+ if (trimmed.startsWith('>')) continue
58
+ if (trimmed.startsWith('<!--')) continue
59
+ return trimmed.slice(0, maxLen) + (trimmed.length > maxLen ? '...' : '')
60
+ }
61
+ return ''
62
+ }
63
+
64
+ /**
65
+ * 提取文件标题(第一个 # 标题)
66
+ */
67
+ function extractTitle(content) {
68
+ const match = content.match(/^#\s+(.+)$/m)
69
+ return match ? match[1].trim() : ''
70
+ }
71
+
72
+ // ── search ──
73
+
74
+ export async function cmdSearch(dir, args, opts = {}) {
75
+ const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
76
+ const queryIdx = args.indexOf('--query')
77
+ const query = queryIdx >= 0 && args[queryIdx + 1] ? args[queryIdx + 1] : ''
78
+ const limitIdx = args.indexOf('--limit')
79
+ const limit = limitIdx >= 0 && args[limitIdx + 1] ? parseInt(args[limitIdx + 1]) : 10
80
+
81
+ if (!query) {
82
+ output(false, {}, '--query is required')
83
+ return
84
+ }
85
+
86
+ if (!existsSync(knowledgeDir)) {
87
+ output(false, {}, { code: 'knowledge_dir_missing', path: knowledgeDir })
88
+ return
89
+ }
90
+
91
+ // 复用 knowledge-match 引擎
92
+ const result = matchKnowledge(knowledgeDir, query)
93
+
94
+ if (!result.matched) {
95
+ output(true, { query, matches: [] })
96
+ return
97
+ }
98
+
99
+ // 增强:为每个匹配项补充 path、title、summary、score
100
+ const matches = result.entries.slice(0, limit).map(entry => {
101
+ const filePath = join(knowledgeDir, entry.file)
102
+ let title = entry.display
103
+ let summary = ''
104
+ let score = entry.keywords.length // 简单评分:匹配关键词数量
105
+
106
+ if (existsSync(filePath)) {
107
+ const content = readFileSync(filePath, 'utf8')
108
+ title = extractTitle(content) || entry.display
109
+ summary = extractSummary(content)
110
+ }
111
+
112
+ return {
113
+ id: pathToId(entry.file, knowledgeDir),
114
+ path: join('knowledge', entry.file),
115
+ title,
116
+ summary,
117
+ score,
118
+ tags: entry.keywords,
119
+ category: entry.category,
120
+ }
121
+ })
122
+
123
+ output(true, { query, matches })
124
+ }
125
+
126
+ // ── inspect ──
127
+
128
+ export async function cmdInspect(dir, args, opts = {}) {
129
+ const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
130
+ const idIdx = args.indexOf('--id')
131
+ const id = idIdx >= 0 && args[idIdx + 1] ? args[idIdx + 1] : ''
132
+
133
+ if (!id) {
134
+ output(false, {}, '--id is required')
135
+ return
136
+ }
137
+
138
+ if (!existsSync(knowledgeDir)) {
139
+ output(false, {}, { code: 'knowledge_dir_missing', path: knowledgeDir })
140
+ return
141
+ }
142
+
143
+ // ID → 文件路径:conventions → knowledge/conventions.md
144
+ // generated/patterns → knowledge/generated/patterns.md
145
+ const filePath = join(knowledgeDir, id + '.md')
146
+
147
+ if (!existsSync(filePath)) {
148
+ output(false, {}, { code: 'not_found', id, path: filePath })
149
+ return
150
+ }
151
+
152
+ const content = readFileSync(filePath, 'utf8')
153
+ const title = extractTitle(content)
154
+ const summary = extractSummary(content)
155
+
156
+ // 元数据
157
+ const meta = {}
158
+ const authorMatch = content.match(/^author:\s*(.+)$/m)
159
+ const createdAtMatch = content.match(/^created_at:\s*(.+)$/m)
160
+ if (authorMatch) meta.author = authorMatch[1].trim()
161
+ if (createdAtMatch) meta.created_at = createdAtMatch[1].trim()
162
+
163
+ // 文件修改时间
164
+ const stat = statSync(filePath)
165
+ meta.updated_at = stat.mtime.toISOString()
166
+
167
+ // 分区检测
168
+ let zone = 'manual'
169
+ if (id.startsWith('generated/')) zone = 'generated'
170
+ else if (id.startsWith('proposed/')) zone = 'proposed'
171
+
172
+ output(true, {
173
+ entry: {
174
+ id,
175
+ title,
176
+ summary,
177
+ zone,
178
+ path: join('knowledge', id + '.md'),
179
+ meta,
180
+ body: content,
181
+ }
182
+ })
183
+ }
184
+
185
+ // ── validate ──
186
+
187
+ export async function cmdValidate(dir, args, opts = {}) {
188
+ const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
189
+ const errors = []
190
+ const warnings = []
191
+
192
+ if (!existsSync(knowledgeDir)) {
193
+ output(false, { errors, warnings }, { code: 'knowledge_dir_missing', path: knowledgeDir })
194
+ return
195
+ }
196
+
197
+ // 1. INDEX.md 存在
198
+ const indexPath = join(knowledgeDir, 'INDEX.md')
199
+ if (!existsSync(indexPath)) {
200
+ errors.push({ code: 'missing_index', path: 'knowledge/INDEX.md' })
201
+ } else {
202
+ // 2. INDEX.md 引用的文件是否存在
203
+ const entries = parseKnowledgeIndex(knowledgeDir)
204
+ for (const entry of entries) {
205
+ const refPath = join(knowledgeDir, entry.file)
206
+ if (!existsSync(refPath)) {
207
+ errors.push({
208
+ code: 'broken_reference',
209
+ path: join('knowledge', entry.file),
210
+ referenced_in: 'INDEX.md',
211
+ display: entry.display,
212
+ })
213
+ }
214
+ }
215
+ }
216
+
217
+ // 3. 扫描所有 .md 文件,检查是否在 INDEX 中注册
218
+ const allMdFiles = scanMdFiles(knowledgeDir, '')
219
+ const indexedFiles = new Set(
220
+ parseKnowledgeIndex(knowledgeDir).map(e => e.file)
221
+ )
222
+
223
+ for (const mdFile of allMdFiles) {
224
+ if (mdFile === 'INDEX.md') continue
225
+ // generated/ 和 proposed/ 区不强制要求 INDEX 注册
226
+ if (mdFile.startsWith('generated/') || mdFile.startsWith('proposed/')) continue
227
+ if (!indexedFiles.has(mdFile)) {
228
+ warnings.push({
229
+ code: 'unregistered_file',
230
+ path: join('knowledge', mdFile),
231
+ })
232
+ }
233
+ }
234
+
235
+ // 4. uncategorized.md 条目数(多了提示需清理)
236
+ const uncategorizedPath = join(knowledgeDir, 'uncategorized.md')
237
+ if (existsSync(uncategorizedPath)) {
238
+ const content = readFileSync(uncategorizedPath, 'utf8')
239
+ const entryCount = (content.match(/^###\s+/gm) || []).length
240
+ if (entryCount >= 10) {
241
+ warnings.push({
242
+ code: 'too_many_uncategorized',
243
+ count: entryCount,
244
+ path: 'knowledge/uncategorized.md',
245
+ })
246
+ }
247
+ }
248
+
249
+ // 5. 空文件检测
250
+ for (const mdFile of allMdFiles) {
251
+ if (mdFile === 'INDEX.md') continue
252
+ const fullPath = join(knowledgeDir, mdFile)
253
+ const content = readFileSync(fullPath, 'utf8')
254
+ if (content.trim().length === 0) {
255
+ errors.push({
256
+ code: 'empty_file',
257
+ path: join('knowledge', mdFile),
258
+ })
259
+ }
260
+ }
261
+
262
+ output(errors.length === 0, { errors, warnings })
263
+ }
264
+
265
+ // ── refresh ──
266
+
267
+ export async function cmdRefresh(dir, args, opts = {}) {
268
+ const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
269
+ const generatedDir = join(knowledgeDir, 'generated')
270
+
271
+ if (!existsSync(knowledgeDir)) {
272
+ output(false, {}, { code: 'knowledge_dir_missing', path: knowledgeDir })
273
+ return
274
+ }
275
+
276
+ // refresh 只写 generated/ 区
277
+ mkdirSync(generatedDir, { recursive: true })
278
+
279
+ // 扫描 scan 文档
280
+ const base = opts.specDir || join(dir, '.sillyspec')
281
+ const projectName = basename(dir)
282
+ const scanDir = join(base, 'docs', projectName, 'scan')
283
+
284
+ if (!existsSync(scanDir)) {
285
+ output(false, {}, { code: 'scan_docs_missing', path: scanDir })
286
+ return
287
+ }
288
+
289
+ const scanFiles = readdirSync(scanDir).filter(f => f.endsWith('.md'))
290
+ const generated = []
291
+ const overwritten = []
292
+
293
+ for (const scanFile of scanFiles) {
294
+ const scanPath = join(scanDir, scanFile)
295
+ const content = readFileSync(scanPath, 'utf8')
296
+ const title = extractTitle(content) || scanFile.replace('.md', '')
297
+
298
+ // 简单提取:取每个 ## 章节作为一个知识条目候选
299
+ const sections = extractSections(content)
300
+ for (const section of sections) {
301
+ if (section.lines < 3) continue // 太短的章节跳过
302
+
303
+ const slug = section.heading.toLowerCase()
304
+ .replace(/[^\w\u4e00-\u9fff]+/g, '-')
305
+ .replace(/^-|-$/g, '')
306
+ .slice(0, 60)
307
+
308
+ const fileName = `${slug}.md`
309
+ const genPath = join(generatedDir, fileName)
310
+ const isNew = !existsSync(genPath)
311
+
312
+ const entryContent = [
313
+ `---`,
314
+ `source: scan/${scanFile}`,
315
+ `section: ${section.heading}`,
316
+ `generated_at: ${new Date().toISOString()}`,
317
+ `---`,
318
+ ``,
319
+ `# ${title} — ${section.heading}`,
320
+ ``,
321
+ section.body,
322
+ ].join('\n')
323
+
324
+ writeFileSync(genPath, entryContent)
325
+ generated.push({ file: `generated/${fileName}`, title: `${title} — ${section.heading}`, new: isNew })
326
+ if (!isNew) overwritten.push(`generated/${fileName}`)
327
+ }
328
+ }
329
+
330
+ // 更新 generated/INDEX.md
331
+ const genIndexPath = join(generatedDir, 'INDEX.md')
332
+ const indexLines = [
333
+ '---',
334
+ `generated_at: ${new Date().toISOString()}`,
335
+ '---',
336
+ '',
337
+ '# Generated Knowledge Index',
338
+ '',
339
+ '> Auto-generated from scan documents. Do not edit manually.',
340
+ '',
341
+ ]
342
+ for (const g of generated) {
343
+ indexLines.push(`- ${g.title} → [${g.file}](${g.file})`)
344
+ }
345
+ writeFileSync(genIndexPath, indexLines.join('\n'))
346
+
347
+ output(true, {
348
+ generated_count: generated.length,
349
+ new_count: generated.filter(g => g.new).length,
350
+ overwritten_count: overwritten.length,
351
+ files: generated,
352
+ })
353
+ }
354
+
355
+ // ── propose ──
356
+
357
+ export async function cmdPropose(dir, args, opts = {}) {
358
+ const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
359
+ const proposedDir = join(knowledgeDir, 'proposed')
360
+
361
+ const titleIdx = args.indexOf('--title')
362
+ const title = titleIdx >= 0 && args[titleIdx + 1] ? args[titleIdx + 1] : ''
363
+ const categoryIdx = args.indexOf('--category')
364
+ const category = categoryIdx >= 0 && args[categoryIdx + 1] ? args[categoryIdx + 1] : 'uncategorized'
365
+ const bodyIdx = args.indexOf('--body')
366
+ const body = bodyIdx >= 0 && args[bodyIdx + 1] ? args[bodyIdx + 1] : ''
367
+ const fromIdx = args.indexOf('--from')
368
+ const from = fromIdx >= 0 && args[fromIdx + 1] ? args[fromIdx + 1] : ''
369
+
370
+ if (!title) {
371
+ output(false, {}, '--title is required')
372
+ return
373
+ }
374
+
375
+ mkdirSync(proposedDir, { recursive: true })
376
+
377
+ // 生成 slug
378
+ const slug = title.toLowerCase()
379
+ .replace(/[^\w\u4e00-\u9fff]+/g, '-')
380
+ .replace(/^-|-$/g, '')
381
+ .slice(0, 60)
382
+
383
+ const fileName = `${slug}.md`
384
+ const propPath = join(proposedDir, fileName)
385
+ const isNew = !existsSync(propPath)
386
+
387
+ const content = [
388
+ '---',
389
+ `proposed_at: ${new Date().toISOString()}`,
390
+ `category: ${category}`,
391
+ ...(from ? [`source: ${from}`] : []),
392
+ 'status: pending_review',
393
+ '---',
394
+ '',
395
+ `# ${title}`,
396
+ '',
397
+ body || '(no body provided)',
398
+ '',
399
+ '---',
400
+ '> This is a proposed knowledge entry. Review and merge into manual/ or generated/.',
401
+ ].join('\n')
402
+
403
+ writeFileSync(propPath, content)
404
+
405
+ output(true, {
406
+ id: `proposed/${slug}`,
407
+ path: `knowledge/proposed/${fileName}`,
408
+ title,
409
+ category,
410
+ new: isNew,
411
+ action: isNew ? 'created' : 'updated',
412
+ })
413
+ }
414
+
415
+ // ── 辅助函数 ──
416
+
417
+ function scanMdFiles(baseDir, prefix) {
418
+ const results = []
419
+ const items = readdirSync(baseDir, { withFileTypes: true })
420
+ for (const item of items) {
421
+ const rel = prefix ? `${prefix}/${item.name}` : item.name
422
+ if (item.isDirectory()) {
423
+ results.push(...scanMdFiles(join(baseDir, item.name), rel))
424
+ } else if (item.name.endsWith('.md')) {
425
+ results.push(rel)
426
+ }
427
+ }
428
+ return results
429
+ }
430
+
431
+ function extractSections(content) {
432
+ const lines = content.split('\n')
433
+ const sections = []
434
+ let currentHeading = null
435
+ let currentLines = []
436
+ let currentBody = []
437
+
438
+ for (const line of lines) {
439
+ if (line.startsWith('## ')) {
440
+ if (currentHeading) {
441
+ sections.push({
442
+ heading: currentHeading,
443
+ body: currentBody.join('\n').trim(),
444
+ lines: currentLines.length,
445
+ })
446
+ }
447
+ currentHeading = line.slice(3).trim()
448
+ currentLines = [line]
449
+ currentBody = []
450
+ } else if (currentHeading) {
451
+ currentLines.push(line)
452
+ // 跳过 frontmatter 和标题行
453
+ if (!line.startsWith('---') && !line.startsWith('#')) {
454
+ currentBody.push(line)
455
+ }
456
+ }
457
+ }
458
+
459
+ if (currentHeading) {
460
+ sections.push({
461
+ heading: currentHeading,
462
+ body: currentBody.join('\n').trim(),
463
+ lines: currentLines.length,
464
+ })
465
+ }
466
+
467
+ return sections.filter(s => s.lines >= 3)
468
+ }
469
+
470
+ // ── 入口路由 ──
471
+
472
+ /**
473
+ * @param {string[]} args - filteredArgs.slice(1)(去掉 'knowledge')
474
+ * @param {string} dir - 项目根目录
475
+ * @param {object} opts - { specDir }
476
+ */
477
+ export async function cmdKnowledge(args, dir, opts = {}) {
478
+ const subCommand = args[0] || ''
479
+
480
+ switch (subCommand) {
481
+ case 'search':
482
+ return cmdSearch(dir, args.slice(1), opts)
483
+ case 'inspect':
484
+ return cmdInspect(dir, args.slice(1), opts)
485
+ case 'validate':
486
+ return cmdValidate(dir, args.slice(1), opts)
487
+ case 'refresh':
488
+ return cmdRefresh(dir, args.slice(1), opts)
489
+ case 'propose':
490
+ return cmdPropose(dir, args.slice(1), opts)
491
+ default:
492
+ output(false, {}, {
493
+ code: 'unknown_subcommand',
494
+ subcommand: subCommand,
495
+ available: ['search', 'inspect', 'validate', 'refresh', 'propose'],
496
+ })
497
+ }
498
+ }