cs-bots 0.1.1 → 0.7.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/CHANGELOG.md +533 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- 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
- 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
- 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
- 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
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots DisciplineService —— 分级纪律体系(DESIGN-v2 §特性②)。
|
|
3
|
+
*
|
|
4
|
+
* 概念(契约冻结,勿改语义):
|
|
5
|
+
* - **层级 scope**:global(全局)→ workspace(工作区,按 cwd)→ room(房间)→ bot(单 Bot);
|
|
6
|
+
* 越具体越靠后,冲突时**具体优先**。
|
|
7
|
+
* - **分类 kind**:rule(纪律/硬规矩)、requirement(要求/交付标准)、
|
|
8
|
+
* principle(原则/价值观)、style(风格/说话方式)。
|
|
9
|
+
* - **优先级 priority**:数字越大越靠前(缺省 0)。
|
|
10
|
+
*
|
|
11
|
+
* ── 三条不变量(与 board.js 同款,机械保证)──
|
|
12
|
+
* 1. **CAS 优先**:任何变更都要带 `expectedRev`;不匹配即抛错,绝不静默覆盖。
|
|
13
|
+
* 2. **事件流是真相源**:先 append `discipline.jsonl`(失败即抛错,内存原封不动),
|
|
14
|
+
* 再更新内存,最后原子写 `discipline.snap.json`(快照失败只降级 warn ——
|
|
15
|
+
* 事件流已落盘,`replay()` 能完整重建)。
|
|
16
|
+
* 3. **写失败绝不静默**:append 失败抛 `DISCIPLINE_APPEND_FAILED` 并回滚内存/计数器。
|
|
17
|
+
*
|
|
18
|
+
* ── 为什么 token 估算要**复制** memory.js 的 estimateTokens 而不是 import ──
|
|
19
|
+
* ① 本模块刻意保持轻量(只依赖 `./store.js` + `./kernel.js` 惰性桥),
|
|
20
|
+
* memory.js 是 3k+ 行的重模块,为 8 行函数把它拖进来不值;
|
|
21
|
+
* ② 更要紧的是**写入范围纪律**:memory.js 属于 memory 负责人,
|
|
22
|
+
* 从它 import 会把本模块的可用性绑到别人的改动上(改坏一处,纪律注入全瘫)。
|
|
23
|
+
* ③ 口径必须**逐字一致**(4 字符/token,宽字符按 1.5)—— 否则「同一段文本,
|
|
24
|
+
* 记忆注入算 100 token、纪律注入算 130」,预算口径就不可比了。
|
|
25
|
+
*
|
|
26
|
+
* ── 为什么 kernel.js 是「动态导入」──
|
|
27
|
+
* 与 board.js 相同:kernel.js 静态 import 了宿主包,裸检出(无 node_modules)
|
|
28
|
+
* 下静态 import 会让本模块连带加载失败,`test/discipline.test.mjs` 起不来。
|
|
29
|
+
* 拿不到 kernel 时**不静默兜底**:调用方必须显式传 `config.stateDir`。
|
|
30
|
+
*
|
|
31
|
+
* 依赖面:node 内置 + `./store.js` + `./kernel.js`。**不 import 任何宿主包/第三方包**。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { normalize as normalizePath } from 'node:path'
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
appendJsonl,
|
|
38
|
+
readJson,
|
|
39
|
+
readJsonl,
|
|
40
|
+
statePath,
|
|
41
|
+
writeJsonAtomic,
|
|
42
|
+
} from './store.js'
|
|
43
|
+
|
|
44
|
+
// ── kernel 惰性桥(见文件头「为什么是动态导入」)──────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** @type {object | null} */
|
|
47
|
+
let kernel = null
|
|
48
|
+
/** @type {unknown} */
|
|
49
|
+
let kernelError = null
|
|
50
|
+
try {
|
|
51
|
+
kernel = await import('./kernel.js')
|
|
52
|
+
} catch (error) {
|
|
53
|
+
kernelError = error
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── 常量(契约冻结)──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** 合法层级,顺序 = 从泛到具体。 */
|
|
59
|
+
export const SCOPES = ['global', 'workspace', 'room', 'bot']
|
|
60
|
+
/** 合法分类。 */
|
|
61
|
+
export const KINDS = ['rule', 'requirement', 'principle', 'style']
|
|
62
|
+
/** 字段上限(DESIGN §特性②模型注释)。 */
|
|
63
|
+
export const LIMITS = { title: 120, content: 4000 }
|
|
64
|
+
/** import 的三种去重策略:跳过 / 覆盖 / 改名(rename 为 library 同款扩展)。 */
|
|
65
|
+
export const IMPORT_MODES = ['skip', 'overwrite', 'rename']
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* scope 具体度排序:数值大 = 更具体。
|
|
69
|
+
* 冲突裁决的**第二关键字**(第一关键字是 priority),见 `compareDisciplines`。
|
|
70
|
+
*/
|
|
71
|
+
const SCOPE_RANK = { global: 1, workspace: 2, room: 3, bot: 4 }
|
|
72
|
+
|
|
73
|
+
/** 分类 → markdown 小节标题(顺序即渲染顺序,契约要求)。 */
|
|
74
|
+
const KIND_SECTION = {
|
|
75
|
+
rule: '## 纪律',
|
|
76
|
+
requirement: '## 要求',
|
|
77
|
+
principle: '## 原则',
|
|
78
|
+
style: '## 风格',
|
|
79
|
+
}
|
|
80
|
+
/** 渲染顺序:硬规矩 → 交付标准 → 原则 → 风格。 */
|
|
81
|
+
const KIND_ORDER = ['rule', 'requirement', 'principle', 'style']
|
|
82
|
+
|
|
83
|
+
/** 超预算时的省略提示前缀(契约原文要求)。 */
|
|
84
|
+
const TRUNCATED_PREFIX = '⚠️ 已省略 '
|
|
85
|
+
|
|
86
|
+
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 分级纪律结构化错误。所有失败都走它,**绝不静默**。
|
|
90
|
+
* `detail` 带上定位信息(条目 id / 字段 / rev),便于 API 层原样回显给 UI。
|
|
91
|
+
*/
|
|
92
|
+
export class DisciplineError extends Error {
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} message 人类可读、含定位信息
|
|
95
|
+
* @param {string} [code] 大写下划线错误码
|
|
96
|
+
* @param {object} [detail] 机器可读的上下文
|
|
97
|
+
*/
|
|
98
|
+
constructor(message, code = 'DISCIPLINE_ERROR', detail) {
|
|
99
|
+
super(message)
|
|
100
|
+
this.name = 'DisciplineError'
|
|
101
|
+
this.code = code
|
|
102
|
+
if (detail !== undefined) this.detail = detail
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 深拷贝 + **JSON 归一**(去掉 undefined 键)。
|
|
110
|
+
* 必须走 JSON 而不是 structuredClone:否则内存态会比磁盘态多出
|
|
111
|
+
* `{priority: undefined}` 这类键,`replay()` 重建后与内存值不等。
|
|
112
|
+
*/
|
|
113
|
+
function clone(value) {
|
|
114
|
+
if (value === undefined || value === null) return value
|
|
115
|
+
return JSON.parse(JSON.stringify(value))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 取 `d-<n>` 里的 n;非本格式返回 0。 */
|
|
119
|
+
function idNumber(id) {
|
|
120
|
+
const m = /^d-(\d+)$/.exec(String(id ?? ''))
|
|
121
|
+
return m ? Number(m[1]) : 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 非空字符串字段校验。 */
|
|
125
|
+
function requireString(value, field, where) {
|
|
126
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
127
|
+
throw new DisciplineError(
|
|
128
|
+
`discipline.${where}:字段 ${field} 缺失或类型错误(要求非空字符串,收到 ${JSON.stringify(value)})`,
|
|
129
|
+
'DISCIPLINE_MISSING_FIELD',
|
|
130
|
+
{ field, where },
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return value
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 取操作者 id。
|
|
138
|
+
*
|
|
139
|
+
* 契约参数名是 `by`(DESIGN §特性②签名);这里同时容忍 board 风格的 `byId`,
|
|
140
|
+
* 因为 api/tools 层在 board 那边习惯写 `byId`,混用时不至于把一个正确的调用
|
|
141
|
+
* 变成「缺 by」的假失败。两者都给时以契约名 `by` 为准。
|
|
142
|
+
*/
|
|
143
|
+
function requireActor(input, where) {
|
|
144
|
+
const value = input?.by ?? input?.byId
|
|
145
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
146
|
+
throw new DisciplineError(
|
|
147
|
+
`discipline.${where}:操作者缺失(字段 by,兼容别名 byId;收到 ${JSON.stringify(value)})`,
|
|
148
|
+
'DISCIPLINE_MISSING_FIELD',
|
|
149
|
+
{ field: 'by', where },
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return value
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 时间参数归一:可注入(测试),缺省 Date.now()。 */
|
|
156
|
+
function normalizeNow(now, where) { if (now === undefined || now === null) return Date.now()
|
|
157
|
+
if (!Number.isFinite(now) || now < 0) {
|
|
158
|
+
throw new DisciplineError(
|
|
159
|
+
`discipline.${where}:now 必须是有限的非负数毫秒时间戳(收到 ${JSON.stringify(now)})`,
|
|
160
|
+
'DISCIPLINE_BAD_FIELD',
|
|
161
|
+
{ field: 'now', where },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return now
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 层级校验。 */
|
|
168
|
+
function normalizeScope(scope, where) {
|
|
169
|
+
if (typeof scope !== 'string' || !SCOPES.includes(scope)) {
|
|
170
|
+
throw new DisciplineError(
|
|
171
|
+
`discipline.${where}:非法 scope=${JSON.stringify(scope)}(合法值:${SCOPES.join(' | ')})`,
|
|
172
|
+
'DISCIPLINE_BAD_SCOPE',
|
|
173
|
+
{ field: 'scope', where, scope },
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return scope
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* scopeId 归一(与 scope 强绑定,不许「global 却带个 id」这种歧义状态)。
|
|
181
|
+
* - global:scopeId 必须为空(null/undefined/'')→ 统一存 null;
|
|
182
|
+
* - 其他:必须是非空字符串(workspace 为 cwd 绝对路径,room/bot 为对应 id)。
|
|
183
|
+
*/
|
|
184
|
+
function normalizeScopeId(scope, scopeId, where) {
|
|
185
|
+
const empty = scopeId === undefined || scopeId === null || scopeId === ''
|
|
186
|
+
if (scope === 'global') {
|
|
187
|
+
if (!empty) {
|
|
188
|
+
throw new DisciplineError(
|
|
189
|
+
`discipline.${where}:scope=global 时 scopeId 必须为空(收到 ${JSON.stringify(scopeId)})——`
|
|
190
|
+
+ '全局条目挂在任何 id 上都会让合并规则出现歧义',
|
|
191
|
+
'DISCIPLINE_BAD_SCOPE_ID',
|
|
192
|
+
{ field: 'scopeId', where, scope },
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
if (empty || typeof scopeId !== 'string' || scopeId.trim() === '') {
|
|
198
|
+
throw new DisciplineError(
|
|
199
|
+
`discipline.${where}:scope=${scope} 时必须给非空 scopeId(workspace=cwd 绝对路径 / room=roomId / bot=botId)`,
|
|
200
|
+
'DISCIPLINE_BAD_SCOPE_ID',
|
|
201
|
+
{ field: 'scopeId', where, scope, scopeId },
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return scopeId.trim()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 分类校验。 */
|
|
208
|
+
function normalizeKind(kind, where) {
|
|
209
|
+
if (typeof kind !== 'string' || !KINDS.includes(kind)) {
|
|
210
|
+
throw new DisciplineError(
|
|
211
|
+
`discipline.${where}:非法 kind=${JSON.stringify(kind)}(合法值:${KINDS.join(' | ')})`,
|
|
212
|
+
'DISCIPLINE_BAD_KIND',
|
|
213
|
+
{ field: 'kind', where, kind },
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
return kind
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 标题校验(≤120)。 */
|
|
220
|
+
function normalizeTitle(title, where) {
|
|
221
|
+
const s = requireString(title, 'title', where).trim()
|
|
222
|
+
if (s.length > LIMITS.title) {
|
|
223
|
+
throw new DisciplineError(
|
|
224
|
+
`discipline.${where}:title 超长(${s.length} > ${LIMITS.title})——标题是合并去重键,不能任意膨胀`,
|
|
225
|
+
'DISCIPLINE_TITLE_TOO_LONG',
|
|
226
|
+
{ field: 'title', where, length: s.length, max: LIMITS.title },
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
return s
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 正文校验(非空,≤4000)。不 trim 正文:markdown 缩进有意义。 */
|
|
233
|
+
function normalizeContent(content, where) {
|
|
234
|
+
requireString(content, 'content', where)
|
|
235
|
+
if (typeof content !== 'string') return content
|
|
236
|
+
if (content.length > LIMITS.content) {
|
|
237
|
+
throw new DisciplineError(
|
|
238
|
+
`discipline.${where}:content 超长(${content.length} > ${LIMITS.content})`,
|
|
239
|
+
'DISCIPLINE_CONTENT_TOO_LONG',
|
|
240
|
+
{ field: 'content', where, length: content.length, max: LIMITS.content },
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
return content
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** priority 归一(缺省 0;必须是有限数字)。 */
|
|
247
|
+
function normalizePriority(priority, where) {
|
|
248
|
+
if (priority === undefined || priority === null) return 0
|
|
249
|
+
// UI 表单可能把数字序列化成字符串("5"):这是等价值,不算坏输入;
|
|
250
|
+
// 但 "high" 这种真非法值仍必须抛错 —— 不静默变 0。
|
|
251
|
+
if (typeof priority === 'string' && priority.trim() !== '' && Number.isFinite(Number(priority))) {
|
|
252
|
+
return Number(priority)
|
|
253
|
+
}
|
|
254
|
+
if (typeof priority !== 'number' || !Number.isFinite(priority)) {
|
|
255
|
+
throw new DisciplineError(
|
|
256
|
+
`discipline.${where}:priority 必须是有限数字(收到 ${JSON.stringify(priority)})`,
|
|
257
|
+
'DISCIPLINE_BAD_FIELD',
|
|
258
|
+
{ field: 'priority', where },
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
return priority
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** enabled 归一(缺省 true;必须是布尔)。 */
|
|
265
|
+
function normalizeEnabled(enabled, where) {
|
|
266
|
+
if (enabled === undefined || enabled === null) return true
|
|
267
|
+
if (typeof enabled !== 'boolean') {
|
|
268
|
+
throw new DisciplineError(
|
|
269
|
+
`discipline.${where}:enabled 必须是布尔(收到 ${JSON.stringify(enabled)})`,
|
|
270
|
+
'DISCIPLINE_BAD_FIELD',
|
|
271
|
+
{ field: 'enabled', where },
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
return enabled
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* cwd 归一(只用于比较,不改变存储值):
|
|
279
|
+
* 去尾部分隔符、按平台处理大小写。
|
|
280
|
+
* 为什么不改存储值:用户看到的是自己写入的原样路径;比较用归一值即可。
|
|
281
|
+
*/
|
|
282
|
+
function normalizeCwd(path) {
|
|
283
|
+
if (typeof path !== 'string' || path.trim() === '') return null
|
|
284
|
+
let s = path.trim()
|
|
285
|
+
try {
|
|
286
|
+
s = normalizePath(s)
|
|
287
|
+
} catch {
|
|
288
|
+
/* 非法路径按原样比较,不抛——比较不该比写还脆 */
|
|
289
|
+
}
|
|
290
|
+
if (s.length > 1) s = s.replace(/[\\/]+$/, '')
|
|
291
|
+
if (process.platform === 'win32') s = s.toLowerCase()
|
|
292
|
+
return s
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* token 粗算(**与 memory.js:estimateTokens 同口径**,见文件头说明):
|
|
297
|
+
* ASCII 按 4 字符/token,宽字符(> U+2E80,含中日韩)按 1.5 字符/token。
|
|
298
|
+
* @param {string} text
|
|
299
|
+
* @returns {number}
|
|
300
|
+
*/
|
|
301
|
+
function estimateTokens(text) {
|
|
302
|
+
const s = String(text ?? '')
|
|
303
|
+
if (s === '') return 0
|
|
304
|
+
let wide = 0
|
|
305
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
306
|
+
if (s.charCodeAt(i) > 0x2e80) wide += 1
|
|
307
|
+
}
|
|
308
|
+
const narrow = s.length - wide
|
|
309
|
+
return Math.ceil(narrow / 4 + wide / 1.5)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 合并时的排序:**priority 降序 → scope 具体度降序 → 创建时间升序 → id 升序**。
|
|
314
|
+
* 后两级只是确定性 tie-break(避免同一份数据两次 resolve 顺序不同——
|
|
315
|
+
* 那会让「预算裁剪丢哪条」变成不可复现的随机行为)。
|
|
316
|
+
*/
|
|
317
|
+
function compareDisciplines(a, b) {
|
|
318
|
+
const byPriority = (Number(b.priority) || 0) - (Number(a.priority) || 0)
|
|
319
|
+
if (byPriority !== 0) return byPriority
|
|
320
|
+
const byScope = (SCOPE_RANK[b.scope] ?? 0) - (SCOPE_RANK[a.scope] ?? 0)
|
|
321
|
+
if (byScope !== 0) return byScope
|
|
322
|
+
const byCreated = (Number(a.createdAt) || 0) - (Number(b.createdAt) || 0)
|
|
323
|
+
if (byCreated !== 0) return byCreated
|
|
324
|
+
return idNumber(a.id) - idNumber(b.id)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 「谁更具体」判定(同 title 去重时保留哪条):
|
|
329
|
+
* scope 具体度优先(契约原文),同级再看 priority / updatedAt / id。
|
|
330
|
+
* 注意:**与排序用的 compare 不同** —— 去重不能让「高 priority 的全局条目」
|
|
331
|
+
* 顶掉「低 priority 的 bot 条目」,否则「具体优先」这条规则形同虚设。
|
|
332
|
+
*/
|
|
333
|
+
function isMoreSpecific(candidate, current) {
|
|
334
|
+
const rankGap = (SCOPE_RANK[candidate.scope] ?? 0) - (SCOPE_RANK[current.scope] ?? 0)
|
|
335
|
+
if (rankGap !== 0) return rankGap > 0
|
|
336
|
+
const prioGap = (Number(candidate.priority) || 0) - (Number(current.priority) || 0)
|
|
337
|
+
if (prioGap !== 0) return prioGap > 0
|
|
338
|
+
const updatedGap = (Number(candidate.updatedAt) || 0) - (Number(current.updatedAt) || 0)
|
|
339
|
+
if (updatedGap !== 0) return updatedGap > 0
|
|
340
|
+
return idNumber(candidate.id) > idNumber(current.id)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 省略提示行(契约原文:`⚠️ 已省略 N 条(超出预算)`)。 */
|
|
344
|
+
function truncatedLine(count) {
|
|
345
|
+
return `${TRUNCATED_PREFIX}${count} 条(超出预算)`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 把条目渲染成分节 markdown(小节顺序 = 契约要求,节内保持排序结果)。
|
|
350
|
+
* @param {object[]} items 已排序的条目
|
|
351
|
+
* @returns {string}
|
|
352
|
+
*/
|
|
353
|
+
function renderItems(items) {
|
|
354
|
+
const blocks = []
|
|
355
|
+
for (const kind of KIND_ORDER) {
|
|
356
|
+
const group = items.filter((d) => d.kind === kind)
|
|
357
|
+
if (group.length === 0) continue
|
|
358
|
+
const lines = [KIND_SECTION[kind]]
|
|
359
|
+
for (const d of group) {
|
|
360
|
+
// 正文多行时缩进到 bullet 之下,否则 markdown 会把续行渲染成新段落
|
|
361
|
+
const body = String(d.content).split('\n')
|
|
362
|
+
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
|
363
|
+
.join('\n')
|
|
364
|
+
lines.push(`- **${d.title}**:${body}`)
|
|
365
|
+
}
|
|
366
|
+
blocks.push(lines.join('\n'))
|
|
367
|
+
}
|
|
368
|
+
return blocks.join('\n\n')
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
373
|
+
* **不做静默兜底**(与 board.js 同一纪律)。
|
|
374
|
+
* @param {{ stateDir?: string }} config
|
|
375
|
+
* @returns {string}
|
|
376
|
+
*/
|
|
377
|
+
function resolveStateDir(config) {
|
|
378
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
379
|
+
return config.stateDir.trim()
|
|
380
|
+
}
|
|
381
|
+
if (kernel && typeof kernel.stateDir === 'function') {
|
|
382
|
+
return kernel.stateDir()
|
|
383
|
+
}
|
|
384
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
385
|
+
throw new DisciplineError(
|
|
386
|
+
`discipline.createDiscipline:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
|
|
387
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
388
|
+
'DISCIPLINE_NO_STATE_DIR',
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 把 `(ctx, config)` 归一。允许「只传一个 config 对象」的简写(`{ stateDir }`),
|
|
394
|
+
* 因为工具层/测试里手写实例时更顺手;两种写法语义完全一致。
|
|
395
|
+
*/
|
|
396
|
+
function splitArgs(ctx, config) {
|
|
397
|
+
const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
|
|
398
|
+
if (cfgGiven) return { context: ctx ?? {}, config }
|
|
399
|
+
if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') {
|
|
400
|
+
return { context: {}, config: ctx }
|
|
401
|
+
}
|
|
402
|
+
return { context: ctx ?? {}, config: config ?? {} }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 创建分级纪律服务实例。
|
|
409
|
+
*
|
|
410
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志,缺失即降级为 no-op)
|
|
411
|
+
* @param {{ stateDir?: string, name?: string }} [config]
|
|
412
|
+
* @returns {object} discipline service
|
|
413
|
+
*/
|
|
414
|
+
export function createDiscipline(ctx, config = {}) {
|
|
415
|
+
const { context, config: cfg } = splitArgs(ctx, config)
|
|
416
|
+
const stateDir = resolveStateDir(cfg)
|
|
417
|
+
|
|
418
|
+
const jsonlPath = statePath(stateDir, 'discipline.jsonl')
|
|
419
|
+
const snapPath = statePath(stateDir, 'discipline.snap.json')
|
|
420
|
+
|
|
421
|
+
const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
422
|
+
const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
423
|
+
|
|
424
|
+
/** @type {Map<string, object>} id → Discipline(插入序 = 创建序) */
|
|
425
|
+
const disciplines = new Map()
|
|
426
|
+
/** @type {object[]} 全部事件(审计流,按落盘顺序) */
|
|
427
|
+
const events = []
|
|
428
|
+
/** @type {Map<string, number>} id → 已见到的最新 rev(回放幂等用) */
|
|
429
|
+
const lastRev = new Map()
|
|
430
|
+
|
|
431
|
+
let idCounter = 0
|
|
432
|
+
let disposed = false
|
|
433
|
+
|
|
434
|
+
const diagnostics = {
|
|
435
|
+
stateDir,
|
|
436
|
+
jsonlPath,
|
|
437
|
+
snapPath,
|
|
438
|
+
kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
|
|
439
|
+
hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
|
|
440
|
+
replayedEvents: 0,
|
|
441
|
+
badLines: 0,
|
|
442
|
+
snapshotWriteFailures: 0,
|
|
443
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 已销毁实例的调用一律抛错:静默返回空数据比崩掉更难查。 */
|
|
447
|
+
function assertAlive(where) {
|
|
448
|
+
if (disposed) {
|
|
449
|
+
throw new DisciplineError(
|
|
450
|
+
`discipline.${where}:实例已 dispose(),请重新 createDiscipline()(拒绝在已销毁实例上静默返回空数据)`,
|
|
451
|
+
'DISCIPLINE_DISPOSED',
|
|
452
|
+
{ where },
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
/** 快照内容(落盘形态)。 */
|
|
460
|
+
const snapshot = () => ({
|
|
461
|
+
version: 1,
|
|
462
|
+
savedAt: Date.now(),
|
|
463
|
+
idCounter,
|
|
464
|
+
disciplines: [...disciplines.values()],
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 提交一次变更:**先落事件,再改内存,最后写快照**。
|
|
469
|
+
*
|
|
470
|
+
* 顺序不可颠倒:append 失败时内存必须原封不动(否则内存与磁盘分叉,
|
|
471
|
+
* 重启回放后状态凭空回退 —— 这是最恶劣的静默失败)。
|
|
472
|
+
*
|
|
473
|
+
* @param {string} disciplineId
|
|
474
|
+
* @param {'create'|'update'|'remove'} action
|
|
475
|
+
* @param {string|null} byId
|
|
476
|
+
* @param {number} at
|
|
477
|
+
* @param {object|null} before 变更前记录(create 为 null)
|
|
478
|
+
* @param {object|null} after 变更后记录(remove 为 null)
|
|
479
|
+
* @returns {object|null} 克隆后的 after
|
|
480
|
+
*/
|
|
481
|
+
function commit(disciplineId, action, byId, at, before, after) {
|
|
482
|
+
/** @type {Record<string, unknown>} */
|
|
483
|
+
const event = {
|
|
484
|
+
disciplineId,
|
|
485
|
+
rev: after ? after.rev : ((before?.rev ?? 0) + 1),
|
|
486
|
+
action,
|
|
487
|
+
byId: byId ?? null,
|
|
488
|
+
at,
|
|
489
|
+
before: before ? clone(before) : null,
|
|
490
|
+
after: after ? clone(after) : null,
|
|
491
|
+
}
|
|
492
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
493
|
+
throw new DisciplineError(
|
|
494
|
+
`discipline.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
|
|
495
|
+
'DISCIPLINE_APPEND_FAILED',
|
|
496
|
+
{ id: disciplineId, action, path: jsonlPath },
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
if (after) {
|
|
500
|
+
disciplines.set(disciplineId, clone(after))
|
|
501
|
+
lastRev.set(disciplineId, after.rev)
|
|
502
|
+
idCounter = Math.max(idCounter, idNumber(disciplineId))
|
|
503
|
+
} else {
|
|
504
|
+
disciplines.delete(disciplineId)
|
|
505
|
+
lastRev.set(disciplineId, event.rev)
|
|
506
|
+
}
|
|
507
|
+
events.push(event)
|
|
508
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
509
|
+
diagnostics.snapshotWriteFailures += 1
|
|
510
|
+
warn(`[cs-bots/discipline] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
511
|
+
}
|
|
512
|
+
return after ? clone(after) : null
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── 回放 ──────────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 从 `discipline.jsonl`(真相源)重建内存态;jsonl 为空才回落到快照。
|
|
519
|
+
* 坏行**不静默丢弃**:计入 diagnostics 并 warn。
|
|
520
|
+
*/
|
|
521
|
+
function hydrate() {
|
|
522
|
+
disciplines.clear()
|
|
523
|
+
events.length = 0
|
|
524
|
+
lastRev.clear()
|
|
525
|
+
idCounter = 0
|
|
526
|
+
disposed = false
|
|
527
|
+
|
|
528
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
529
|
+
diagnostics.badLines = bad
|
|
530
|
+
diagnostics.replayedEvents = rows.length
|
|
531
|
+
if (bad > 0) {
|
|
532
|
+
warn(`[cs-bots/discipline] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (rows.length === 0) {
|
|
536
|
+
const snap = readJson(snapPath, null)
|
|
537
|
+
const list = Array.isArray(snap?.disciplines) ? snap.disciplines : null
|
|
538
|
+
if (list) {
|
|
539
|
+
for (const raw of list) {
|
|
540
|
+
if (!raw || typeof raw.id !== 'string') continue
|
|
541
|
+
const d = clone(raw)
|
|
542
|
+
// 快照是旧版本时补齐字段(向后兼容,不改语义)
|
|
543
|
+
if (typeof d.priority !== 'number') d.priority = 0
|
|
544
|
+
if (typeof d.enabled !== 'boolean') d.enabled = true
|
|
545
|
+
disciplines.set(d.id, d)
|
|
546
|
+
lastRev.set(d.id, Number.isFinite(d.rev) ? d.rev : 1)
|
|
547
|
+
idCounter = Math.max(idCounter, idNumber(d.id))
|
|
548
|
+
}
|
|
549
|
+
if (Number.isFinite(snap?.idCounter)) idCounter = Math.max(idCounter, snap.idCounter)
|
|
550
|
+
diagnostics.hydratedFrom = 'snapshot'
|
|
551
|
+
info(`[cs-bots/discipline] 回放完成:无事件流 → 由快照恢复 ${disciplines.size} 条纪律`)
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
diagnostics.hydratedFrom = 'empty'
|
|
555
|
+
info('[cs-bots/discipline] 回放完成:空库(无事件流、无快照)')
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
|
|
560
|
+
let skipped = 0
|
|
561
|
+
for (const ev of rows) {
|
|
562
|
+
const id = typeof ev?.disciplineId === 'string' ? ev.disciplineId : ev?.after?.id
|
|
563
|
+
if (!id) {
|
|
564
|
+
skipped += 1
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
const rev = Number.isFinite(ev?.rev) ? ev.rev : undefined
|
|
568
|
+
const current = lastRev.get(id) ?? 0
|
|
569
|
+
if (rev !== undefined && rev <= current) {
|
|
570
|
+
skipped += 1
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
573
|
+
if (ev.after && ev.after.id === id) {
|
|
574
|
+
const d = clone(ev.after)
|
|
575
|
+
if (typeof d.priority !== 'number') d.priority = 0
|
|
576
|
+
if (typeof d.enabled !== 'boolean') d.enabled = true
|
|
577
|
+
disciplines.set(id, d)
|
|
578
|
+
lastRev.set(id, rev ?? d.rev)
|
|
579
|
+
idCounter = Math.max(idCounter, idNumber(id))
|
|
580
|
+
} else if (ev.action === 'remove') {
|
|
581
|
+
disciplines.delete(id)
|
|
582
|
+
lastRev.set(id, rev ?? current + 1)
|
|
583
|
+
} else {
|
|
584
|
+
skipped += 1
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
events.push(clone(ev))
|
|
588
|
+
}
|
|
589
|
+
if (skipped > 0) {
|
|
590
|
+
warn(`[cs-bots/discipline] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
|
|
591
|
+
}
|
|
592
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
593
|
+
info(`[cs-bots/discipline] 回放完成:${rows.length} 条事件 → ${disciplines.size} 条纪律`)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── 校验组合 ──────────────────────────────────────────────────────────────
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* 归一一条纪律的可写字段(create / import 共用)。
|
|
600
|
+
* @param {object} input
|
|
601
|
+
* @param {string} where
|
|
602
|
+
*/
|
|
603
|
+
function normalizeFields(input, where) {
|
|
604
|
+
const scope = normalizeScope(input?.scope, where)
|
|
605
|
+
return {
|
|
606
|
+
scope,
|
|
607
|
+
scopeId: normalizeScopeId(scope, input?.scopeId, where),
|
|
608
|
+
kind: normalizeKind(input?.kind, where),
|
|
609
|
+
title: normalizeTitle(input?.title, where),
|
|
610
|
+
content: normalizeContent(input?.content, where),
|
|
611
|
+
priority: normalizePriority(input?.priority, where),
|
|
612
|
+
enabled: normalizeEnabled(input?.enabled, where),
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** CAS 前置校验:expectedRev 必须显式给、且与当前 rev 一致。 */
|
|
617
|
+
function assertExpectedRev(current, expectedRev, where) {
|
|
618
|
+
if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
|
|
619
|
+
throw new DisciplineError(
|
|
620
|
+
`discipline.${where}:expectedRev 必须是整数(CAS 需要显式期望版本,条目 ${current.id} 当前 rev=${current.rev},`
|
|
621
|
+
+ `收到 ${JSON.stringify(expectedRev)})`,
|
|
622
|
+
'DISCIPLINE_MISSING_EXPECTED_REV',
|
|
623
|
+
{ id: current.id, rev: current.rev, where },
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
if (current.rev !== expectedRev) {
|
|
627
|
+
throw new DisciplineError(
|
|
628
|
+
`discipline.${where} CAS 冲突:条目 ${current.id} 期望 rev=${expectedRev},实际 rev=${current.rev} —— `
|
|
629
|
+
+ '拒绝静默覆盖,请重新 getDiscipline() 后按最新 rev 重试',
|
|
630
|
+
'DISCIPLINE_REV_CONFLICT',
|
|
631
|
+
{ id: current.id, expectedRev, rev: current.rev, where },
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** 取记录或抛错。 */
|
|
637
|
+
function mustGet(id, where) {
|
|
638
|
+
const current = disciplines.get(id)
|
|
639
|
+
if (!current) {
|
|
640
|
+
throw new DisciplineError(
|
|
641
|
+
`discipline.${where}:条目不存在 id=${id}`,
|
|
642
|
+
'DISCIPLINE_NOT_FOUND',
|
|
643
|
+
{ id, where },
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
return current
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── API:写 ────────────────────────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 新建纪律条目。
|
|
653
|
+
*
|
|
654
|
+
* @param {{ scope: string, scopeId?: string|null, kind: string, title: string, content: string,
|
|
655
|
+
* priority?: number, enabled?: boolean, by: string, now?: number }} input
|
|
656
|
+
* @returns {object} Discipline
|
|
657
|
+
*/
|
|
658
|
+
function createDiscipline(input = {}) {
|
|
659
|
+
assertAlive('createDiscipline')
|
|
660
|
+
const fields = normalizeFields(input, 'create')
|
|
661
|
+
const byId = requireActor(input, 'create')
|
|
662
|
+
const at = normalizeNow(input?.now, 'create')
|
|
663
|
+
|
|
664
|
+
const nextCounter = idCounter + 1
|
|
665
|
+
const id = `d-${nextCounter}`
|
|
666
|
+
/** @type {Record<string, unknown>} */
|
|
667
|
+
const record = {
|
|
668
|
+
id,
|
|
669
|
+
rev: 1,
|
|
670
|
+
...fields,
|
|
671
|
+
createdAt: at,
|
|
672
|
+
createdBy: byId,
|
|
673
|
+
updatedAt: at,
|
|
674
|
+
updatedBy: byId,
|
|
675
|
+
}
|
|
676
|
+
idCounter = nextCounter
|
|
677
|
+
try {
|
|
678
|
+
return commit(id, 'create', byId, at, null, record)
|
|
679
|
+
} catch (error) {
|
|
680
|
+
// 落盘失败 → 计数器回滚,绝不留下「内存有、磁盘无」的幽灵 id
|
|
681
|
+
idCounter = nextCounter - 1
|
|
682
|
+
throw error
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* 按 `expectedRev` 做 CAS 变更。**rev 不匹配即抛错,绝不静默覆盖。**
|
|
688
|
+
* 可改字段仅:title / content / kind / priority / enabled(scope 不可迁移,
|
|
689
|
+
* 迁移会让合并顺序语义失效 —— 需要换层级请 remove 后重建)。
|
|
690
|
+
*
|
|
691
|
+
* @param {{ id: string, expectedRev: number, title?: string, content?: string, kind?: string,
|
|
692
|
+
* priority?: number, enabled?: boolean, by: string, now?: number }} input
|
|
693
|
+
* @returns {object} 变更后的 Discipline
|
|
694
|
+
*/
|
|
695
|
+
function updateDiscipline(input = {}) {
|
|
696
|
+
assertAlive('updateDiscipline')
|
|
697
|
+
const { id, expectedRev, title, content, kind, priority, enabled, by, byId: byAlias, now, ...rest } = input ?? {}
|
|
698
|
+
const disciplineId = requireString(id, 'id', 'update')
|
|
699
|
+
const byId = requireActor({ by, byId: byAlias }, 'update')
|
|
700
|
+
|
|
701
|
+
const current = mustGet(disciplineId, 'update')
|
|
702
|
+
assertExpectedRev(current, expectedRev, 'update')
|
|
703
|
+
|
|
704
|
+
const unknown = Object.keys(rest ?? {})
|
|
705
|
+
if (unknown.length > 0) {
|
|
706
|
+
throw new DisciplineError(
|
|
707
|
+
`discipline.update:未知字段 ${unknown.join(', ')}(可改:title | content | kind | priority | enabled;`
|
|
708
|
+
+ 'scope/scopeId 不可改,请 remove 后重建)——拒绝静默忽略',
|
|
709
|
+
'DISCIPLINE_UNKNOWN_FIELD',
|
|
710
|
+
{ id: disciplineId, unknown },
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const patch = {}
|
|
715
|
+
if (title !== undefined) patch.title = normalizeTitle(title, 'update')
|
|
716
|
+
if (content !== undefined) patch.content = normalizeContent(content, 'update')
|
|
717
|
+
if (kind !== undefined) patch.kind = normalizeKind(kind, 'update')
|
|
718
|
+
if (priority !== undefined) patch.priority = normalizePriority(priority, 'update')
|
|
719
|
+
if (enabled !== undefined) patch.enabled = normalizeEnabled(enabled, 'update')
|
|
720
|
+
if (Object.keys(patch).length === 0) {
|
|
721
|
+
throw new DisciplineError(
|
|
722
|
+
'discipline.update:未提供任何可改字段(title | content | kind | priority | enabled)',
|
|
723
|
+
'DISCIPLINE_NO_PATCH_FIELDS',
|
|
724
|
+
{ id: disciplineId },
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const at = normalizeNow(now, 'update')
|
|
729
|
+
const before = clone(current)
|
|
730
|
+
/** @type {Record<string, any>} */
|
|
731
|
+
const next = clone(current)
|
|
732
|
+
next.rev = current.rev + 1
|
|
733
|
+
Object.assign(next, patch)
|
|
734
|
+
next.updatedAt = at
|
|
735
|
+
next.updatedBy = byId
|
|
736
|
+
return commit(disciplineId, 'update', byId, at, before, next)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* 删除条目(CAS 保护)。事件流保留 `before` 快照,`history()` 可追。
|
|
741
|
+
*
|
|
742
|
+
* @param {{ id: string, expectedRev: number, by: string }} input
|
|
743
|
+
* @returns {{ removed: string }}
|
|
744
|
+
*/
|
|
745
|
+
function removeDiscipline(input = {}) {
|
|
746
|
+
assertAlive('removeDiscipline')
|
|
747
|
+
const { id, expectedRev, by, byId: byAlias } = input ?? {}
|
|
748
|
+
const disciplineId = requireString(id, 'id', 'remove')
|
|
749
|
+
const byId = requireActor({ by, byId: byAlias }, 'remove')
|
|
750
|
+
|
|
751
|
+
const current = mustGet(disciplineId, 'remove')
|
|
752
|
+
assertExpectedRev(current, expectedRev, 'remove')
|
|
753
|
+
|
|
754
|
+
commit(disciplineId, 'remove', byId, Date.now(), clone(current), null)
|
|
755
|
+
return { removed: disciplineId }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* 批量导入(去重键 = scope + scopeId + title)。
|
|
760
|
+
*
|
|
761
|
+
* 单条非法**不中断整批**:进 `errors[]`(DESIGN §特性② / library 同款纪律)。
|
|
762
|
+
* 但**落盘失败仍抛错** —— 那不是「这条数据不好」,而是整台机器写不进去了,
|
|
763
|
+
* 继续循环只会制造更多「内存有、磁盘无」的分叉。
|
|
764
|
+
*
|
|
765
|
+
* @param {{ items: object[], by: string, mode?: 'skip'|'overwrite'|'rename', now?: number }} input
|
|
766
|
+
* @returns {{ created: object[], updated: object[], skipped: object[], errors: {index: number, title?: string, reason: string, code?: string}[] }}
|
|
767
|
+
*/
|
|
768
|
+
function importDisciplines(input = {}) {
|
|
769
|
+
assertAlive('importDisciplines')
|
|
770
|
+
const { items, by, byId: byAlias, now } = input ?? {}
|
|
771
|
+
// mode 不能靠解构缺省处理空串:UI 下拉框未选时可能传 '' / null,
|
|
772
|
+
// 两者语义都是「按默认策略」,与不传等价(否则会假报 DISCIPLINE_BAD_MODE)。
|
|
773
|
+
const modeRaw = input?.mode
|
|
774
|
+
const mode = modeRaw === undefined || modeRaw === null || modeRaw === '' ? 'skip' : modeRaw
|
|
775
|
+
const byId = requireActor({ by, byId: byAlias }, 'import')
|
|
776
|
+
if (!Array.isArray(items)) {
|
|
777
|
+
throw new DisciplineError(
|
|
778
|
+
`discipline.import:items 必须是数组(收到 ${JSON.stringify(items)})`,
|
|
779
|
+
'DISCIPLINE_BAD_ITEMS',
|
|
780
|
+
{ field: 'items' },
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
if (!IMPORT_MODES.includes(mode)) {
|
|
784
|
+
throw new DisciplineError(
|
|
785
|
+
`discipline.import:非法 mode=${JSON.stringify(mode)}(合法值:${IMPORT_MODES.join(' | ')})`,
|
|
786
|
+
'DISCIPLINE_BAD_MODE',
|
|
787
|
+
{ field: 'mode', mode },
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
const at = normalizeNow(now, 'import')
|
|
791
|
+
|
|
792
|
+
/** @type {{ created: object[], updated: object[], skipped: object[], errors: object[] }} */
|
|
793
|
+
const result = { created: [], updated: [], skipped: [], errors: [] }
|
|
794
|
+
|
|
795
|
+
/** 去重键:同一 scope 下 title 视为同一条(title 已 trim)。 */
|
|
796
|
+
const dedupeKey = (rec) => {
|
|
797
|
+
const scopeId = rec.scope === 'workspace' ? normalizeCwd(rec.scopeId) : rec.scopeId
|
|
798
|
+
return `${rec.scope}|${scopeId ?? ''}|${rec.title}`
|
|
799
|
+
}
|
|
800
|
+
/** 在现有库里按去重键找条目。 */
|
|
801
|
+
const findByKey = (rec) => {
|
|
802
|
+
const key = dedupeKey(rec)
|
|
803
|
+
for (const d of disciplines.values()) {
|
|
804
|
+
if (dedupeKey(d) === key) return d
|
|
805
|
+
}
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
/** rename 模式:在当前库里找一个不冲突的标题。 */
|
|
809
|
+
const uniqueTitle = (rec) => {
|
|
810
|
+
const base = rec.title
|
|
811
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
812
|
+
const suffix = ` (${n})`
|
|
813
|
+
const room = Math.max(0, LIMITS.title - suffix.length)
|
|
814
|
+
const candidate = `${base.slice(0, room).trim()}${suffix}`
|
|
815
|
+
if (!findByKey({ ...rec, title: candidate })) return candidate
|
|
816
|
+
}
|
|
817
|
+
throw new DisciplineError(
|
|
818
|
+
`discipline.import:标题 ${JSON.stringify(base)} 的候选后缀已用尽(≥1000 条同名)——请先整理数据`,
|
|
819
|
+
'DISCIPLINE_TITLE_EXHAUSTED',
|
|
820
|
+
{ title: base },
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
825
|
+
const raw = items[index]
|
|
826
|
+
try {
|
|
827
|
+
const fields = normalizeFields(raw, `import[${index}]`)
|
|
828
|
+
const existing = findByKey(fields)
|
|
829
|
+
if (!existing) {
|
|
830
|
+
const created = commitCreateForImport(fields, byId, at)
|
|
831
|
+
result.created.push(created)
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
if (mode === 'skip') {
|
|
835
|
+
result.skipped.push({
|
|
836
|
+
index,
|
|
837
|
+
id: existing.id,
|
|
838
|
+
title: fields.title,
|
|
839
|
+
scope: fields.scope,
|
|
840
|
+
scopeId: fields.scopeId,
|
|
841
|
+
reason: 'duplicate',
|
|
842
|
+
})
|
|
843
|
+
continue
|
|
844
|
+
}
|
|
845
|
+
if (mode === 'overwrite') {
|
|
846
|
+
// 走同一条 CAS 更新通道,保证 rev 递增与事件流完整
|
|
847
|
+
const patch = {
|
|
848
|
+
content: fields.content,
|
|
849
|
+
kind: fields.kind,
|
|
850
|
+
priority: fields.priority,
|
|
851
|
+
enabled: fields.enabled,
|
|
852
|
+
}
|
|
853
|
+
const updated = updateDiscipline({
|
|
854
|
+
id: existing.id,
|
|
855
|
+
expectedRev: existing.rev,
|
|
856
|
+
...patch,
|
|
857
|
+
by: byId,
|
|
858
|
+
now: at,
|
|
859
|
+
})
|
|
860
|
+
result.updated.push(updated)
|
|
861
|
+
continue
|
|
862
|
+
}
|
|
863
|
+
// mode === 'rename'
|
|
864
|
+
const created = commitCreateForImport({ ...fields, title: uniqueTitle(fields) }, byId, at)
|
|
865
|
+
result.created.push(created)
|
|
866
|
+
} catch (error) {
|
|
867
|
+
// 落盘类错误必须上抛(不能当成「这条数据有问题」吞掉)
|
|
868
|
+
if (error instanceof DisciplineError && error.code === 'DISCIPLINE_APPEND_FAILED') throw error
|
|
869
|
+
result.errors.push({
|
|
870
|
+
index,
|
|
871
|
+
title: typeof raw?.title === 'string' ? raw.title : undefined,
|
|
872
|
+
reason: error?.message ?? String(error),
|
|
873
|
+
code: error?.code ?? 'DISCIPLINE_IMPORT_ITEM_FAILED',
|
|
874
|
+
})
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return result
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* import 内部的建条通道(复用 create 的落盘逻辑,但时间统一用 import 的 `at`,
|
|
882
|
+
* 保证同一批导入的 createdAt 一致 —— 排序 tie-break 才有确定性)。
|
|
883
|
+
*/
|
|
884
|
+
function commitCreateForImport(fields, byId, at) {
|
|
885
|
+
const nextCounter = idCounter + 1
|
|
886
|
+
const id = `d-${nextCounter}`
|
|
887
|
+
const record = {
|
|
888
|
+
id,
|
|
889
|
+
rev: 1,
|
|
890
|
+
...fields,
|
|
891
|
+
createdAt: at,
|
|
892
|
+
createdBy: byId,
|
|
893
|
+
updatedAt: at,
|
|
894
|
+
updatedBy: byId,
|
|
895
|
+
}
|
|
896
|
+
idCounter = nextCounter
|
|
897
|
+
try {
|
|
898
|
+
return commit(id, 'create', byId, at, null, record)
|
|
899
|
+
} catch (error) {
|
|
900
|
+
idCounter = nextCounter - 1
|
|
901
|
+
throw error
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ── API:读 ────────────────────────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
/** @param {string} id @returns {object | undefined} */
|
|
908
|
+
function getDiscipline(id) {
|
|
909
|
+
assertAlive('getDiscipline')
|
|
910
|
+
const d = typeof id === 'string' ? disciplines.get(id.trim()) : undefined
|
|
911
|
+
return d ? clone(d) : undefined
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* 列出条目(**创建序**)。所有过滤条件都是 AND。
|
|
916
|
+
*
|
|
917
|
+
* @param {{ scope?: string, scopeId?: string|null, kind?: string, enabled?: boolean, query?: string }} [options]
|
|
918
|
+
* @returns {object[]}
|
|
919
|
+
*/
|
|
920
|
+
function listDisciplines(options = {}) {
|
|
921
|
+
assertAlive('listDisciplines')
|
|
922
|
+
const { scope, scopeId, kind, enabled, query } = options ?? {}
|
|
923
|
+
// '' 表示「UI 清空了该筛选」= 不筛选;null 同理(scopeId 例外,见下)
|
|
924
|
+
const scopeFilter = scope === undefined || scope === null || scope === '' ? undefined : scope
|
|
925
|
+
const kindFilter = kind === undefined || kind === null || kind === '' ? undefined : kind
|
|
926
|
+
// scopeId:undefined/'' = 不筛选;null = 显式筛「global」(global 的 scopeId 就是 null)
|
|
927
|
+
const scopeIdFilter = scopeId === undefined || scopeId === '' ? undefined : scopeId
|
|
928
|
+
if (scopeFilter !== undefined && !SCOPES.includes(scopeFilter)) {
|
|
929
|
+
throw new DisciplineError(
|
|
930
|
+
`discipline.list:非法 scope=${JSON.stringify(scope)}(合法值:${SCOPES.join(' | ')})`,
|
|
931
|
+
'DISCIPLINE_BAD_SCOPE',
|
|
932
|
+
{ field: 'scope', scope },
|
|
933
|
+
)
|
|
934
|
+
}
|
|
935
|
+
if (kindFilter !== undefined && !KINDS.includes(kindFilter)) {
|
|
936
|
+
throw new DisciplineError(
|
|
937
|
+
`discipline.list:非法 kind=${JSON.stringify(kind)}(合法值:${KINDS.join(' | ')})`,
|
|
938
|
+
'DISCIPLINE_BAD_KIND',
|
|
939
|
+
{ field: 'kind', kind },
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
const needEnabled = enabled === undefined || enabled === null ? undefined : Boolean(enabled)
|
|
943
|
+
const q = typeof query === 'string' && query.trim() !== '' ? query.trim().toLowerCase() : null
|
|
944
|
+
|
|
945
|
+
const out = []
|
|
946
|
+
for (const d of disciplines.values()) {
|
|
947
|
+
if (scopeFilter !== undefined && d.scope !== scopeFilter) continue
|
|
948
|
+
if (scopeIdFilter !== undefined && d.scopeId !== scopeIdFilter) continue
|
|
949
|
+
if (kindFilter !== undefined && d.kind !== kindFilter) continue
|
|
950
|
+
if (needEnabled !== undefined && d.enabled !== needEnabled) continue
|
|
951
|
+
if (q) {
|
|
952
|
+
const haystack = `${d.title}\n${d.content}`.toLowerCase()
|
|
953
|
+
if (!haystack.includes(q)) continue
|
|
954
|
+
}
|
|
955
|
+
out.push(clone(d))
|
|
956
|
+
}
|
|
957
|
+
return out
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* **核心**:沿层级合并生效纪律(DESIGN §特性②合并规则 1–6)。
|
|
962
|
+
*
|
|
963
|
+
* 合并顺序(规则 1):global → workspace → room → bot;只取 `enabled !== false`。
|
|
964
|
+
* 排序(规则 3):priority 降序,再 scope 具体度降序。
|
|
965
|
+
* 去重(规则 4):同 title 只保留**最具体**那条,丢弃计入 `dropped`。
|
|
966
|
+
* 预算(规则 6):超出 `tokenBudget` 从**低优先级**(即排序尾部)丢,
|
|
967
|
+
* `truncated=true`,text 末尾加 `⚠️ 已省略 N 条(超出预算)`。
|
|
968
|
+
*
|
|
969
|
+
* `tokenBudget` 不是契约里的必填参数(resolve 允许不带预算做预览),
|
|
970
|
+
* 但透传它能让 UI 用同一函数预览「注入后会不会被裁」。
|
|
971
|
+
*
|
|
972
|
+
* @param {{ botId?: string, cwd?: string, roomId?: string, tokenBudget?: number }} [input]
|
|
973
|
+
* @returns {{ items: object[], text: string, byScope: Record<string, number>, truncated: boolean, dropped: number }}
|
|
974
|
+
*/
|
|
975
|
+
function resolveDisciplines(input = {}) {
|
|
976
|
+
assertAlive('resolveDisciplines')
|
|
977
|
+
const { botId, cwd, roomId, tokenBudget } = input ?? {}
|
|
978
|
+
return resolveInternal({ botId, cwd, roomId, tokenBudget })
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* 注入用渲染:与 resolve 同源,额外返回 `tokens`(与 tokenBudget 同口径)。
|
|
983
|
+
*
|
|
984
|
+
* @param {{ botId?: string, cwd?: string, roomId?: string, tokenBudget?: number }} [input]
|
|
985
|
+
* @returns {{ text: string, items: object[], truncated: boolean, dropped: number, tokens: number }}
|
|
986
|
+
*/
|
|
987
|
+
function renderDisciplinePrompt(input = {}) {
|
|
988
|
+
assertAlive('renderDisciplinePrompt')
|
|
989
|
+
const { botId, cwd, roomId, tokenBudget } = input ?? {}
|
|
990
|
+
const resolved = resolveInternal({ botId, cwd, roomId, tokenBudget })
|
|
991
|
+
return {
|
|
992
|
+
text: resolved.text,
|
|
993
|
+
items: resolved.items,
|
|
994
|
+
truncated: resolved.truncated,
|
|
995
|
+
dropped: resolved.dropped,
|
|
996
|
+
tokens: estimateTokens(resolved.text),
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** resolve / render 的共用实现(避免两套合并逻辑漂移)。 */
|
|
1001
|
+
function resolveInternal({ botId, cwd, roomId, tokenBudget }) {
|
|
1002
|
+
const cwdKey = normalizeCwd(cwd)
|
|
1003
|
+
const botKey = typeof botId === 'string' && botId.trim() !== '' ? botId.trim() : null
|
|
1004
|
+
const roomKey = typeof roomId === 'string' && roomId.trim() !== '' ? roomId.trim() : null
|
|
1005
|
+
|
|
1006
|
+
// 1+2:按层级收集(只取 enabled !== false)
|
|
1007
|
+
const candidates = []
|
|
1008
|
+
for (const d of disciplines.values()) {
|
|
1009
|
+
if (d.enabled === false) continue
|
|
1010
|
+
if (d.scope === 'global') {
|
|
1011
|
+
candidates.push(d)
|
|
1012
|
+
} else if (d.scope === 'workspace') {
|
|
1013
|
+
if (cwdKey && normalizeCwd(d.scopeId) === cwdKey) candidates.push(d)
|
|
1014
|
+
} else if (d.scope === 'room') {
|
|
1015
|
+
if (roomKey && d.scopeId === roomKey) candidates.push(d)
|
|
1016
|
+
} else if (d.scope === 'bot') {
|
|
1017
|
+
if (botKey && d.scopeId === botKey) candidates.push(d)
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// 3:先按 priority → scope 具体度排序(去重的 tie-break 也依赖它)
|
|
1022
|
+
candidates.sort(compareDisciplines)
|
|
1023
|
+
|
|
1024
|
+
// 4:同 title 只留最具体(注意:具体度判定独立于排序,见 isMoreSpecific 注释)
|
|
1025
|
+
/** @type {Map<string, object>} */
|
|
1026
|
+
const byTitle = new Map()
|
|
1027
|
+
let droppedByTitle = 0
|
|
1028
|
+
for (const d of candidates) {
|
|
1029
|
+
const key = String(d.title).trim()
|
|
1030
|
+
const kept = byTitle.get(key)
|
|
1031
|
+
if (!kept) {
|
|
1032
|
+
byTitle.set(key, d)
|
|
1033
|
+
continue
|
|
1034
|
+
}
|
|
1035
|
+
droppedByTitle += 1
|
|
1036
|
+
if (isMoreSpecific(d, kept)) byTitle.set(key, d)
|
|
1037
|
+
}
|
|
1038
|
+
// 去重可能换了代表条目 → 重新排序,保证输出顺序仍符合规则 3
|
|
1039
|
+
let kept = [...byTitle.values()].sort(compareDisciplines)
|
|
1040
|
+
|
|
1041
|
+
// 6:tokenBudget 裁剪(从尾部 = 最低优先级开始丢)
|
|
1042
|
+
let droppedByBudget = 0
|
|
1043
|
+
let truncated = false
|
|
1044
|
+
let text
|
|
1045
|
+
const budget = Number.isFinite(tokenBudget) && tokenBudget > 0 ? Math.floor(tokenBudget) : Infinity
|
|
1046
|
+
if (Number.isFinite(budget)) {
|
|
1047
|
+
for (;;) {
|
|
1048
|
+
const body = renderItems(kept)
|
|
1049
|
+
const candidateText = droppedByBudget === 0
|
|
1050
|
+
? body
|
|
1051
|
+
: (body === '' ? truncatedLine(droppedByBudget) : `${body}\n\n${truncatedLine(droppedByBudget)}`)
|
|
1052
|
+
if (estimateTokens(candidateText) <= budget) {
|
|
1053
|
+
text = candidateText
|
|
1054
|
+
break
|
|
1055
|
+
}
|
|
1056
|
+
if (kept.length === 0) {
|
|
1057
|
+
// 预算连省略提示都装不下:仍然返回提示(绝不静默伪装成「无纪律」)
|
|
1058
|
+
text = candidateText
|
|
1059
|
+
break
|
|
1060
|
+
}
|
|
1061
|
+
kept = kept.slice(0, -1)
|
|
1062
|
+
droppedByBudget += 1
|
|
1063
|
+
truncated = true
|
|
1064
|
+
}
|
|
1065
|
+
} else {
|
|
1066
|
+
text = renderItems(kept)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// 5:byScope 统计(固定四键,UI 不用判空)
|
|
1070
|
+
const byScope = { global: 0, workspace: 0, room: 0, bot: 0 }
|
|
1071
|
+
for (const d of kept) {
|
|
1072
|
+
byScope[d.scope] = (byScope[d.scope] ?? 0) + 1
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return {
|
|
1076
|
+
items: kept.map((d) => clone(d)),
|
|
1077
|
+
text,
|
|
1078
|
+
byScope,
|
|
1079
|
+
truncated,
|
|
1080
|
+
// dropped = 同 title 去重丢弃 + 预算裁剪丢弃(两类都算「没进最终内容」)
|
|
1081
|
+
dropped: droppedByTitle + droppedByBudget,
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 审计流查询。
|
|
1087
|
+
* @param {string} [id] 省略则返回全部事件
|
|
1088
|
+
* @returns {object[]}
|
|
1089
|
+
*/
|
|
1090
|
+
function history(id) {
|
|
1091
|
+
assertAlive('history')
|
|
1092
|
+
const rows = typeof id === 'string' && id !== ''
|
|
1093
|
+
? events.filter((e) => e.disciplineId === id)
|
|
1094
|
+
: events
|
|
1095
|
+
return clone(rows)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
1100
|
+
* @returns {{ total: number, enabled: number, disabled: number, byScope: object, byKind: object, events: number, idCounter: number }}
|
|
1101
|
+
*/
|
|
1102
|
+
function stats() {
|
|
1103
|
+
assertAlive('stats')
|
|
1104
|
+
const byScope = { global: 0, workspace: 0, room: 0, bot: 0 }
|
|
1105
|
+
const byKind = { rule: 0, requirement: 0, principle: 0, style: 0 }
|
|
1106
|
+
let enabled = 0
|
|
1107
|
+
let disabled = 0
|
|
1108
|
+
for (const d of disciplines.values()) {
|
|
1109
|
+
byScope[d.scope] = (byScope[d.scope] ?? 0) + 1
|
|
1110
|
+
byKind[d.kind] = (byKind[d.kind] ?? 0) + 1
|
|
1111
|
+
if (d.enabled === false) disabled += 1
|
|
1112
|
+
else enabled += 1
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
total: disciplines.size,
|
|
1116
|
+
enabled,
|
|
1117
|
+
disabled,
|
|
1118
|
+
byScope,
|
|
1119
|
+
byKind,
|
|
1120
|
+
events: events.length,
|
|
1121
|
+
idCounter,
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* 从 `discipline.jsonl` 重建内存态(重启回放一致性)。
|
|
1127
|
+
* @returns {{ from: string, disciplines: number, events: number, bad: number }}
|
|
1128
|
+
*/
|
|
1129
|
+
function replay() {
|
|
1130
|
+
assertAlive('replay')
|
|
1131
|
+
hydrate()
|
|
1132
|
+
diagnostics.lastReplayAt = Date.now()
|
|
1133
|
+
return {
|
|
1134
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
1135
|
+
disciplines: disciplines.size,
|
|
1136
|
+
events: events.length,
|
|
1137
|
+
bad: diagnostics.badLines,
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* 销毁实例(释放内存、拒绝后续调用)。
|
|
1143
|
+
* 为什么不写盘/不删文件:事件流是长期资产,dispose 只是「这个实例下班了」。
|
|
1144
|
+
* @returns {{ disposed: boolean, disciplines: number, events: number }}
|
|
1145
|
+
*/
|
|
1146
|
+
function dispose() {
|
|
1147
|
+
const out = { disposed: true, disciplines: disciplines.size, events: events.length }
|
|
1148
|
+
disposed = true
|
|
1149
|
+
disciplines.clear()
|
|
1150
|
+
events.length = 0
|
|
1151
|
+
lastRev.clear()
|
|
1152
|
+
return out
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** 首次构造即回放,保证 `list()` 在重启后马上可用。 */
|
|
1156
|
+
hydrate()
|
|
1157
|
+
|
|
1158
|
+
const service = {
|
|
1159
|
+
name: cfg.name ?? 'discipline',
|
|
1160
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
1161
|
+
// 契约函数名(DESIGN §特性②,签名固定)
|
|
1162
|
+
createDiscipline,
|
|
1163
|
+
updateDiscipline,
|
|
1164
|
+
removeDiscipline,
|
|
1165
|
+
getDiscipline,
|
|
1166
|
+
listDisciplines,
|
|
1167
|
+
importDisciplines,
|
|
1168
|
+
resolveDisciplines,
|
|
1169
|
+
renderDisciplinePrompt,
|
|
1170
|
+
stats,
|
|
1171
|
+
replay,
|
|
1172
|
+
dispose,
|
|
1173
|
+
history,
|
|
1174
|
+
// 短别名:api/tools 层若按 board 的 create/get/list 风格调用也能直接走
|
|
1175
|
+
create: createDiscipline,
|
|
1176
|
+
update: updateDiscipline,
|
|
1177
|
+
remove: removeDiscipline,
|
|
1178
|
+
get: getDiscipline,
|
|
1179
|
+
list: listDisciplines,
|
|
1180
|
+
import: importDisciplines,
|
|
1181
|
+
resolve: resolveDisciplines,
|
|
1182
|
+
render: renderDisciplinePrompt,
|
|
1183
|
+
diagnostics: () => clone(diagnostics),
|
|
1184
|
+
}
|
|
1185
|
+
return service
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export default createDiscipline
|