cs-bots 0.1.1 → 0.7.5

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 (48) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/verify.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * cs-bots VerifyService —— PDCA 检查面的证据自动化。
3
+ *
4
+ * 为什么需要它:`board.complete` 只要求 `artifacts` 非空,**没人检查产出是否真实**。
5
+ * 「我完成了」不能当验收依据 —— 本模块让验收带上**机器证据**(文件存在 / 命令退出码 /
6
+ * 测试套件结果),人点采纳时看到的是有出处的结果,而不是一句声明。
7
+ *
8
+ * 安全约束(本模块最重要的性质):
9
+ * - **命令白名单**:`kind:'command'` 只允许固定命令名(bash/node/npm/pnpm/npx/git/ls/test/python3),
10
+ * 不在白名单一律抛 `VERIFY_CMD_NOT_ALLOWED`;
11
+ * - **绝不用 shell**:一律 `execFile`(参数数组),杜绝命令注入;
12
+ * - **超时保护**:缺省 120s,超时返回 `{ok:false, evidence:{error:'timeout'}}`;
13
+ * - **任何执行异常都转成可读结果**,绝不抛出去让上层崩(参数校验错误除外)。
14
+ *
15
+ * 不变量:事件流真相源 / 只追加 / 绝不静默失败。
16
+ * 依赖面:node 内置 + `./store.js` + `./kernel.js`。零第三方依赖。
17
+ */
18
+
19
+ import { execFile } from 'node:child_process'
20
+ import { existsSync, statSync } from 'node:fs'
21
+ import { resolve } from 'node:path'
22
+
23
+ import { appendJsonl, readJsonl, statePath, writeJsonAtomic } from './store.js'
24
+
25
+ let kernel = null
26
+ let kernelError = null
27
+ try {
28
+ kernel = await import('./kernel.js')
29
+ } catch (error) {
30
+ kernelError = error
31
+ }
32
+
33
+ /** 命令白名单(`kind:'command'` 的 cmd 第一段必须在此列)。 */
34
+ export const CMD_WHITELIST = Object.freeze([
35
+ 'bash', 'node', 'npm', 'pnpm', 'npx', 'git', 'ls', 'test', 'python3',
36
+ ])
37
+
38
+ /** 检查种类。 */
39
+ export const CHECK_KINDS = Object.freeze(['file', 'command', 'test'])
40
+
41
+ /** 缺省超时(ms)。 */
42
+ const DEFAULT_TIMEOUT_MS = 120_000
43
+
44
+ /** 结构化错误。 */
45
+ export class VerifyError extends Error {
46
+ constructor(message, code = 'VERIFY_ERROR', details) {
47
+ super(message)
48
+ this.name = 'VerifyError'
49
+ this.code = code
50
+ if (details !== undefined) this.details = details
51
+ }
52
+ }
53
+
54
+ function clone(value) {
55
+ if (value === undefined || value === null) return value
56
+ return JSON.parse(JSON.stringify(value))
57
+ }
58
+
59
+ function normalizeNow(now, where) {
60
+ if (now === undefined || now === null) return Date.now()
61
+ if (typeof now !== 'number' || !Number.isFinite(now)) {
62
+ throw new VerifyError(
63
+ `verify.${where}:now 必须是有限数字,收到 ${JSON.stringify(now)}`,
64
+ 'VERIFY_BAD_FIELD', { where, field: 'now' },
65
+ )
66
+ }
67
+ return now
68
+ }
69
+
70
+ function requireString(value, field, where) {
71
+ if (typeof value !== 'string' || value.trim() === '') {
72
+ throw new VerifyError(
73
+ `verify.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`,
74
+ 'VERIFY_BAD_FIELD', { where, field },
75
+ )
76
+ }
77
+ return value.trim()
78
+ }
79
+
80
+ function resolveStateDir(config) {
81
+ if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') return config.stateDir.trim()
82
+ if (kernel && typeof kernel.stateDir === 'function') return kernel.stateDir()
83
+ const reason = kernelError?.message ?? 'kernel.stateDir 不存在'
84
+ throw new VerifyError(
85
+ `verify.createVerify:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`,
86
+ 'VERIFY_NO_STATE_DIR',
87
+ )
88
+ }
89
+
90
+ function splitArgs(ctx, config) {
91
+ const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
92
+ if (cfgGiven) return { context: ctx ?? {}, config }
93
+ if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') return { context: {}, config: ctx }
94
+ return { context: ctx ?? {}, config: config ?? {} }
95
+ }
96
+
97
+ /**
98
+ * 执行一条命令(白名单 + 无 shell + 超时)。
99
+ * @returns {Promise<{exitCode:number, stdout:string, stderr:string, error?:string}>}
100
+ */
101
+ function runCommand(cmd, cwd, timeoutMs) {
102
+ return new Promise((resolvePromise) => {
103
+ const file = String(cmd[0])
104
+ if (!CMD_WHITELIST.includes(file)) {
105
+ resolvePromise({ exitCode: -1, stdout: '', stderr: '', error: `command not allowed: ${file}` })
106
+ return
107
+ }
108
+ const args = cmd.slice(1)
109
+ let settled = false
110
+ const done = (out) => { if (!settled) { settled = true; resolvePromise(out) } }
111
+ try {
112
+ execFile(file, args, { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, shell: false }, (error, stdout, stderr) => {
113
+ if (error && (error.killed || error.signal === 'SIGTERM')) {
114
+ done({ exitCode: -1, stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), error: 'timeout' })
115
+ return
116
+ }
117
+ done({ exitCode: error ? (typeof error.code === 'number' ? error.code : -1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })
118
+ })
119
+ } catch (e) {
120
+ done({ exitCode: -1, stdout: '', stderr: '', error: String(e?.message ?? e) })
121
+ }
122
+ })
123
+ }
124
+
125
+ /**
126
+ * 创建验收证据服务。
127
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [ctx]
128
+ * @param {{ stateDir?: string, name?: string }} [config]
129
+ * @returns {object} verify service
130
+ */
131
+ export function createVerify(ctx, config = {}) {
132
+ const { context, config: cfg } = splitArgs(ctx, config)
133
+ const stateDir = resolveStateDir(cfg)
134
+
135
+ const jsonlPath = statePath(stateDir, 'verify.jsonl')
136
+ const snapPath = statePath(stateDir, 'verify.snap.json')
137
+
138
+ const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* */ } }
139
+ const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* */ } }
140
+
141
+ const entries = new Map()
142
+ const events = []
143
+ let idCounter = 0
144
+ let disposed = false
145
+
146
+ const diagnostics = {
147
+ stateDir, jsonlPath, snapPath,
148
+ kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
149
+ hydratedFrom: null, replayedEvents: 0, badLines: 0,
150
+ snapshotWriteFailures: 0, lastReplayAt: undefined,
151
+ checksRun: 0, checksFailed: 0,
152
+ }
153
+
154
+ function assertAlive(where) {
155
+ if (disposed) {
156
+ throw new VerifyError(
157
+ `verify.${where}:实例已 dispose(),请重新 createVerify()(拒绝在已销毁实例上静默返回空数据)`,
158
+ 'VERIFY_DISPOSED', { where },
159
+ )
160
+ }
161
+ }
162
+
163
+ function snapshot() {
164
+ return { version: 1, savedAt: Date.now(), entries: [...entries.values()] }
165
+ }
166
+
167
+ function commit(action, at, by, before, after) {
168
+ if (!appendJsonl(jsonlPath, { id: after.id, rev: after.rev, action, by, at, before, after })) {
169
+ throw new VerifyError(`verify.${action}:事件追加失败(${jsonlPath})`, 'VERIFY_APPEND_FAILED', { action })
170
+ }
171
+ entries.set(after.id, clone(after))
172
+ events.push({ id: after.id, rev: after.rev, action, by, at, before: before ? clone(before) : null, after: clone(after) })
173
+ if (!writeJsonAtomic(snapPath, snapshot())) {
174
+ diagnostics.snapshotWriteFailures += 1
175
+ warn(`[cs-bots/verify] 快照写入失败(${snapPath})——事件流已落盘,replay() 可恢复`)
176
+ }
177
+ }
178
+
179
+ function hydrate() {
180
+ entries.clear(); events.length = 0; idCounter = 0
181
+ const { rows, bad } = readJsonl(jsonlPath)
182
+ diagnostics.badLines = bad
183
+ diagnostics.replayedEvents = rows.length
184
+ if (bad > 0) warn(`[cs-bots/verify] ${jsonlPath} 有 ${bad} 行无法解析——已跳过,请核对`)
185
+ if (rows.length === 0) { diagnostics.hydratedFrom = 'empty'; return }
186
+ let skipped = 0
187
+ for (const ev of rows) {
188
+ if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
189
+ const current = entries.get(ev.id)
190
+ if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) { skipped += 1; continue }
191
+ entries.set(ev.id, clone(ev.after)); events.push(clone(ev))
192
+ const m = /^v-(\d+)$/.exec(ev.id)
193
+ if (m) idCounter = Math.max(idCounter, Number(m[1]))
194
+ }
195
+ if (skipped > 0) warn(`[cs-bots/verify] 回放跳过 ${skipped} 条非递增/残缺事件`)
196
+ diagnostics.hydratedFrom = 'jsonl'
197
+ }
198
+
199
+ // ── 单条检查 ──────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * 执行一条声明式检查。
203
+ * @param {object} spec
204
+ * @returns {Promise<object>}
205
+ */
206
+ async function check(spec = {}) {
207
+ assertAlive('check')
208
+ const started = Date.now()
209
+ const kind = spec?.kind
210
+ if (!CHECK_KINDS.includes(kind)) {
211
+ throw new VerifyError(
212
+ `verify.check:kind=${JSON.stringify(kind)} 非法(合法:${CHECK_KINDS.join(' | ')})`,
213
+ 'VERIFY_BAD_KIND', { kind },
214
+ )
215
+ }
216
+ const by = spec?.by ?? 'system'
217
+ const at = normalizeNow(spec?.now, 'check')
218
+
219
+ // ── 参数校验(这些是可以抛的)──
220
+ let evidence
221
+ if (kind === 'file') {
222
+ const path = requireString(spec.path, 'path', 'check')
223
+ const expected = spec.expected ?? 'exists'
224
+ if (!['exists', 'absent', 'nonempty'].includes(expected)) {
225
+ throw new VerifyError(`verify.check:expected 非法(${JSON.stringify(expected)})`, 'VERIFY_BAD_FIELD', { field: 'expected' })
226
+ }
227
+ let exists = false
228
+ let size = 0
229
+ try {
230
+ if (existsSync(resolve(path))) { exists = true; size = statSync(resolve(path)).size }
231
+ } catch { exists = false }
232
+ let ok
233
+ if (expected === 'exists') ok = exists
234
+ else if (expected === 'absent') ok = !exists
235
+ else ok = exists && size > 0
236
+ evidence = { path, expected, exists, size, ok }
237
+ } else if (kind === 'command') {
238
+ if (!Array.isArray(spec.cmd) || spec.cmd.length === 0 || typeof spec.cmd[0] !== 'string') {
239
+ throw new VerifyError(`verify.check:cmd 必须是字符串数组(execFile 参数,禁用 shell)`, 'VERIFY_BAD_CMD', { field: 'cmd' })
240
+ }
241
+ if (!CMD_WHITELIST.includes(spec.cmd[0])) {
242
+ throw new VerifyError(
243
+ `verify.check:命令 ${JSON.stringify(spec.cmd[0])} 不在白名单(${CMD_WHITELIST.join('|')})——拒绝执行`,
244
+ 'VERIFY_CMD_NOT_ALLOWED', { cmd: spec.cmd[0], whitelist: CMD_WHITELIST },
245
+ )
246
+ }
247
+ const out = await runCommand(spec.cmd, spec.cwd ?? undefined, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS)
248
+ evidence = { cmd: spec.cmd, exitCode: out.exitCode, stdout: out.stdout, stderr: out.stderr, error: out.error }
249
+ // expectExit 指定时**替换**「必须 0 退出」的判定(否则「期望退出码 2」永远不可能成立)。
250
+ let ok = out.error === 'timeout' ? false : out.exitCode === 0
251
+ if (spec.expectExit !== undefined && spec.expectExit !== null) {
252
+ ok = out.error === 'timeout' ? false : out.exitCode === spec.expectExit
253
+ }
254
+ if (spec.expectOutput !== undefined && spec.expectOutput !== null) ok = ok && out.stdout.includes(spec.expectOutput)
255
+ evidence.ok = ok
256
+ } else { // test
257
+ if (!spec.cwd) {
258
+ throw new VerifyError(`verify.check:kind='test' 必须显式提供 cwd(拒绝在任意目录跑测试)`, 'VERIFY_BAD_FIELD', { field: 'cwd' })
259
+ }
260
+ const suite = spec.suite ?? 'test/run-all.sh'
261
+ // ⚠️ 不用 `bash -c <string>`:那等于给任意 shell 注入开口,
262
+ // 与「禁用 shell」的安全约束直接冲突。`suite` 只允许**脚本路径**(`bash <path>`),
263
+ // 参数不含代码 → 不存在注入面。要跑自定义命令请用 kind:'command'(走白名单 + execFile)。
264
+ const out = await runCommand(['bash', suite], spec.cwd, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS)
265
+ evidence = { suite, cwd: spec.cwd, exitCode: out.exitCode, stdout: out.stdout, stderr: out.stderr, error: out.error }
266
+ evidence.ok = out.exitCode === 0
267
+ }
268
+
269
+ const durationMs = Date.now() - started
270
+ idCounter += 1
271
+ const id = `v-${idCounter}`
272
+ const rec = { id, rev: 1, kind, spec: clone(spec), result: evidence, at, by, durationMs, createdAt: at, updatedAt: at }
273
+ commit('check', at, by, null, rec)
274
+ diagnostics.checksRun += 1
275
+ if (!evidence.ok) diagnostics.checksFailed += 1
276
+ return clone(rec)
277
+ }
278
+
279
+ // ── 批量 ──────────────────────────────────────────────────────────────────
280
+
281
+ /** 对一组路径做存在性检查(同步,不执行命令)。 */
282
+ function evaluate(artifacts, options = {}) {
283
+ assertAlive('evaluate')
284
+ const list = Array.isArray(artifacts) ? artifacts : []
285
+ const results = list.map((p) => {
286
+ const abs = resolve(String(p))
287
+ let exists = false, size = 0
288
+ try { if (existsSync(abs)) { exists = true; size = statSync(abs).size } } catch { exists = false }
289
+ return { path: String(p), exists, size, ok: exists }
290
+ })
291
+ const passed = results.filter((r) => r.ok).length
292
+ return { ok: passed === results.length && results.length > 0, results, passed, failed: results.length - passed }
293
+ }
294
+
295
+ /**
296
+ * 校验一个任务的产出:artifacts 逐个存在 + writeScopes 存在性检查。
297
+ * @param {{ id: string, artifacts?: string[], writeScopes?: string[] }} task
298
+ */
299
+ function verifyTask(task = {}, options = {}) {
300
+ assertAlive('verifyTask')
301
+ const by = options?.by ?? task?.by ?? 'system'
302
+ const at = normalizeNow(options?.now, 'verifyTask')
303
+ const artifactCheck = evaluate(task.artifacts ?? [], options)
304
+ const scopes = Array.isArray(task.writeScopes) ? task.writeScopes : []
305
+ const scopeCheck = scopes.map((s) => {
306
+ const abs = resolve(String(s))
307
+ let exists = false
308
+ try { if (existsSync(abs)) exists = true } catch { exists = false }
309
+ return { scope: String(s), exists, ok: exists }
310
+ })
311
+ const scopeOk = scopeCheck.every((r) => r.ok)
312
+ const ok = artifactCheck.ok && (scopes.length === 0 || scopeOk)
313
+ idCounter += 1
314
+ const id = `v-${idCounter}`
315
+ const rec = {
316
+ id, rev: 1, kind: 'task', taskId: task.id ?? null,
317
+ result: { ok, artifactCheck, scopeCheck }, at, by, durationMs: 0,
318
+ createdAt: at, updatedAt: at,
319
+ }
320
+ commit('verifyTask', at, by, null, rec)
321
+ diagnostics.checksRun += 1
322
+ if (!ok) diagnostics.checksFailed += 1
323
+ return clone(rec)
324
+ }
325
+
326
+ function history(limit = 50) {
327
+ assertAlive('history')
328
+ return clone([...entries.values()].sort((a, b) => b.at - a.at).slice(0, limit))
329
+ }
330
+
331
+ function stats() {
332
+ const byKind = {}
333
+ for (const r of entries.values()) byKind[r.kind] = (byKind[r.kind] ?? 0) + 1
334
+ return { total: entries.size, byKind, events: events.length, diagnostics: clone(diagnostics) }
335
+ }
336
+
337
+ function replay() { hydrate(); diagnostics.lastReplayAt = Date.now(); return { from: diagnostics.hydratedFrom, events: events.length, bad: diagnostics.badLines } }
338
+ function dispose() { disposed = true }
339
+
340
+ hydrate()
341
+
342
+ return {
343
+ name: cfg.name ?? 'verify',
344
+ paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
345
+ check,
346
+ evaluate,
347
+ verifyTask,
348
+ history,
349
+ stats,
350
+ replay,
351
+ diagnostics: () => clone(diagnostics),
352
+ dispose,
353
+ }
354
+ }
355
+
356
+ export default createVerify