sillyspec 3.20.2 → 3.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
- package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +54 -54
- package/.claude/skills/sillyspec-status/SKILL.md +21 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
- package/.husky/pre-push +13 -13
- package/CLAUDE.md +18 -18
- package/README.md +198 -188
- package/SKILL.md +90 -91
- package/bin/sillyspec.js +2 -2
- package/docs/brainstorm-plan-contract.md +64 -64
- package/docs/plan-execute-contract.md +123 -123
- package/docs/platform-scan-protocol.md +298 -298
- package/docs/revision-mode.md +115 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
- package/docs/sillyspec/file-lifecycle.md +125 -125
- package/docs/workflow-contract-regression.md +106 -106
- package/docs/worktree-isolation.md +252 -252
- package/package.json +40 -40
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/index.html +15 -15
- package/packages/dashboard/package-lock.json +2384 -2384
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +86 -86
- package/packages/dashboard/server/index.js +588 -588
- package/packages/dashboard/server/parser.js +526 -526
- package/packages/dashboard/server/watcher.js +344 -344
- package/packages/dashboard/src/App.vue +558 -558
- package/packages/dashboard/src/components/ActionBar.vue +93 -93
- package/packages/dashboard/src/components/CommandPalette.vue +96 -96
- package/packages/dashboard/src/components/DetailPanel.vue +137 -137
- package/packages/dashboard/src/components/LogStream.vue +65 -65
- package/packages/dashboard/src/components/PipelineStage.vue +95 -95
- package/packages/dashboard/src/components/PipelineView.vue +156 -156
- package/packages/dashboard/src/components/ProjectList.vue +210 -210
- package/packages/dashboard/src/components/StageBadge.vue +67 -67
- package/packages/dashboard/src/components/StepCard.vue +94 -94
- package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
- package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
- package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
- package/packages/dashboard/src/composables/useDashboard.js +170 -170
- package/packages/dashboard/src/composables/useKeyboard.js +119 -119
- package/packages/dashboard/src/composables/useWebSocket.js +129 -129
- package/packages/dashboard/src/main.js +8 -8
- package/packages/dashboard/src/style.css +132 -132
- package/packages/dashboard/vite.config.js +18 -18
- package/src/brainstorm-postcheck.js +158 -158
- package/src/change-list.js +52 -52
- package/src/change-risk-profile.js +352 -352
- package/src/classify-change.js +73 -73
- package/src/constants.js +70 -70
- package/src/contract-matrix.js +278 -278
- package/src/db.js +201 -201
- package/src/endpoint-extractor.js +315 -315
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +653 -653
- package/src/index.js +922 -900
- package/src/init.js +431 -431
- package/src/knowledge-match.js +130 -130
- package/src/migrate.js +117 -117
- package/src/modules.js +482 -482
- package/src/progress.js +1734 -1734
- package/src/run.js +3465 -3358
- package/src/scan-postcheck.js +387 -383
- package/src/setup.js +398 -398
- package/src/stage-contract.js +700 -700
- package/src/stages/archive.js +160 -160
- package/src/stages/brainstorm-auto.js +229 -229
- package/src/stages/brainstorm.js +645 -645
- package/src/stages/doctor.js +365 -365
- package/src/stages/execute.js +625 -625
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +511 -513
- package/src/stages/plan.js +582 -582
- package/src/stages/propose.js +174 -174
- package/src/stages/quick.js +82 -82
- package/src/stages/scan.js +558 -558
- package/src/stages/status.js +65 -65
- package/src/stages/verify.js +322 -322
- package/src/sync.js +497 -497
- package/src/task-review.js +346 -346
- package/src/workflow.js +785 -785
- package/src/worktree-apply.js +549 -549
- package/src/worktree-deps.js +185 -0
- package/src/worktree.js +982 -932
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
- package/test/brainstorm-plan-contract.test.mjs +273 -273
- package/test/check-syntax.mjs +26 -26
- package/test/contract-artifacts.test.mjs +323 -323
- package/test/decision-supersede.test.mjs +277 -277
- package/test/knowledge-match.test.mjs +231 -231
- package/test/plan-execute-contract.test.mjs +330 -330
- package/test/plan-optimization.test.mjs +572 -572
- package/test/platform-artifacts.test.mjs +166 -166
- package/test/platform-failure-samples.test.mjs +199 -199
- package/test/platform-recovery-chain.test.mjs +167 -167
- package/test/platform-recovery.test.mjs +136 -136
- package/test/platform-scan-p0.test.mjs +168 -168
- package/test/revision-v1.test.mjs +1145 -1145
- package/test/run-scan-project-parse.test.mjs +200 -200
- package/test/run-tests.mjs +48 -48
- package/test/scan-knowledge.test.mjs +175 -175
- package/test/scan-paths.test.mjs +68 -68
- package/test/scan-postcheck.test.mjs +197 -197
- package/test/spec-dir.test.mjs +206 -206
- package/test/stage-contract.test.mjs +299 -299
- package/test/stage-definitions.test.mjs +39 -39
- package/test/wait-gates.test.mjs +496 -496
- package/test/worktree-deps-provision.test.mjs +148 -0
- package/test/worktree-guard.test.mjs +71 -71
- package/test/worktree-native-overlay.test.mjs +188 -188
package/src/stages/knowledge.js
CHANGED
|
@@ -1,498 +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
|
-
}
|
|
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
|
+
}
|