dsh-sa-workbench 0.1.30 → 0.1.32
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/README.md +1 -1
- package/client.js +1761 -697
- package/modules/dsh-pm-core/delivery-library.mjs +279 -0
- package/modules/dsh-pm-core/engine.js +1 -1
- package/modules/dsh-pm-core/index.js +41 -1
- package/modules/dsh-pm-core/knowledge-model.mjs +36 -0
- package/modules/dsh-pm-core/knowledge-workflow.mjs +291 -0
- package/modules/dsh-pm-core/product-context.mjs +74 -0
- package/modules/dsh-pm-core/requirement-deletion.mjs +276 -0
- package/modules/dsh-pm-ui/client.js +1761 -697
- package/modules/dsh-pm-ui/index.js +23 -3
- package/modules/dsh-pm-ui/knowledge-model.mjs +36 -0
- package/modules/dsh-pm-ui/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fingerprint, PmError, readDocumentArchiveLedger } from './engine.js'
|
|
4
|
+
import { safeDocumentPath } from './documents.js'
|
|
5
|
+
import { atomicWrite, safePath, withKnowledgeLock, KnowledgeWorkflow, CANDIDATE_PATH } from './knowledge-workflow.mjs'
|
|
6
|
+
|
|
7
|
+
const MAX_DOCUMENT = 2 * 1024 * 1024
|
|
8
|
+
const MAX_CAPTURE = 12 * 1024 * 1024
|
|
9
|
+
const deliveryId = value => {
|
|
10
|
+
if (!/^(?:local|cloud|history|file)-[a-f0-9]{32}$/.test(String(value))) throw new PmError('bad_input', '交付记录编号无效。')
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
const identity = (kind, key) => kind + '-' + fingerprint(key).slice(0, 32)
|
|
14
|
+
const owner = record => record.collaboration?.pmOwner || ''
|
|
15
|
+
const summary = entry => {
|
|
16
|
+
const { origin, ...result } = entry
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DeliveryLibrary {
|
|
21
|
+
constructor(pm) {
|
|
22
|
+
this.pm = pm
|
|
23
|
+
this.root = path.join(pm.root, '.sa-deliveries')
|
|
24
|
+
const library = this
|
|
25
|
+
this.workflow = new KnowledgeWorkflow({
|
|
26
|
+
root: this.root,
|
|
27
|
+
get knowledgeDir() { return pm.knowledgeDir },
|
|
28
|
+
findRequirement: async id => {
|
|
29
|
+
const delivery = await library.get(id)
|
|
30
|
+
return { record: { code: delivery.code || '历史交付', title: delivery.title, archived: false,
|
|
31
|
+
sourceDeliveryId: delivery.id, sourceRequirementId: delivery.requirementId || '' } }
|
|
32
|
+
},
|
|
33
|
+
authorizeArtifact: (_id, relative) => String(relative),
|
|
34
|
+
readDocument: async (id, relative) => relative === CANDIDATE_PATH ? library.candidates(id) : library.read(id, relative),
|
|
35
|
+
listKnowledge: () => pm.listKnowledge(),
|
|
36
|
+
readKnowledge: relative => pm.readKnowledge(relative)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async localEntries() {
|
|
41
|
+
const directory = await safePath(this.root, 'records')
|
|
42
|
+
const names = await fs.readdir(directory).catch(error => { if (error.code === 'ENOENT') return []; throw error })
|
|
43
|
+
const entries = []
|
|
44
|
+
for (const name of names.filter(name => /^local-[a-f0-9]{32}$/.test(name))) {
|
|
45
|
+
const file = await safePath(directory, name + '/manifest.json')
|
|
46
|
+
try {
|
|
47
|
+
const entry = JSON.parse(await fs.readFile(file, 'utf8'))
|
|
48
|
+
if (entry.id !== name || entry.schemaVersion !== 1 || !Array.isArray(entry.documents)) throw Error('Invalid manifest')
|
|
49
|
+
entries.push({ ...entry, origin: 'local' })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code !== 'ENOENT') throw new PmError('corrupt_state', '交付清单无法读取,请核对 ' + name)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return entries
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async catalog() {
|
|
58
|
+
const entries = await this.localEntries()
|
|
59
|
+
const state = await this.pm.loadState()
|
|
60
|
+
const records = Object.values(state.requirements || {})
|
|
61
|
+
for (const record of records) {
|
|
62
|
+
const ledger = await readDocumentArchiveLedger(this.pm.root, record)
|
|
63
|
+
const receipts = new Map()
|
|
64
|
+
for (const entry of [...Object.values(ledger.files || {}), ledger.solution, ledger.prd].filter(Boolean)) {
|
|
65
|
+
for (const receipt of [entry.receipt, ...(entry.history || [])].filter(Boolean)) receipts.set(receipt.requestId, receipt)
|
|
66
|
+
}
|
|
67
|
+
for (const receipt of receipts.values()) {
|
|
68
|
+
if (!receipt.path || !receipt.requestId) continue
|
|
69
|
+
entries.push({ id: identity('cloud', record.id + ':' + receipt.requestId), requirementId: record.id,
|
|
70
|
+
code: record.code, title: receipt.requirementTitle || record.title,
|
|
71
|
+
version: receipt.deliveryVersion || '', owner: receipt.owner || '',
|
|
72
|
+
archivedAt: receipt.archivedAt, kind: 'DBOX交付', note: '', origin: 'cloud',
|
|
73
|
+
documents: [{ path: receipt.path, label: receipt.stage === 'prd' ? 'PRD' : '高阶方案',
|
|
74
|
+
stage: receipt.stage, documentVersion: receipt.documentVersion, fingerprint: receipt.fingerprint,
|
|
75
|
+
snapshotPath: receipt.snapshotPath || receipt.path }], receipt })
|
|
76
|
+
}
|
|
77
|
+
if (record.archived) {
|
|
78
|
+
const view = await this.pm.viewRequirement(record.id)
|
|
79
|
+
entries.push({ id: identity('history', record.id), requirementId: record.id, code: record.code,
|
|
80
|
+
title: record.title, version: record.version || '', owner: owner(record),
|
|
81
|
+
archivedAt: record.archivedAt, kind: '历史本地归档', note: '', origin: 'history',
|
|
82
|
+
documents: view.stages.flatMap(stage => stage.artifacts.filter(doc => doc.exists).map(doc => ({
|
|
83
|
+
path: doc.legacyPath || doc.path, label: doc.label, stage: stage.id,
|
|
84
|
+
documentVersion: doc.documentVersion, fingerprint: doc.fingerprint }))) })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const groups = await this.pm.listPrdArchive()
|
|
88
|
+
for (const group of groups) {
|
|
89
|
+
for (const file of group.files) {
|
|
90
|
+
const matches = records.filter(record => record.code && file.name.startsWith(record.code + '-'))
|
|
91
|
+
const record = matches.length === 1 ? matches[0] : null
|
|
92
|
+
const historical = entries.find(entry => entry.origin === 'history' && entry.requirementId === record?.id &&
|
|
93
|
+
(entry.version || '未排期') === group.version)
|
|
94
|
+
if (historical) {
|
|
95
|
+
try {
|
|
96
|
+
const copy = await this.pm.readPrdArchiveFile(group.version, file.name)
|
|
97
|
+
if (historical.documents.some(doc => doc.stage === 'prd' && doc.fingerprint === fingerprint(copy))) continue
|
|
98
|
+
} catch { /* Keep the independent file visible so its read error can be inspected. */ }
|
|
99
|
+
}
|
|
100
|
+
entries.push({ id: identity('file', group.version + '/' + file.name), requirementId: record?.id || null,
|
|
101
|
+
code: record?.code || '', title: file.name.replace(/-prd\.md$/i, ''),
|
|
102
|
+
version: group.version === '未排期' ? '' : group.version, owner: record ? owner(record) : '',
|
|
103
|
+
archivedAt: file.updatedAt, kind: '历史PRD文件', note: '', origin: 'file',
|
|
104
|
+
legacyVersion: group.version, legacyName: file.name,
|
|
105
|
+
documents: [{ path: 'prd.md', label: 'PRD', stage: 'prd', documentVersion: '历史文件' }] })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return entries.map(entry => ({ ...entry, versionLabel: entry.version ||
|
|
109
|
+
(entry.origin === 'cloud' && !Object.hasOwn(entry.receipt, 'deliveryVersion') ? '版本未记录' : '未排期') }))
|
|
110
|
+
.sort((a, b) => String(b.archivedAt || '').localeCompare(String(a.archivedAt || '')))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async get(id) {
|
|
114
|
+
const found = (await this.catalog()).find(entry => entry.id === deliveryId(id))
|
|
115
|
+
if (!found) throw new PmError('not_found', '交付记录不存在,请刷新交付库。')
|
|
116
|
+
return found
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async withDeliveryFacts(requirements) {
|
|
120
|
+
const entries = await this.catalog()
|
|
121
|
+
return Promise.all(requirements.map(async requirement => {
|
|
122
|
+
const deliveries = entries.filter(entry => entry.requirementId === requirement.id &&
|
|
123
|
+
entry.documents.some(document => document.stage === 'prd'))
|
|
124
|
+
const latest = deliveries[0]
|
|
125
|
+
if (!latest) return { ...requirement, delivery: { delivered: false } }
|
|
126
|
+
const current = requirement.stages.flatMap(stage => stage.artifacts.filter(document => document.exists)
|
|
127
|
+
.map(document => ({ ...document, path: document.legacyPath || document.path })))
|
|
128
|
+
let updated = false
|
|
129
|
+
for (const document of latest.documents) {
|
|
130
|
+
let hash = document.fingerprint
|
|
131
|
+
if (!hash) {
|
|
132
|
+
try { hash = (await this.readEntry(latest, document.path)).fingerprint }
|
|
133
|
+
catch { updated = null; break }
|
|
134
|
+
}
|
|
135
|
+
if (current.find(file => file.path === document.path)?.fingerprint !== hash) updated = true
|
|
136
|
+
}
|
|
137
|
+
if (updated !== null && latest.origin === 'local' && current.some(file => !latest.documents.some(document => document.path === file.path))) updated = true
|
|
138
|
+
return { ...requirement, delivery: { delivered: true, latestId: latest.id,
|
|
139
|
+
deliveredAt: latest.archivedAt, count: deliveries.length, updated } }
|
|
140
|
+
}))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async read(id, relative) {
|
|
144
|
+
const entry = await this.get(id)
|
|
145
|
+
return this.readEntry(entry, relative)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async readEntry(entry, relative) {
|
|
149
|
+
const document = entry.documents.find(document => document.path === relative)
|
|
150
|
+
if (!document) throw new PmError('bad_path', '文件不属于此交付记录。')
|
|
151
|
+
let content
|
|
152
|
+
if (entry.origin === 'local') {
|
|
153
|
+
if (!/^[0-9]+\.(md|html)$/.test(document.storageName)) throw new PmError('bad_path', '交付文件索引无效。')
|
|
154
|
+
const file = await safePath(this.root, 'records/' + entry.id + '/' + document.storageName)
|
|
155
|
+
if ((await fs.stat(file)).size > MAX_DOCUMENT) throw new PmError('bad_input', '交付文件过大,请在原目录查看。')
|
|
156
|
+
content = await fs.readFile(file, 'utf8')
|
|
157
|
+
} else if (entry.origin === 'file') {
|
|
158
|
+
content = await this.pm.readPrdArchiveFile(entry.legacyVersion, entry.legacyName)
|
|
159
|
+
} else if (entry.origin === 'cloud') {
|
|
160
|
+
const { record } = await this.pm.findRequirement(entry.requirementId)
|
|
161
|
+
const file = await safeDocumentPath(this.pm.root, record, document.snapshotPath)
|
|
162
|
+
if ((await fs.stat(file)).size > MAX_DOCUMENT) throw new PmError('bad_input', '交付文件过大,请在原目录查看。')
|
|
163
|
+
content = await fs.readFile(file, 'utf8')
|
|
164
|
+
} else {
|
|
165
|
+
content = (await this.pm.readDocument(entry.requirementId, relative)).content
|
|
166
|
+
}
|
|
167
|
+
if (typeof content !== 'string') throw new PmError('not_found', '交付原文已移走,无法读取。')
|
|
168
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_DOCUMENT) throw new PmError('bad_input', '交付文件过大,请在原目录查看。')
|
|
169
|
+
const hash = fingerprint(content)
|
|
170
|
+
if (document.fingerprint && document.fingerprint !== hash) throw new PmError('stale_artifact', '交付原文与记录指纹不一致,请核对原文件。')
|
|
171
|
+
return { ...document, content, fingerprint: hash, deliveryId: entry.id }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async capture(input) {
|
|
175
|
+
if (!/^[a-zA-Z0-9-]{8,80}$/.test(String(input.requestId || ''))) throw new PmError('bad_input', '请从需求重新打开提交交付。')
|
|
176
|
+
const id = identity('local', input.id + ':' + input.requestId)
|
|
177
|
+
return withKnowledgeLock(this.root, async () => {
|
|
178
|
+
const existing = (await this.localEntries()).find(entry => entry.id === id)
|
|
179
|
+
if (existing) return summary(existing)
|
|
180
|
+
const view = await this.pm.viewRequirement(input.id)
|
|
181
|
+
const documents = view.stages.flatMap(stage => stage.artifacts.filter(doc => doc.exists).map(doc => ({
|
|
182
|
+
path: doc.legacyPath || doc.path, label: doc.label, stage: stage.id,
|
|
183
|
+
fingerprint: doc.fingerprint, documentVersion: doc.documentVersion })))
|
|
184
|
+
if (!documents.some(doc => doc.stage === 'prd')) throw new PmError('bad_input', '请先准备PRD,再提交交付。')
|
|
185
|
+
if (view.updatedAt !== input.expectedUpdatedAt || JSON.stringify(documents.map(doc => [doc.path, doc.fingerprint])) !== JSON.stringify(input.documents)) {
|
|
186
|
+
throw new PmError('stale_artifact', '需求或交付文档已变化,请刷新后重新确认交付。')
|
|
187
|
+
}
|
|
188
|
+
const note = String(input.note || '').trim()
|
|
189
|
+
if (note.length > 4000) throw new PmError('bad_input', '交付说明请控制在4000字内。')
|
|
190
|
+
const contents = []
|
|
191
|
+
let bytes = 0
|
|
192
|
+
for (const document of documents) {
|
|
193
|
+
const source = await this.pm.readDocument(view.id, document.path)
|
|
194
|
+
bytes += Buffer.byteLength(source.content, 'utf8')
|
|
195
|
+
if (Buffer.byteLength(source.content, 'utf8') > MAX_DOCUMENT || bytes > MAX_CAPTURE) throw new PmError('bad_input', '交付文档过大,请分批整理。')
|
|
196
|
+
if (source.fingerprint !== document.fingerprint) throw new PmError('stale_artifact', '交付文档已变化,请刷新后重试。')
|
|
197
|
+
contents.push(source.content)
|
|
198
|
+
}
|
|
199
|
+
// Publish the manifest last. An interrupted capture remains invisible and retries safely.
|
|
200
|
+
for (let index = 0; index < documents.length; index++) {
|
|
201
|
+
const extension = /\.html$/i.test(documents[index].path) ? 'html' : 'md'
|
|
202
|
+
documents[index].storageName = index + '.' + extension
|
|
203
|
+
await atomicWrite(await safePath(this.root, 'records/' + id + '/' + documents[index].storageName), contents[index])
|
|
204
|
+
}
|
|
205
|
+
const entry = { schemaVersion: 1, id, requirementId: view.id, code: view.code, title: view.title,
|
|
206
|
+
version: view.version || '', owner: owner(view), archivedAt: new Date().toISOString(),
|
|
207
|
+
kind: '本地交付快照', note, documents }
|
|
208
|
+
await atomicWrite(await safePath(this.root, 'records/' + id + '/manifest.json'), JSON.stringify(entry, null, 2))
|
|
209
|
+
return entry
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async candidates(id) {
|
|
214
|
+
await this.get(id)
|
|
215
|
+
const file = await safePath(this.root, 'candidates/' + deliveryId(id) + '.md')
|
|
216
|
+
let content
|
|
217
|
+
try { content = await fs.readFile(file, 'utf8') }
|
|
218
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; content = '{"schemaVersion":1,"items":[]}' }
|
|
219
|
+
return { path: CANDIDATE_PATH, content, fingerprint: fingerprint(content), documentVersion: '候选工作稿' }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async saveCandidates(input) {
|
|
223
|
+
return withKnowledgeLock(this.root, async () => {
|
|
224
|
+
const previous = await this.candidates(input.id)
|
|
225
|
+
if (input.expectedFingerprint !== previous.fingerprint) throw new PmError('conflict', '候选已变化,请读取最新候选后重试。')
|
|
226
|
+
if (typeof input.content !== 'string' || Buffer.byteLength(input.content, 'utf8') > 256 * 1024) throw new PmError('bad_input', '候选内容为空或超过256KiB。')
|
|
227
|
+
let batch
|
|
228
|
+
try { batch = JSON.parse(input.content.trim().replace(/^```json\s*\n([\s\S]*?)\n```$/, '$1')) }
|
|
229
|
+
catch { throw new PmError('bad_input', '请保存有效的候选JSON。') }
|
|
230
|
+
if (batch.schemaVersion !== 1 || !Array.isArray(batch.items) || batch.items.length > 30) throw new PmError('bad_input', '候选格式应为schemaVersion 1,最多30项。')
|
|
231
|
+
const validation = new KnowledgeWorkflow({ ...this.workflow.pm,
|
|
232
|
+
readDocument: async (id, relative) => relative === CANDIDATE_PATH
|
|
233
|
+
? { content: input.content, fingerprint: fingerprint(input.content) }
|
|
234
|
+
: this.read(id, relative) })
|
|
235
|
+
await validation.load(input.id)
|
|
236
|
+
for (const item of batch.items) await this.workflow.verifySources(input.id, item.sources)
|
|
237
|
+
const file = await safePath(this.root, 'candidates/' + deliveryId(input.id) + '.md')
|
|
238
|
+
await atomicWrite(file, input.content)
|
|
239
|
+
return { id: input.id, fingerprint: fingerprint(input.content), count: batch.items.length }
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async detail(id) {
|
|
244
|
+
const entry = await this.get(id)
|
|
245
|
+
const batch = await this.workflow.load(id)
|
|
246
|
+
return { ...summary(entry), knowledge: { pending: batch.items.filter(item => !item.receipt).length,
|
|
247
|
+
accepted: batch.items.filter(item => item.receipt).map(item => item.receipt) } }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async assistant(id) {
|
|
251
|
+
await this.get(id)
|
|
252
|
+
const directory = await safePath(this.root, 'assistants/交付知识助手-' + deliveryId(id).slice(-8))
|
|
253
|
+
await fs.mkdir(directory, { recursive: true })
|
|
254
|
+
return { path: directory }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async list(query = '') {
|
|
258
|
+
const entries = await this.catalog()
|
|
259
|
+
const terms = String(query).trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
|
260
|
+
if (!terms.length) return { entries: entries.map(summary) }
|
|
261
|
+
let budget = 8 * 1024 * 1024
|
|
262
|
+
let limited = false
|
|
263
|
+
const matches = []
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
let haystack = [entry.code, entry.title, entry.version, entry.owner, entry.note].join(' ').toLocaleLowerCase()
|
|
266
|
+
if (terms.every(term => haystack.includes(term))) { matches.push(summary(entry)); continue }
|
|
267
|
+
for (const document of entry.documents) {
|
|
268
|
+
if (budget <= 0) { limited = true; break }
|
|
269
|
+
try {
|
|
270
|
+
const read = await this.readEntry(entry, document.path)
|
|
271
|
+
budget -= Buffer.byteLength(read.content, 'utf8')
|
|
272
|
+
haystack += '\n' + read.content.toLocaleLowerCase()
|
|
273
|
+
} catch { limited = true }
|
|
274
|
+
}
|
|
275
|
+
if (terms.every(term => haystack.includes(term))) matches.push(summary(entry))
|
|
276
|
+
}
|
|
277
|
+
return { entries: matches, searchLimited: limited }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -864,7 +864,7 @@ export async function beginDocumentArchive(root, input) {
|
|
|
864
864
|
const requestId = randomUUID(), snapshotPath = `.publications/${requestId}/${path.basename(rel)}`
|
|
865
865
|
const absolutePath = await safeDocumentPath(root, record, snapshotPath)
|
|
866
866
|
await writeText(absolutePath, content)
|
|
867
|
-
const request = { requestId, stage: input.stage, path: rel, main: !!selectedArtifact.main, sourceVersion: labelRevision(revision), documentVersion: `v${revision.major + 1}.0`, major: revision.major + 1, baseMinor: revision.minor, snapshotPath, fingerprint: hash, requestedAt: new Date().toISOString() }
|
|
867
|
+
const request = { requestId, deliveryVersion: record.version || '', requirementTitle: record.title, owner: record.collaboration?.pmOwner || '', stage: input.stage, path: rel, main: !!selectedArtifact.main, sourceVersion: labelRevision(revision), documentVersion: `v${revision.major + 1}.0`, major: revision.major + 1, baseMinor: revision.minor, snapshotPath, fingerprint: hash, requestedAt: new Date().toISOString() }
|
|
868
868
|
ledger.files ||= {}
|
|
869
869
|
// One in-flight publication per document; another stage/file does not replace it.
|
|
870
870
|
ledger.files[rel] = { ...(ledger.files[rel] || {}), pending: request }
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {installRequirementDeletion} from './requirement-deletion.mjs'
|
|
2
|
+
import {ProductContext} from './product-context.mjs'
|
|
3
|
+
import {DeliveryLibrary} from './delivery-library.mjs'
|
|
4
|
+
import {KnowledgeWorkflow} from './knowledge-workflow.mjs'
|
|
1
5
|
/**
|
|
2
6
|
* dsh-pm-core — host half.
|
|
3
7
|
*
|
|
@@ -106,6 +110,10 @@ export function apply(ctx, config = {}) {
|
|
|
106
110
|
readPrdArchiveFile: (version, name) => engine.readPrdArchiveFile(root, settings.value.prdArchiveDir, version, name),
|
|
107
111
|
}
|
|
108
112
|
// Cordis 4:服务必须先 provide 才能被 inject("cannot set property without provide")。
|
|
113
|
+
pm.knowledgeWorkflow = new KnowledgeWorkflow(pm)
|
|
114
|
+
pm.deliveryLibrary = new DeliveryLibrary(pm)
|
|
115
|
+
pm.productContext = new ProductContext(pm)
|
|
116
|
+
installRequirementDeletion(pm)
|
|
109
117
|
ctx.provide('pmCore', pm)
|
|
110
118
|
|
|
111
119
|
// 播种全局知识库(SKILL / PRD 模板 / 决策记忆);只写缺失文件,失败不拖垮启动。
|
|
@@ -119,7 +127,39 @@ export function apply(ctx, config = {}) {
|
|
|
119
127
|
})
|
|
120
128
|
|
|
121
129
|
// -------------------------------------------------------------------------
|
|
122
|
-
// Agent 工具
|
|
130
|
+
// Agent 工具
|
|
131
|
+
ctx.tools.register(defineTool({
|
|
132
|
+
name:'pm_product_context_read',
|
|
133
|
+
description:'读取已接入的权威产品包含目录与人工确认的知识关联。空目录表示尚未接入,不可自行编造层级。关联只表示知识涉及这些产品,正文适用性仍须查阅知识内容与确认状态。可以建议关联,由用户在知识详情中确认。',
|
|
134
|
+
parameters:{},output:jsonOut,
|
|
135
|
+
execute:async ()=>pm.productContext.read()
|
|
136
|
+
}))
|
|
137
|
+
ctx.tools.register(defineTool({
|
|
138
|
+
name:'pm_delivery_read',
|
|
139
|
+
description:'读取交付库某次交付的固定文档及指纹。path省略时返回交付清单,path为notes/knowledge-candidates.md时返回该交付独立候选及指纹。历史交付用于追溯,提炼时核对适用范围。',
|
|
140
|
+
parameters:{id:req('交付记录ID,使用工作台提取任务给出的ID'),path:str('交付清单中的文件相对路径,或notes/knowledge-candidates.md')},output:jsonOut,
|
|
141
|
+
execute:async args=>!args.path?pm.deliveryLibrary.detail(args.id):args.path==='notes/knowledge-candidates.md'?pm.deliveryLibrary.candidates(args.id):pm.deliveryLibrary.read(args.id,args.path)
|
|
142
|
+
}))
|
|
143
|
+
ctx.tools.register(defineTool({
|
|
144
|
+
name:'pm_delivery_candidates_save',
|
|
145
|
+
description:'将知识候选JSON保存到交付记录的独立候选区。先读取旧候选并保留已有条目,提供旧指纹。每项sources必须逐字引用此交付清单中的Markdown。用户复核后才入知识库。',
|
|
146
|
+
parameters:{id:req('交付记录ID'),content:req('schemaVersion 1候选JSON全文'),expectedFingerprint:req('先读取候选获得的原指纹')},output:jsonOut,
|
|
147
|
+
execute:async args=>pm.deliveryLibrary.saveCandidates(args)
|
|
148
|
+
}))
|
|
149
|
+
ctx.tools.register(defineTool({
|
|
150
|
+
name:'pm_knowledge_search',
|
|
151
|
+
description:'检索SA知识库的Markdown,返回类型、领域、确认状态、原文片段和指纹。知识候选提取前先查已有知识。准备更新某文件时必须再完整读取该文件,保留无关内容。',
|
|
152
|
+
parameters:{query:str('用空格分隔关键词,任一关键词匹配。空值列出前12项。')},
|
|
153
|
+
output:jsonOut,
|
|
154
|
+
execute:async args=>pm.knowledgeWorkflow.search(args.query || '')
|
|
155
|
+
}))
|
|
156
|
+
ctx.tools.register(defineTool({
|
|
157
|
+
name:'pm_knowledge_read',
|
|
158
|
+
description:'读取知识库中一份Markdown全文及其指纹。更新知识前用此工具完整读取,保留未涉及的内容。',
|
|
159
|
+
parameters:{path:req('知识根目录相对路径')},
|
|
160
|
+
output:jsonOut,
|
|
161
|
+
execute:async args=>pm.knowledgeWorkflow.read(args.path)
|
|
162
|
+
}))
|
|
123
163
|
// -------------------------------------------------------------------------
|
|
124
164
|
|
|
125
165
|
ctx.tools.register(
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Deliberately restricted front matter: plain scalar fields and string lists only.
|
|
2
|
+
// Never infer business confirmation or domains from prose or filenames.
|
|
3
|
+
export function knowledgeRecord(file, text='') {
|
|
4
|
+
const fields={}, match=String(text).match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
|
|
5
|
+
const scalar=s=>{
|
|
6
|
+
const value=String(s).trim()
|
|
7
|
+
if(value.startsWith('"')){try{return String(JSON.parse(value))}catch{}}
|
|
8
|
+
return value.replace(/^(['"])(.*)\1$/,'$2')
|
|
9
|
+
}
|
|
10
|
+
if(match){let key='';for(const line of match[1].split(/\r?\n/)){
|
|
11
|
+
const pair=line.match(/^([a-z_]+):\s*(.*?)\s*$/)
|
|
12
|
+
if(pair){key=pair[1];fields[key]=pair[2] ? scalar(pair[2]) : []}
|
|
13
|
+
else {const item=line.match(/^\s+-\s+(.+)$/);if(item && Array.isArray(fields[key]))fields[key].push(scalar(item[1]))}
|
|
14
|
+
}}
|
|
15
|
+
const list=v=>Array.isArray(v)?v.map(scalar):typeof v==='string'?v.replace(/^\[|\]$/g,'').split(/[,,]/).map(scalar).filter(Boolean):[]
|
|
16
|
+
const body=match?text.slice(match[0].length):String(text)
|
|
17
|
+
return {...file,title:scalar(fields.title || body.match(/^#\s+(.+)$/m)?.[1] || file.path.replace(/.*\//,'').replace(/\.[^.]+$/,'')),
|
|
18
|
+
kind:scalar(fields.type || '未分类'),domains:list(fields.domains),status:scalar(fields.status || '未标注'),
|
|
19
|
+
summary:scalar(fields.summary || body.replace(/^#+\s+.*$/gm,'').replace(/[`*_>#\[\]]/g,'').trim().slice(0,160)),
|
|
20
|
+
source:scalar(fields.source || ''),owner:scalar(fields.owner || ''),version:scalar(fields.version || ''),
|
|
21
|
+
scope:scalar(fields.scope || ''),reviewedBy:scalar(fields.reviewed_by || ''),
|
|
22
|
+
sourceDeliveryId:scalar(fields.source_delivery_id || ''),sourceRequirementId:scalar(fields.source_requirement_id || ''),
|
|
23
|
+
related:list(fields.related),searchText:body.slice(0,262144),metadata:!!match}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function knowledgeCatalog(pm){
|
|
27
|
+
const files=await pm.listKnowledge(), records=[]
|
|
28
|
+
let budget=4*1024*1024
|
|
29
|
+
// Sequential, bounded reads avoid unbounded I/O on a large knowledge directory.
|
|
30
|
+
for(const file of files){
|
|
31
|
+
if(file.bytes>262144 || file.bytes>budget){records.push({...knowledgeRecord(file),previewLimited:true});continue}
|
|
32
|
+
try{const content=await pm.readKnowledge(file.path) || '';budget-=Math.max(file.bytes,content.length);records.push(knowledgeRecord(file,content))}
|
|
33
|
+
catch{records.push({...knowledgeRecord(file),metadataError:true})}
|
|
34
|
+
}
|
|
35
|
+
return records
|
|
36
|
+
}
|