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.
- package/CHANGELOG.md +539 -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
package/src/board.js
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* 但未规定落点;落在 Task 上便于查询,事件流里也有一份)。
|
|
14
14
|
* `createdAt` / `createdBy` / `completedAt` —— 便于 `list()` 消费方直接展示。
|
|
15
15
|
* `blockedReason` —— `block({ note })` 时记录阻塞原因。
|
|
16
|
+
* `expectedComplexity` / `requiresPaid` —— **复杂度路由标签**(契约 §6):规划 Agent
|
|
17
|
+
* 拆解时显式给出该子任务的复杂度档位(T0–T3)与「是否值得付费」的诉求;
|
|
18
|
+
* 子任务被 claim/wake 时平台据此给 owner 会话写 hint(见 `src/routing/hints.js`)。
|
|
19
|
+
* 两者都是**可选字段**:缺省 ⇒ 平台按 `free_only` 处理(fail-closed,省成本)。
|
|
16
20
|
*
|
|
17
21
|
* ── 三条不变量(全模块机械保证)──
|
|
18
22
|
* 1. **CAS 优先**:任何变更都要带 `expectedRev`;不匹配即抛错,绝不静默覆盖。
|
|
@@ -57,6 +61,22 @@ try {
|
|
|
57
61
|
kernelError = error
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
// ── store.appendAudit 软导入(P0-B §5)──────────────────────────────────────
|
|
65
|
+
// 另一位工程师正在 store.js 加 appendAudit(与 board.js 并行)。我们必须**软用**:
|
|
66
|
+
// 拿不到就只 warn 一次并计入 diagnostics,绝不阻断主流程,也绝不自己另造审计格式。
|
|
67
|
+
// 用顶层 await 一次性判定(board.js 已经是 ESM + 顶层 await 的写法,见 kernel 桥),
|
|
68
|
+
// 这样 commit() 里是同步判定,diagnostics 计数不会因异步竞态而不确定。
|
|
69
|
+
let storeAppendAudit = /** @type {null | ((dir: string, entry: object) => unknown)} */ (null)
|
|
70
|
+
let storeAuditMissing = false
|
|
71
|
+
try {
|
|
72
|
+
const storeMod = await import('./store.js')
|
|
73
|
+
if (typeof storeMod.appendAudit === 'function') storeAppendAudit = storeMod.appendAudit
|
|
74
|
+
else storeAuditMissing = true
|
|
75
|
+
} catch {
|
|
76
|
+
storeAuditMissing = true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
60
80
|
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
61
81
|
|
|
62
82
|
/** `description` 上限(§3.3)。 */
|
|
@@ -67,10 +87,64 @@ const STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'deleted']
|
|
|
67
87
|
const ACCEPT_RESULTS = ['pass', 'reject']
|
|
68
88
|
/** 租约缺省 TTL(ms)。 */
|
|
69
89
|
const DEFAULT_LEASE_MS = 300_000
|
|
90
|
+
/** 复杂度档位(契约 §6 冻结;路由侧 `src/routing/complexity.js` 用同一套字面量)。 */
|
|
91
|
+
const COMPLEXITY_TIERS = ['T0', 'T1', 'T2', 'T3']
|
|
92
|
+
/**
|
|
93
|
+
* 收尾结论集合 —— 借鉴 `dsh-superagent-prototype` 的 Report 枚举
|
|
94
|
+
* (docs/001:`progress | waiting | ready | blocked | failed | dropped`)。
|
|
95
|
+
*
|
|
96
|
+
* 我们只保留**收尾时才有意义**的四档,且与自家 5 态机一一对应:
|
|
97
|
+
* - `ready` —— 干完了,等人验收 ⇒ `complete` 默认值(status=completed)
|
|
98
|
+
* - `blocked` —— 卡住了,要人决策 ⇒ 必须走 `block`(status=blocked)
|
|
99
|
+
* - `failed` —— 干不成(不是卡住,是失败)⇒ 必须走 `block`(同上,
|
|
100
|
+
* 用 `outcome:'failed'` 区分「不是等人解锁,是这条路走不通」)
|
|
101
|
+
* - `dropped` —— 结论是「不需要人做任何事」⇒ `complete` 可用(status=completed),
|
|
102
|
+
* 面板不把它计入「待你决定」
|
|
103
|
+
*
|
|
104
|
+
* **为什么不给 `complete` 开 `blocked`/`failed`**:我们的 `complete` 会置
|
|
105
|
+
* `status='completed'`,而「卡住/失败」的任务不该出现在「待验收」里 —— 那会让人
|
|
106
|
+
* 以为有东西可验收。宁可让它报错并指路 `block`,也不接受一个语义矛盾的收尾。
|
|
107
|
+
*/
|
|
108
|
+
const OUTCOMES = ['ready', 'blocked', 'failed', 'dropped']
|
|
109
|
+
/** `complete` 允许直接使用的收尾结论(其余必须走 `block`)。 */
|
|
110
|
+
const COMPLETE_OUTCOMES = ['ready', 'dropped']
|
|
111
|
+
/** `humanAction` 上限 —— 对齐 superagent 的 `human_action` ≤1000 字符(docs/003)。 */
|
|
112
|
+
const MAX_HUMAN_ACTION = 1000
|
|
70
113
|
/** 字段补丁允许改的字段(**不含 status**,见文件头不变量 3)。 */
|
|
71
|
-
const PATCH_FIELDS = [
|
|
114
|
+
const PATCH_FIELDS = [
|
|
115
|
+
'subject', 'description', 'blockedBy', 'writeScopes', 'priority', 'dueAt',
|
|
116
|
+
// 复杂度路由标注(2026-11-04 新增,契约 §6)—— 见文件头「路由标签」说明
|
|
117
|
+
'expectedComplexity', 'requiresPaid',
|
|
118
|
+
// 结构化收尾(2026-11-09 新增,借鉴 superagent 的 Report 枚举):
|
|
119
|
+
// `outcome`/`humanAction` 是**入参**(complete/block 时消费),落盘到
|
|
120
|
+
// `latestReport`(`src/stage.js` 读的字段)。它们也允许出现在 `edit`/`set`
|
|
121
|
+
// 的补丁里(比如执行中改口「我要你决定 X」),由 applyNewFields 统一落到
|
|
122
|
+
// latestReport —— **不产生第二个顶层字段**。
|
|
123
|
+
'outcome', 'humanAction',
|
|
124
|
+
// P0-B 新增可选字段
|
|
125
|
+
'labels', 'milestone', 'cycle', 'estimate', 'assignee', 'reviewer',
|
|
126
|
+
'startedAt', 'completedAt', 'archivedAt', 'triaged', 'progress',
|
|
127
|
+
// 投递失败留痕(2026-11-09):`stage.js` 的 `hasDeliveryFailure()` 读的就是它。
|
|
128
|
+
// 在此之前**全项目没有任何代码写这个字段** —— 读的人存在、写的人不存在,
|
|
129
|
+
// 于是「唤醒失败」这类错误永远显形不了(任务静默卡在排队中,见 api.js
|
|
130
|
+
// `markDeliveryFailure` 的现场说明)。
|
|
131
|
+
'delivery',
|
|
132
|
+
]
|
|
133
|
+
/** P0-B 新增可选字段(create/edit/set 三条入口共用同一套校验)。 */
|
|
134
|
+
const NEW_FIELD_NAMES = [
|
|
135
|
+
'labels', 'milestone', 'cycle', 'estimate', 'assignee', 'reviewer',
|
|
136
|
+
'startedAt', 'completedAt', 'archivedAt', 'triaged', 'progress',
|
|
137
|
+
// 结构化收尾(2026-11-09):与 PATCH_FIELDS 同步,否则 `set` 会拒这两个字段。
|
|
138
|
+
// 落盘目标是 `latestReport`(stage.js 的唯一真相),不是顶层字段。
|
|
139
|
+
'outcome', 'humanAction',
|
|
140
|
+
// Mandate 授权留痕(2026-11-09,借鉴 superagent docs/001):
|
|
141
|
+
// `mandate.basis` 记「这条活的授权依据」—— explicit(人明确点名)/ inferred(规则推断)
|
|
142
|
+
// / task-board(任务板派发)/ room(房间指令);`mandate.sender` 记谁下的令;
|
|
143
|
+
// `mandate.policyVersion` 记规则版本,供「改规则后追溯当时依据」用。
|
|
144
|
+
'mandate',
|
|
145
|
+
]
|
|
72
146
|
/** 事件动作集合。 */
|
|
73
|
-
const ACTIONS = ['create', 'edit', 'claim', 'release', 'heartbeat', 'complete', 'reopen', 'block', 'delete', 'accept', 'lease_expired']
|
|
147
|
+
const ACTIONS = ['create', 'edit', 'claim', 'release', 'heartbeat', 'complete', 'reopen', 'block', 'unblock', 'set', 'delete', 'accept', 'lease_expired', 'triage']
|
|
74
148
|
|
|
75
149
|
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
76
150
|
|
|
@@ -189,8 +263,47 @@ function normalizeDescription(value, where = 'create') {
|
|
|
189
263
|
return value
|
|
190
264
|
}
|
|
191
265
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
266
|
+
/**
|
|
267
|
+
* `expectedComplexity` 归一:`T0`–`T3`(大小写不敏感)。
|
|
268
|
+
*
|
|
269
|
+
* 非法值**抛可读错误**、绝不悄悄改成默认档 —— 否则「这个子任务为什么走了免费渠道」
|
|
270
|
+
* 就无从追溯(契约 §6 明确要求「非法值给可读错误码」)。`undefined/null` ⇒ 未标注。
|
|
271
|
+
*/
|
|
272
|
+
function normalizeComplexity(value, where) {
|
|
273
|
+
if (value === undefined || value === null) return undefined
|
|
274
|
+
if (typeof value !== 'string') {
|
|
275
|
+
throw new BoardError(
|
|
276
|
+
`board.${where}:字段 expectedComplexity 类型错误(要求 ${COMPLEXITY_TIERS.join(' | ')} 字符串,`
|
|
277
|
+
+ `收到 ${JSON.stringify(value)})`,
|
|
278
|
+
'BOARD_BAD_COMPLEXITY',
|
|
279
|
+
{ field: 'expectedComplexity', where, accepted: [...COMPLEXITY_TIERS] },
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
const v = value.trim().toUpperCase()
|
|
283
|
+
if (!COMPLEXITY_TIERS.includes(v)) {
|
|
284
|
+
throw new BoardError(
|
|
285
|
+
`board.${where}:expectedComplexity=${JSON.stringify(value)} 非法(合法值:${COMPLEXITY_TIERS.join(' | ')})`,
|
|
286
|
+
'BOARD_BAD_COMPLEXITY',
|
|
287
|
+
{ field: 'expectedComplexity', where, value },
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
return v
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** `requiresPaid` 归一:严格布尔(**不做字符串强转**,`'true'` 也算非法)。 */
|
|
294
|
+
function normalizeRequiresPaid(value, where) {
|
|
295
|
+
if (value === undefined || value === null) return undefined
|
|
296
|
+
if (typeof value !== 'boolean') {
|
|
297
|
+
throw new BoardError(
|
|
298
|
+
`board.${where}:字段 requiresPaid 类型错误(要求 true|false 布尔,收到 ${JSON.stringify(value)})`,
|
|
299
|
+
'BOARD_BAD_FIELD',
|
|
300
|
+
{ field: 'requiresPaid', where },
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
return value
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `complete` 的产出物证据归一:非空字符串或非空数组(元素为字符串/对象)。 */function normalizeArtifacts(value, taskId) {
|
|
194
307
|
let list
|
|
195
308
|
if (typeof value === 'string') list = [value]
|
|
196
309
|
else if (Array.isArray(value)) list = value
|
|
@@ -232,6 +345,216 @@ function normalizeArtifacts(value, taskId) {
|
|
|
232
345
|
return out
|
|
233
346
|
}
|
|
234
347
|
|
|
348
|
+
/** labels 归一:必须是字符串数组(可选;undefined ⇒ 不写字段)。 */
|
|
349
|
+
function normalizeLabels(value, where) {
|
|
350
|
+
if (value === undefined || value === null) return undefined
|
|
351
|
+
if (!Array.isArray(value)) {
|
|
352
|
+
throw new BoardError(
|
|
353
|
+
`board.${where}:字段 labels 类型错误(要求字符串数组,收到 ${JSON.stringify(value)})`,
|
|
354
|
+
'BOARD_BAD_FIELD', { field: 'labels', where },
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
const out = []
|
|
358
|
+
for (const item of value) {
|
|
359
|
+
if (typeof item !== 'string') {
|
|
360
|
+
throw new BoardError(
|
|
361
|
+
`board.${where}:labels 元素必须是字符串,收到 ${JSON.stringify(item)}`,
|
|
362
|
+
'BOARD_BAD_FIELD', { field: 'labels', where },
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
out.push(item)
|
|
366
|
+
}
|
|
367
|
+
return out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 可选字符串字段。 */
|
|
371
|
+
function normalizeOptionalString(value, field, where) {
|
|
372
|
+
if (value === undefined || value === null) return undefined
|
|
373
|
+
if (typeof value !== 'string') {
|
|
374
|
+
throw new BoardError(
|
|
375
|
+
`board.${where}:字段 ${field} 类型错误(要求字符串,收到 ${JSON.stringify(value)})`,
|
|
376
|
+
'BOARD_BAD_FIELD', { field, where },
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
return value
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** estimate 归一:≥0 有限数。 */
|
|
383
|
+
function normalizeEstimate(value, where) {
|
|
384
|
+
if (value === undefined || value === null) return undefined
|
|
385
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
386
|
+
throw new BoardError(
|
|
387
|
+
`board.${where}:字段 estimate 类型错误(要求有限数字,收到 ${JSON.stringify(value)})`,
|
|
388
|
+
'BOARD_BAD_FIELD', { field: 'estimate', where },
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
if (value < 0) {
|
|
392
|
+
throw new BoardError(
|
|
393
|
+
`board.${where}:字段 estimate 必须 ≥ 0,收到 ${value}`,
|
|
394
|
+
'BOARD_BAD_FIELD', { field: 'estimate', where },
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
return value
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** progress 归一:0-100 有限数。 */
|
|
401
|
+
function normalizeProgress(value, where) {
|
|
402
|
+
if (value === undefined || value === null) return undefined
|
|
403
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
404
|
+
throw new BoardError(
|
|
405
|
+
`board.${where}:字段 progress 类型错误(要求有限数字,收到 ${JSON.stringify(value)})`,
|
|
406
|
+
'BOARD_BAD_FIELD', { field: 'progress', where },
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
if (value < 0 || value > 100) {
|
|
410
|
+
throw new BoardError(
|
|
411
|
+
`board.${where}:字段 progress 必须在 0-100 之间,收到 ${value}`,
|
|
412
|
+
'BOARD_BAD_FIELD', { field: 'progress', where },
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
return value
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** triaged 归一:严格布尔。 */
|
|
419
|
+
function normalizeTriaged(value, where) {
|
|
420
|
+
if (value === undefined || value === null) return undefined
|
|
421
|
+
if (typeof value !== 'boolean') {
|
|
422
|
+
throw new BoardError(
|
|
423
|
+
`board.${where}:字段 triaged 类型错误(要求 boolean,收到 ${JSON.stringify(value)})`,
|
|
424
|
+
'BOARD_BAD_FIELD', { field: 'triaged', where },
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
return value
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 收尾结论归一:必须是 `OUTCOMES` 之一(借鉴 superagent Report 枚举)。
|
|
432
|
+
*
|
|
433
|
+
* 不在集合内 → 抛错,**不静默取默认**。理由:`outcome` 是 `stage.js` 派生
|
|
434
|
+
* 「待验收/待你决定」的唯一依据,猜一个默认值会把「失败」显示成「待验收」,
|
|
435
|
+
* 或反过来。宁可拒绝,也不接受语义错误的收尾。
|
|
436
|
+
*/
|
|
437
|
+
function normalizeOutcome(value, where) {
|
|
438
|
+
if (value === undefined || value === null) return undefined
|
|
439
|
+
if (typeof value !== 'string') {
|
|
440
|
+
throw new BoardError(
|
|
441
|
+
`board.${where}:字段 outcome 类型错误(要求字符串,收到 ${JSON.stringify(value)})`,
|
|
442
|
+
'BOARD_BAD_FIELD', { field: 'outcome', where },
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
if (!OUTCOMES.includes(value)) {
|
|
446
|
+
throw new BoardError(
|
|
447
|
+
`board.${where}:outcome 必须是 ${OUTCOMES.join(' | ')},收到 ${JSON.stringify(value)}`,
|
|
448
|
+
'BOARD_BAD_FIELD', { field: 'outcome', where },
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
return value
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* `humanAction` 归一:**一句话、必填于需要人决策时、≤ `MAX_HUMAN_ACTION` 字符**。
|
|
456
|
+
*
|
|
457
|
+
* 对齐 superagent 的 `human_action`(docs/003:≤1000 字符,「一句话说清要人做什么」)。
|
|
458
|
+
* 它是「待你决定」列表的标题来源,所以必须是**一句话**,不是一段报告。
|
|
459
|
+
*
|
|
460
|
+
* 只校验「传了就必须是干净字符串」;是否**该**传由调用方(complete/block 的语义)
|
|
461
|
+
* 决定,不在这里猜 —— 但空串会被 `complete`/`block` 拒绝(见对应 case)。
|
|
462
|
+
*/
|
|
463
|
+
function normalizeHumanAction(value, where) {
|
|
464
|
+
if (value === undefined || value === null) return undefined
|
|
465
|
+
if (typeof value !== 'string') {
|
|
466
|
+
throw new BoardError(
|
|
467
|
+
`board.${where}:字段 humanAction 类型错误(要求字符串,收到 ${JSON.stringify(value)})`,
|
|
468
|
+
'BOARD_BAD_FIELD', { field: 'humanAction', where },
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
if (value.length > MAX_HUMAN_ACTION) {
|
|
472
|
+
throw new BoardError(
|
|
473
|
+
`board.${where}:字段 humanAction 超长(≤${MAX_HUMAN_ACTION} 字符,收到 ${value.length})—— `
|
|
474
|
+
+ '它要当「待你决定」列表的标题,请压缩成一句话',
|
|
475
|
+
'BOARD_BAD_FIELD', { field: 'humanAction', where },
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
return value
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Mandate 授权留痕归一(借鉴 superagent docs/001 §Mandate)。
|
|
483
|
+
*
|
|
484
|
+
* 只接受 `{ basis, sender, policyVersion }` 三个字段,且 `basis` 必须在
|
|
485
|
+
* `MANDATE_BASES` 内;**多余的键直接拒绝**,不静默忽略 —— Mandate 是「这条活
|
|
486
|
+
* 凭什么能做」的唯一证据,多一个字段就意味着多一种「当时没记清」的可能。
|
|
487
|
+
*
|
|
488
|
+
* 其中 `sender`(谁下的令)必填且非空;`policyVersion` 可选(缺省 null)。
|
|
489
|
+
*/
|
|
490
|
+
const MANDATE_BASES = ['explicit', 'inferred', 'task-board', 'room', 'cycle']
|
|
491
|
+
/**
|
|
492
|
+
* Mandate 的合法字段集合。
|
|
493
|
+
* 「谁下令」(sender)必填,其余可选;`basis` 的语义见 `MANDATE_BASES`。
|
|
494
|
+
*/
|
|
495
|
+
const MANDATE_FIELDS = ['basis', 'sender', 'policyVersion', 'scope']
|
|
496
|
+
|
|
497
|
+
function normalizeMandate(value, where) {
|
|
498
|
+
if (value === undefined || value === null) return undefined
|
|
499
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
500
|
+
throw new BoardError(
|
|
501
|
+
`board.${where}:字段 mandate 类型错误(要求对象,收到 ${JSON.stringify(value)})`,
|
|
502
|
+
'BOARD_BAD_FIELD', { field: 'mandate', where },
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
const unknown = Object.keys(value).filter((k) => !MANDATE_FIELDS.includes(k))
|
|
506
|
+
if (unknown.length > 0) {
|
|
507
|
+
throw new BoardError(
|
|
508
|
+
`board.${where}:mandate 含未知字段 ${unknown.join(', ')}(合法:${MANDATE_FIELDS.join(' | ')})—— `
|
|
509
|
+
+ '拒绝静默忽略,Mandate 是授权依据,多一个字段就多一处说不清',
|
|
510
|
+
'BOARD_BAD_FIELD', { field: 'mandate', where, unknown },
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
const out = { ...value }
|
|
514
|
+
if (out.basis !== undefined && out.basis !== null) {
|
|
515
|
+
if (typeof out.basis !== 'string' || !MANDATE_BASES.includes(out.basis)) {
|
|
516
|
+
throw new BoardError(
|
|
517
|
+
`board.${where}:mandate.basis 必须是 ${MANDATE_BASES.join(' | ')},收到 ${JSON.stringify(out.basis)}`,
|
|
518
|
+
'BOARD_BAD_FIELD', { field: 'mandate.basis', where },
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (out.sender !== undefined && out.sender !== null) {
|
|
523
|
+
if (typeof out.sender !== 'string' || out.sender.trim() === '') {
|
|
524
|
+
throw new BoardError(
|
|
525
|
+
`board.${where}:mandate.sender 必须是非空字符串(谁下的令)—— 收到 ${JSON.stringify(out.sender)}`,
|
|
526
|
+
'BOARD_BAD_FIELD', { field: 'mandate.sender', where },
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
out.sender = out.sender.trim()
|
|
530
|
+
}
|
|
531
|
+
if (out.policyVersion !== undefined && out.policyVersion !== null) {
|
|
532
|
+
if (typeof out.policyVersion !== 'string') {
|
|
533
|
+
throw new BoardError(
|
|
534
|
+
`board.${where}:mandate.policyVersion 类型错误(要求字符串,收到 ${JSON.stringify(out.policyVersion)})`,
|
|
535
|
+
'BOARD_BAD_FIELD', { field: 'mandate.policyVersion', where },
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (out.scope !== undefined && out.scope !== null) {
|
|
540
|
+
if (typeof out.scope !== 'string') {
|
|
541
|
+
throw new BoardError(
|
|
542
|
+
`board.${where}:mandate.scope 类型错误(要求字符串,收到 ${JSON.stringify(out.scope)})`,
|
|
543
|
+
'BOARD_BAD_FIELD', { field: 'mandate.scope', where },
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// 至少要留下一条依据 —— 否则 Mandate 是空壳,等于没记
|
|
548
|
+
if (out.basis === undefined && out.sender === undefined && out.policyVersion === undefined && out.scope === undefined) {
|
|
549
|
+
throw new BoardError(
|
|
550
|
+
`board.${where}:mandate 不能是空对象(至少给 basis 或 sender)`,
|
|
551
|
+
'BOARD_BAD_FIELD', { field: 'mandate', where },
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
return out
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
235
558
|
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
236
559
|
|
|
237
560
|
/**
|
|
@@ -267,6 +590,11 @@ export function createBoard(ctx, config = {}) {
|
|
|
267
590
|
const leaseMsDefault = Number.isFinite(config.leaseMs) && config.leaseMs > 0
|
|
268
591
|
? config.leaseMs
|
|
269
592
|
: DEFAULT_LEASE_MS
|
|
593
|
+
const settings = (config && typeof config.settings === 'object' && config.settings !== null) ? config.settings : {}
|
|
594
|
+
const wipLimitPerBot = Number.isFinite(settings.wipLimitPerBot) && settings.wipLimitPerBot >= 1
|
|
595
|
+
? settings.wipLimitPerBot
|
|
596
|
+
: (Number.isFinite(config.wipLimitPerBot) && config.wipLimitPerBot >= 1 ? config.wipLimitPerBot : 2)
|
|
597
|
+
const requireTriageForReady = settings.requireTriageForReady === true || config.requireTriageForReady === true
|
|
270
598
|
|
|
271
599
|
const jsonlPath = statePath(stateDir, 'board.jsonl')
|
|
272
600
|
const snapPath = statePath(stateDir, 'board.snap.json')
|
|
@@ -297,6 +625,8 @@ export function createBoard(ctx, config = {}) {
|
|
|
297
625
|
badLines: 0,
|
|
298
626
|
snapshotWriteFailures: 0,
|
|
299
627
|
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
628
|
+
auditWriteFailures: 0,
|
|
629
|
+
auditWarnedOnce: false,
|
|
300
630
|
}
|
|
301
631
|
|
|
302
632
|
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
@@ -351,6 +681,42 @@ export function createBoard(ctx, config = {}) {
|
|
|
351
681
|
diagnostics.snapshotWriteFailures += 1
|
|
352
682
|
warn(`[cs-bots/board] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
353
683
|
}
|
|
684
|
+
// ── 审计接线(P0-B §5)────────────────────────────────────────────────
|
|
685
|
+
// 软用 store.appendAudit:拿不到/写失败 → 只 warn 一次 + 计数,**绝不阻断**主流程
|
|
686
|
+
//(事件流 board.jsonl 已是本模块自己的真相源,审计是补充,不是必需品)。
|
|
687
|
+
if (storeAuditMissing) {
|
|
688
|
+
diagnostics.auditWriteFailures += 1
|
|
689
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
690
|
+
diagnostics.auditWarnedOnce = true
|
|
691
|
+
warn('[cs-bots/board] store.appendAudit 尚不可用(另一侧未合并)→ 审计接线跳过;主流程不受影响')
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
try {
|
|
695
|
+
const digest = (t) => (t
|
|
696
|
+
? { id: t.id, status: t.status, rev: t.rev, subject: t.subject, assignee: t.assignee ?? null, progress: t.progress ?? 0, triaged: t.triaged === true }
|
|
697
|
+
: null)
|
|
698
|
+
const result = storeAppendAudit(stateDir, {
|
|
699
|
+
ts: at, actor: byId, action, target: taskId, before: digest(before), after: digest(after),
|
|
700
|
+
})
|
|
701
|
+
// appendAudit 的失败约定兼容两种形态:`false`(布尔)与 `{ok:false,reason}`(对象)。
|
|
702
|
+
// 抛错同样按失败计。**任何形态都只 warn 一次,不阻断。**
|
|
703
|
+
const failed = result === false || Boolean(result && typeof result === 'object' && result.ok === false)
|
|
704
|
+
if (failed) {
|
|
705
|
+
diagnostics.auditWriteFailures += 1
|
|
706
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
707
|
+
diagnostics.auditWarnedOnce = true
|
|
708
|
+
const reason = result && typeof result === 'object' ? (result.reason ?? '未知原因') : '返回 false'
|
|
709
|
+
warn(`[cs-bots/board] store.appendAudit 返回失败:${reason}——主流程不受影响`)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
} catch (error) {
|
|
713
|
+
diagnostics.auditWriteFailures += 1
|
|
714
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
715
|
+
diagnostics.auditWarnedOnce = true
|
|
716
|
+
warn(`[cs-bots/board] store.appendAudit 抛错:${error?.message ?? error}——主流程不受影响`)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
354
720
|
return clone(after)
|
|
355
721
|
}
|
|
356
722
|
|
|
@@ -384,6 +750,9 @@ export function createBoard(ctx, config = {}) {
|
|
|
384
750
|
const t = clone(raw)
|
|
385
751
|
if (!Array.isArray(t.blockedBy)) t.blockedBy = []
|
|
386
752
|
if (!Array.isArray(t.writeScopes)) t.writeScopes = []
|
|
753
|
+
if (!Array.isArray(t.labels)) t.labels = []
|
|
754
|
+
if (t.triaged !== true && t.triaged !== false) t.triaged = false
|
|
755
|
+
if (typeof t.progress !== 'number' || !Number.isFinite(t.progress)) t.progress = 0
|
|
387
756
|
tasks.set(t.id, t)
|
|
388
757
|
idCounter = Math.max(idCounter, idNumber(t.id))
|
|
389
758
|
if (t.sourceSeq) sourceSeqIndex.set(t.sourceSeq, t.id)
|
|
@@ -399,6 +768,23 @@ export function createBoard(ctx, config = {}) {
|
|
|
399
768
|
return
|
|
400
769
|
}
|
|
401
770
|
|
|
771
|
+
// ── id 水位:必须先于「是否应用」的判定(2026-11-09 修复 id 撞号根因)──────
|
|
772
|
+
// 原来的写法只在**应用成功**的分支里 `idCounter = max(idCounter, idNumber(taskId))`,
|
|
773
|
+
// 于是「被跳过的重复投递」里的任务 id 不会抬高计数器:
|
|
774
|
+
// 快照 idCounter=31、事件流最大 id=33 ⇒ 新任务仍分配 t-32 ⇒ **与既有 id 相撞**。
|
|
775
|
+
// 撞号的后果不是报错,而是更坏的静默覆盖:重启回放时那条 create 事件因
|
|
776
|
+
// `ev.rev <= before.rev` 被判成「重复投递」跳过 ⇒ 新任务凭空消失。
|
|
777
|
+
// 所以:**凡是出现在事件流里的 taskId,无论该事件是否被应用,都要计入水位。**
|
|
778
|
+
for (const ev of rows) {
|
|
779
|
+
if (!ev || typeof ev.taskId !== 'string') continue
|
|
780
|
+
const n = idNumber(ev.taskId)
|
|
781
|
+
if (Number.isFinite(n) && n > idCounter) idCounter = n
|
|
782
|
+
if (ev.after && typeof ev.after.id === 'string') {
|
|
783
|
+
const n2 = idNumber(ev.after.id)
|
|
784
|
+
if (Number.isFinite(n2) && n2 > idCounter) idCounter = n2
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
402
788
|
// 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
|
|
403
789
|
let skipped = 0
|
|
404
790
|
for (const ev of rows) {
|
|
@@ -414,6 +800,9 @@ export function createBoard(ctx, config = {}) {
|
|
|
414
800
|
const t = clone(ev.after)
|
|
415
801
|
if (!Array.isArray(t.blockedBy)) t.blockedBy = []
|
|
416
802
|
if (!Array.isArray(t.writeScopes)) t.writeScopes = []
|
|
803
|
+
if (!Array.isArray(t.labels)) t.labels = []
|
|
804
|
+
if (t.triaged !== true && t.triaged !== false) t.triaged = false
|
|
805
|
+
if (typeof t.progress !== 'number' || !Number.isFinite(t.progress)) t.progress = 0
|
|
417
806
|
tasks.set(ev.taskId, t)
|
|
418
807
|
events.push(clone(ev))
|
|
419
808
|
idCounter = Math.max(idCounter, idNumber(ev.taskId))
|
|
@@ -519,8 +908,30 @@ export function createBoard(ctx, config = {}) {
|
|
|
519
908
|
* @param {{ status?: string }} [options]
|
|
520
909
|
* @returns {object[]}
|
|
521
910
|
*/
|
|
911
|
+
/**
|
|
912
|
+
* 「需要人管」的判定(与 todo 的四象限同思路,但对象是协作任务):
|
|
913
|
+
* - blocked(卡住了,需要人判断怎么办)
|
|
914
|
+
* - 高优先级(priority >= 8,紧急)
|
|
915
|
+
* - 已逾期(dueAt 在过去且仍未完成)
|
|
916
|
+
* - 待验收(completed 但还没验收)
|
|
917
|
+
* - 我建的、还没做完
|
|
918
|
+
* 没有「重要/紧急」字段 —— 那是待办的概念;协作任务用 priority + dueAt 表达。
|
|
919
|
+
*/
|
|
920
|
+
function needsAttention(task, mySession) {
|
|
921
|
+
if (task.status === 'deleted') return false
|
|
922
|
+
if (task.status === 'blocked') return true
|
|
923
|
+
if (task.priority !== undefined && task.priority >= 8) return true
|
|
924
|
+
if (task.dueAt && task.status !== 'completed' && task.dueAt < Date.now()) return true
|
|
925
|
+
if (task.status === 'completed' && task.accepted !== true) return true
|
|
926
|
+
// 结构化「待你回答」:latestReport.attention 存在 ⇒ 执行者明确在等人
|
|
927
|
+
// (2026-11-09:与 stage.js 的 person/answer 判定同一依据,不另起字段)
|
|
928
|
+
if (task.latestReport?.attention) return true
|
|
929
|
+
if (mySession && task.createdBy === mySession && !['completed', 'deleted'].includes(task.status)) return true
|
|
930
|
+
return false
|
|
931
|
+
}
|
|
932
|
+
|
|
522
933
|
function list(options = {}) {
|
|
523
|
-
const { status } = options ?? {}
|
|
934
|
+
const { status, attention, priority, dueAt, source, sort, triaged } = options ?? {}
|
|
524
935
|
if (status !== undefined && status !== null) {
|
|
525
936
|
if (!STATUSES.includes(status)) {
|
|
526
937
|
throw new BoardError(
|
|
@@ -531,12 +942,33 @@ export function createBoard(ctx, config = {}) {
|
|
|
531
942
|
}
|
|
532
943
|
}
|
|
533
944
|
const out = []
|
|
945
|
+
const mySession = options?.byId
|
|
534
946
|
for (const t of tasks.values()) {
|
|
535
947
|
if (status) {
|
|
536
|
-
if (t.status
|
|
537
|
-
} else if (t.status
|
|
538
|
-
|
|
948
|
+
if (t.status !== status) continue
|
|
949
|
+
} else if (t.status === 'deleted') {
|
|
950
|
+
continue
|
|
539
951
|
}
|
|
952
|
+
if (attention === true || attention === '1') {
|
|
953
|
+
if (!needsAttention(t, mySession)) continue
|
|
954
|
+
}
|
|
955
|
+
if (priority !== undefined && priority !== null) {
|
|
956
|
+
if (t.priority !== priority) continue
|
|
957
|
+
}
|
|
958
|
+
if (dueAt !== undefined && dueAt !== null) {
|
|
959
|
+
if (t.dueAt !== dueAt) continue
|
|
960
|
+
}
|
|
961
|
+
if (source !== undefined && source !== null) {
|
|
962
|
+
if (t.source !== source) continue
|
|
963
|
+
}
|
|
964
|
+
if (triaged !== undefined && triaged !== null) {
|
|
965
|
+
const want = triaged === true || triaged === '1' || triaged === 'true'
|
|
966
|
+
if ((t.triaged === true) !== want) continue
|
|
967
|
+
}
|
|
968
|
+
out.push(clone(t))
|
|
969
|
+
}
|
|
970
|
+
if (sort === 'priority' || attention === true || attention === '1') {
|
|
971
|
+
out.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
540
972
|
}
|
|
541
973
|
return out
|
|
542
974
|
}
|
|
@@ -546,12 +978,19 @@ export function createBoard(ctx, config = {}) {
|
|
|
546
978
|
* 纯读操作,不做租约回收(租约回收请显式调用 `expireLeases()`)。
|
|
547
979
|
* @returns {object[]} 创建序
|
|
548
980
|
*/
|
|
549
|
-
function ready() {
|
|
981
|
+
function ready(options = {}) {
|
|
982
|
+
const strict = options?.strict === true || requireTriageForReady
|
|
550
983
|
const out = []
|
|
551
984
|
for (const t of tasks.values()) {
|
|
552
985
|
if (t.status !== 'pending') continue
|
|
986
|
+
if (strict) {
|
|
987
|
+
if (t.triaged !== true) continue
|
|
988
|
+
if (typeof t.assignee !== 'string' || t.assignee.trim() === '') continue
|
|
989
|
+
}
|
|
553
990
|
if (unfinishedDeps(t).length === 0) out.push(clone(t))
|
|
554
991
|
}
|
|
992
|
+
// 文档承诺「按优先级降序」,这里落实(否则只是「创建序」,与承诺不符)
|
|
993
|
+
out.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
555
994
|
return out
|
|
556
995
|
}
|
|
557
996
|
|
|
@@ -574,17 +1013,40 @@ export function createBoard(ctx, config = {}) {
|
|
|
574
1013
|
* @returns {object} Task
|
|
575
1014
|
*/
|
|
576
1015
|
function create(input = {}) {
|
|
577
|
-
const {
|
|
1016
|
+
const {
|
|
1017
|
+
subject, description, blockedBy, writeScopes, priority, dueAt, byId, sourceSeq, now,
|
|
1018
|
+
expectedComplexity, requiresPaid, source,
|
|
1019
|
+
labels, milestone, cycle, estimate, assignee, reviewer, progress, triaged,
|
|
1020
|
+
mandate,
|
|
1021
|
+
} = input ?? {}
|
|
578
1022
|
const by = requireString(byId, 'byId', 'create')
|
|
1023
|
+
// 来源判定:显式传了 source 就采信;否则按 byId 推断
|
|
1024
|
+
// ('ui'/'user' → 人;其他(会话 ID/Bot ID)→ 智能体)
|
|
1025
|
+
const src = source === 'human' || source === 'ai' ? source
|
|
1026
|
+
: (by === 'ui' || by === 'user' ? 'human' : 'ai')
|
|
579
1027
|
const subj = requireString(subject, 'subject', 'create')
|
|
580
1028
|
const desc = normalizeDescription(description, 'create')
|
|
581
1029
|
const deps = stringList(blockedBy, 'blockedBy', 'create')
|
|
582
1030
|
const scopes = stringList(writeScopes, 'writeScopes', 'create')
|
|
583
1031
|
const prio = optionalNumber(priority, 'priority', 'create')
|
|
584
1032
|
const due = optionalNumber(dueAt, 'dueAt', 'create')
|
|
1033
|
+
// 复杂度路由标签(可选;非法值在这里就抛,不进事件流)
|
|
1034
|
+
const complexity = normalizeComplexity(expectedComplexity, 'create')
|
|
1035
|
+
const paid = normalizeRequiresPaid(requiresPaid, 'create')
|
|
585
1036
|
const seq = sourceSeq === undefined || sourceSeq === null
|
|
586
1037
|
? undefined
|
|
587
1038
|
: requireString(sourceSeq, 'sourceSeq', 'create')
|
|
1039
|
+
// P0-B 新增可选字段(全部「可选 + 回放补默认」,老数据不受影响)
|
|
1040
|
+
const labelsNorm = normalizeLabels(labels, 'create')
|
|
1041
|
+
const milestoneNorm = normalizeOptionalString(milestone, 'milestone', 'create')
|
|
1042
|
+
const cycleNorm = normalizeOptionalString(cycle, 'cycle', 'create')
|
|
1043
|
+
const estimateNorm = normalizeEstimate(estimate, 'create')
|
|
1044
|
+
const assigneeNorm = normalizeOptionalString(assignee, 'assignee', 'create')
|
|
1045
|
+
const reviewerNorm = normalizeOptionalString(reviewer, 'reviewer', 'create')
|
|
1046
|
+
const progressNorm = normalizeProgress(progress, 'create')
|
|
1047
|
+
const triagedNorm = normalizeTriaged(triaged, 'create')
|
|
1048
|
+
// Mandate 授权留痕(可选;不给就是「未记录依据」,不伪造一个)
|
|
1049
|
+
const mandateNorm = normalizeMandate(mandate, 'create')
|
|
588
1050
|
|
|
589
1051
|
// ── 幂等(校验全部通过后再判定,避免校验失败污染幂等键)──
|
|
590
1052
|
if (seq !== undefined) {
|
|
@@ -605,14 +1067,21 @@ export function createBoard(ctx, config = {}) {
|
|
|
605
1067
|
}
|
|
606
1068
|
|
|
607
1069
|
const at = normalizeNow(now, 'create')
|
|
608
|
-
|
|
1070
|
+
// ── id 分配的兜底(2026-11-09)────────────────────────────────────────────
|
|
1071
|
+
// 主口径是 `idCounter + 1`,但内存计数器与事件流可能被**外部写入者**拉开距离
|
|
1072
|
+
// (例如脚本/另一个进程直接往 board.jsonl 追加)。此时分配到的 id 会在重启回放时
|
|
1073
|
+
// 被 `ev.rev <= before.rev` 判成「重复投递」而跳过 ⇒ 任务凭空消失(静默覆盖)。
|
|
1074
|
+
// 这里加一道极便宜的兜底:**跳过任何已存在于当前任务表的 id**。
|
|
1075
|
+
// 它不解决「多写者」这个根问题(那需要文件锁),但保证不会撞到**已知**的 id。
|
|
1076
|
+
let nextCounter = idCounter + 1
|
|
1077
|
+
while (tasks.has(`t-${nextCounter}`)) nextCounter += 1
|
|
609
1078
|
const taskId = `t-${nextCounter}`
|
|
610
1079
|
assertDependencyGraph(taskId, deps)
|
|
611
1080
|
|
|
612
1081
|
/** @type {Record<string, unknown>} */
|
|
613
1082
|
const task = {
|
|
614
1083
|
id: taskId,
|
|
615
|
-
rev: 1,
|
|
1084
|
+
rev: 1, source: src,
|
|
616
1085
|
subject: subj,
|
|
617
1086
|
description: desc,
|
|
618
1087
|
status: 'pending',
|
|
@@ -620,10 +1089,26 @@ export function createBoard(ctx, config = {}) {
|
|
|
620
1089
|
writeScopes: scopes,
|
|
621
1090
|
createdAt: at,
|
|
622
1091
|
createdBy: by,
|
|
1092
|
+
// P0-B:老数据回放补默认;新建任务也直接带上,保证 replay 深比较一致
|
|
1093
|
+
labels: [],
|
|
1094
|
+
triaged: false,
|
|
1095
|
+
progress: 0,
|
|
623
1096
|
}
|
|
624
1097
|
if (prio !== undefined) task.priority = prio
|
|
625
1098
|
if (due !== undefined) task.dueAt = due
|
|
626
1099
|
if (seq !== undefined) task.sourceSeq = seq
|
|
1100
|
+
if (complexity !== undefined) task.expectedComplexity = complexity
|
|
1101
|
+
if (paid !== undefined) task.requiresPaid = paid
|
|
1102
|
+
if (labelsNorm !== undefined) task.labels = labelsNorm
|
|
1103
|
+
if (milestoneNorm !== undefined) task.milestone = milestoneNorm
|
|
1104
|
+
if (cycleNorm !== undefined) task.cycle = cycleNorm
|
|
1105
|
+
if (estimateNorm !== undefined) task.estimate = estimateNorm
|
|
1106
|
+
if (assigneeNorm !== undefined) task.assignee = assigneeNorm
|
|
1107
|
+
if (reviewerNorm !== undefined) task.reviewer = reviewerNorm
|
|
1108
|
+
if (progressNorm !== undefined) task.progress = progressNorm
|
|
1109
|
+
if (triagedNorm !== undefined) task.triaged = triagedNorm
|
|
1110
|
+
if (mandateNorm !== undefined) task.mandate = mandateNorm
|
|
1111
|
+
else task.mandate = { sender: by } // 事实留痕:谁建的(`byId`),不是判断
|
|
627
1112
|
|
|
628
1113
|
idCounter = nextCounter
|
|
629
1114
|
try {
|
|
@@ -648,7 +1133,7 @@ export function createBoard(ctx, config = {}) {
|
|
|
648
1133
|
function update(input = {}) {
|
|
649
1134
|
const {
|
|
650
1135
|
id, expectedRev, action = 'edit', byId, now,
|
|
651
|
-
artifacts, note, leaseMs, force, ...fields
|
|
1136
|
+
artifacts, note, reason, leaseMs, force, ...fields
|
|
652
1137
|
} = input ?? {}
|
|
653
1138
|
const taskId = requireString(id, 'id', 'update')
|
|
654
1139
|
const by = requireString(byId, 'byId', 'update')
|
|
@@ -691,6 +1176,66 @@ export function createBoard(ctx, config = {}) {
|
|
|
691
1176
|
const isDead = (st) => st === 'deleted'
|
|
692
1177
|
const ownerName = (lease) => (lease?.ownerId ? String(lease.ownerId) : '?')
|
|
693
1178
|
|
|
1179
|
+
// 复杂度路由标签(契约 §6):**任何 action 下**非法值都直接拒 ——
|
|
1180
|
+
// 不给「脏输入被静默忽略」的口子(那样路由就不可追溯了)。
|
|
1181
|
+
const patchComplexity = normalizeComplexity(fields.expectedComplexity, `update/${action}`)
|
|
1182
|
+
const patchRequiresPaid = normalizeRequiresPaid(fields.requiresPaid, `update/${action}`)
|
|
1183
|
+
/**
|
|
1184
|
+
* 把 P0-B 新增可选字段落到 next 上(`null` = 清除)。
|
|
1185
|
+
* 与 create 共用同一套校验函数 —— 同一字段两条入口不得有两套规则。
|
|
1186
|
+
* @param {object} nextTask @param {object} src @param {string} where
|
|
1187
|
+
*/
|
|
1188
|
+
const applyNewFields = (nextTask, src, where) => {
|
|
1189
|
+
const setOrDelete = (key, normalized) => {
|
|
1190
|
+
if (normalized === undefined) delete nextTask[key]
|
|
1191
|
+
else nextTask[key] = normalized
|
|
1192
|
+
}
|
|
1193
|
+
if (src.labels !== undefined) setOrDelete('labels', normalizeLabels(src.labels, where))
|
|
1194
|
+
if (src.milestone !== undefined) setOrDelete('milestone', normalizeOptionalString(src.milestone, 'milestone', where))
|
|
1195
|
+
if (src.cycle !== undefined) setOrDelete('cycle', normalizeOptionalString(src.cycle, 'cycle', where))
|
|
1196
|
+
if (src.estimate !== undefined) setOrDelete('estimate', normalizeEstimate(src.estimate, where))
|
|
1197
|
+
if (src.assignee !== undefined) setOrDelete('assignee', normalizeOptionalString(src.assignee, 'assignee', where))
|
|
1198
|
+
if (src.reviewer !== undefined) setOrDelete('reviewer', normalizeOptionalString(src.reviewer, 'reviewer', where))
|
|
1199
|
+
if (src.progress !== undefined) setOrDelete('progress', normalizeProgress(src.progress, where))
|
|
1200
|
+
if (src.triaged !== undefined) setOrDelete('triaged', normalizeTriaged(src.triaged, where))
|
|
1201
|
+
if (src.startedAt !== undefined) setOrDelete('startedAt', optionalNumber(src.startedAt, 'startedAt', where))
|
|
1202
|
+
if (src.completedAt !== undefined) setOrDelete('completedAt', optionalNumber(src.completedAt, 'completedAt', where))
|
|
1203
|
+
if (src.archivedAt !== undefined) setOrDelete('archivedAt', optionalNumber(src.archivedAt, 'archivedAt', where))
|
|
1204
|
+
// 结构化收尾(2026-11-09):`outcome`/`humanAction` 是**入参**,
|
|
1205
|
+
// 落盘目标是 `latestReport`(stage.js 的唯一真相),**不新增顶层字段**。
|
|
1206
|
+
// `null` 表示「清除报告」(等价于 `delete latestReport`)—— 与 `setOrDelete`
|
|
1207
|
+
// 的既有约定一致;stage.js 读不到就用保守值,干净。
|
|
1208
|
+
if (src.outcome !== undefined) {
|
|
1209
|
+
if (src.outcome === null) {
|
|
1210
|
+
if (nextTask.latestReport) delete nextTask.latestReport.state
|
|
1211
|
+
} else {
|
|
1212
|
+
if (nextTask.latestReport === undefined) nextTask.latestReport = {}
|
|
1213
|
+
nextTask.latestReport.state = normalizeOutcome(src.outcome, where)
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if (src.humanAction !== undefined) {
|
|
1217
|
+
if (src.humanAction === null) {
|
|
1218
|
+
if (nextTask.latestReport) delete nextTask.latestReport.attention
|
|
1219
|
+
} else {
|
|
1220
|
+
if (nextTask.latestReport === undefined) nextTask.latestReport = {}
|
|
1221
|
+
nextTask.latestReport.attention = { question: normalizeHumanAction(src.humanAction, where) }
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (src.mandate !== undefined) setOrDelete('mandate', normalizeMandate(src.mandate, where))
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** 把(可选)路由标签落到 next 上;`null` = 清除该标注。@param {object} nextTask */
|
|
1228
|
+
const applyRoutingLabels = (nextTask) => {
|
|
1229
|
+
if (fields.expectedComplexity !== undefined) {
|
|
1230
|
+
if (fields.expectedComplexity === null) delete nextTask.expectedComplexity
|
|
1231
|
+
else nextTask.expectedComplexity = patchComplexity
|
|
1232
|
+
}
|
|
1233
|
+
if (fields.requiresPaid !== undefined) {
|
|
1234
|
+
if (fields.requiresPaid === null) delete nextTask.requiresPaid
|
|
1235
|
+
else nextTask.requiresPaid = patchRequiresPaid
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
694
1239
|
switch (action) {
|
|
695
1240
|
// ── 字段补丁(内容类)────────────────────────────────────────────────
|
|
696
1241
|
case 'edit': {
|
|
@@ -734,6 +1279,9 @@ export function createBoard(ctx, config = {}) {
|
|
|
734
1279
|
if (fields.dueAt === null) delete next.dueAt
|
|
735
1280
|
else next.dueAt = optionalNumber(fields.dueAt, 'dueAt', 'update')
|
|
736
1281
|
}
|
|
1282
|
+
// P0-B 新增字段(null = 清除该字段,与 priority/dueAt 同语义)
|
|
1283
|
+
applyNewFields(next, fields, `update/edit`)
|
|
1284
|
+
applyRoutingLabels(next)
|
|
737
1285
|
break
|
|
738
1286
|
}
|
|
739
1287
|
|
|
@@ -777,9 +1325,35 @@ export function createBoard(ctx, config = {}) {
|
|
|
777
1325
|
)
|
|
778
1326
|
}
|
|
779
1327
|
}
|
|
1328
|
+
// WIP 上限(P0-B §2):同 bot 名下 in_progress 任务数 >= 上限 → 拒
|
|
1329
|
+
//
|
|
1330
|
+
// ⚠️ 借自 superagent 的一条硬纪律(src/executor/dsh.ts:171-199):
|
|
1331
|
+
// `by === 'person'` 时**额外给一个槽位** —— 官方注释是
|
|
1332
|
+
// 「New work therefore cannot prevent a person from continuing an existing Ticket」。
|
|
1333
|
+
// 人手动接活不能被机器人的排队上限挡住:上限是给自动化流量设的闸,
|
|
1334
|
+
// 不是用来拒绝人。所以人的有效上限 = wipLimitPerBot + 1。
|
|
1335
|
+
const wipActive = [...tasks.values()].filter(
|
|
1336
|
+
(t) => t.status === 'in_progress' && t.ownerId === by,
|
|
1337
|
+
)
|
|
1338
|
+
const isPerson = by === 'person'
|
|
1339
|
+
const effectiveWipLimit = wipLimitPerBot + (isPerson ? 1 : 0)
|
|
1340
|
+
if (wipActive.length >= effectiveWipLimit) {
|
|
1341
|
+
throw new BoardError(
|
|
1342
|
+
`board.claim:WIP 超限 —— bot ${by} 当前在 ${wipActive.length} 个 in_progress 任务上(上限 ${effectiveWipLimit}),`
|
|
1343
|
+
+ `拒绝再领 ${taskId}`,
|
|
1344
|
+
'BOARD_WIP_EXCEEDED',
|
|
1345
|
+
{
|
|
1346
|
+
botId: by, limit: effectiveWipLimit, baseLimit: wipLimitPerBot,
|
|
1347
|
+
personBonus: isPerson, current: wipActive.length, taskId,
|
|
1348
|
+
},
|
|
1349
|
+
)
|
|
1350
|
+
}
|
|
780
1351
|
next.status = 'in_progress'
|
|
781
1352
|
next.ownerId = by
|
|
782
1353
|
next.lease = { ownerId: by, expiresAt: at + ttl, heartbeatAt: at }
|
|
1354
|
+
if (next.startedAt === undefined) next.startedAt = at
|
|
1355
|
+
// claim 也可顺带纠正路由标注(规划 Agent 领活时补/改 expectedComplexity 的路径)
|
|
1356
|
+
applyRoutingLabels(next)
|
|
783
1357
|
break
|
|
784
1358
|
}
|
|
785
1359
|
|
|
@@ -855,12 +1429,49 @@ export function createBoard(ctx, config = {}) {
|
|
|
855
1429
|
{ id: taskId, ownerId: current.lease.ownerId },
|
|
856
1430
|
)
|
|
857
1431
|
}
|
|
1432
|
+
// ── 结构化收尾(2026-11-09,借鉴 superagent Report 枚举)──────────────
|
|
1433
|
+
// 落到 `latestReport`(**单一真相**),而不是另开顶层 `outcome` 字段 ——
|
|
1434
|
+
// `src/stage.js` 的 8 阶段分类器读的就是 `latestReport.state` / `latestReport.attention`,
|
|
1435
|
+
// 若这里写另一个字段名,阶段表永远派不出「等你验收/等你回答」。
|
|
1436
|
+
//
|
|
1437
|
+
// 顺序纪律(与 stage.js 同构):complete 时**不要**带 attention ——
|
|
1438
|
+
// attention 的优先级高于 `ready`(stage 顺序:answer > review),
|
|
1439
|
+
// 带 attention 会把「待验收」显示成「等你回答」,语义就错了。
|
|
1440
|
+
const rawOutcome = fields.outcome
|
|
1441
|
+
const outcomeNorm = normalizeOutcome(rawOutcome, 'complete')
|
|
1442
|
+
if (outcomeNorm !== undefined && !COMPLETE_OUTCOMES.includes(outcomeNorm)) {
|
|
1443
|
+
throw new BoardError(
|
|
1444
|
+
`board.complete:outcome=${JSON.stringify(outcomeNorm)} 不能用 complete 交付 —— `
|
|
1445
|
+
+ `「卡住/失败」的任务不该出现在「待验收」里(那是 ready/dropped 的语义)。请改用 board.update block。`,
|
|
1446
|
+
'BOARD_BAD_FIELD',
|
|
1447
|
+
{ id: taskId, outcome: outcomeNorm, allowed: COMPLETE_OUTCOMES },
|
|
1448
|
+
)
|
|
1449
|
+
}
|
|
1450
|
+
const humanActionNorm = normalizeHumanAction(fields.humanAction, 'complete')
|
|
1451
|
+
if (humanActionNorm !== undefined) {
|
|
1452
|
+
throw new BoardError(
|
|
1453
|
+
`board.complete:complete 不接受 humanAction(${JSON.stringify(humanActionNorm)})—— `
|
|
1454
|
+
+ '完成就是「等人验收」,不是「等人回答」;要提问请用 block 或 edit。'
|
|
1455
|
+
+ '(否则 stage 会把「待验收」误判成「等你回答」)',
|
|
1456
|
+
'BOARD_BAD_FIELD', { id: taskId, field: 'humanAction' },
|
|
1457
|
+
)
|
|
1458
|
+
}
|
|
1459
|
+
|
|
858
1460
|
next.artifacts = normalizeArtifacts(artifacts, taskId)
|
|
859
1461
|
next.status = 'completed'
|
|
860
1462
|
next.completedAt = at
|
|
861
1463
|
next.ownerId = current.ownerId ?? by
|
|
1464
|
+
// `ready` = 干完了等人验收(默认);`dropped` = 结论是「不需要人做任何事」。
|
|
1465
|
+
// 两者都合法,但面板含义完全不同:前者进「待验收」,后者直接归档不显示。
|
|
1466
|
+
// 缺省取 `ready` —— 一个 completed 任务不该默默变成「无需验收」。
|
|
1467
|
+
next.latestReport = {
|
|
1468
|
+
state: outcomeNorm ?? 'ready',
|
|
1469
|
+
at,
|
|
1470
|
+
reporter: by,
|
|
1471
|
+
}
|
|
862
1472
|
delete next.lease
|
|
863
1473
|
delete next.acceptance // 完成即待验收,旧的验收结论作废(历史在事件流里)
|
|
1474
|
+
delete next.accepted // `accepted` 与 `acceptance` 同生同灭,必须一起清
|
|
864
1475
|
break
|
|
865
1476
|
}
|
|
866
1477
|
|
|
@@ -880,6 +1491,8 @@ export function createBoard(ctx, config = {}) {
|
|
|
880
1491
|
delete next.completedAt
|
|
881
1492
|
delete next.artifacts
|
|
882
1493
|
delete next.blockedReason
|
|
1494
|
+
// 重开 = 一切收尾结论作废(旧结论在事件流的 before 里留档)
|
|
1495
|
+
delete next.latestReport
|
|
883
1496
|
break
|
|
884
1497
|
}
|
|
885
1498
|
|
|
@@ -893,7 +1506,158 @@ export function createBoard(ctx, config = {}) {
|
|
|
893
1506
|
}
|
|
894
1507
|
next.status = 'blocked'
|
|
895
1508
|
delete next.lease
|
|
896
|
-
|
|
1509
|
+
// ⚠️ 修复(2026-11-04 测试):tools.js 的 schema 与 UI 传的是 `reason`,
|
|
1510
|
+
// 实现却只读 `note` ⇒ 「终止/阻塞原因」被静默丢弃。现在两个字段都认。
|
|
1511
|
+
const blockReason = note !== undefined && note !== null ? note : reason
|
|
1512
|
+
if (blockReason !== undefined && blockReason !== null && String(blockReason) !== '') {
|
|
1513
|
+
next.blockedReason = String(blockReason)
|
|
1514
|
+
}
|
|
1515
|
+
// ── 结构化「要人做什么」(2026-11-09)──────────────────────────────
|
|
1516
|
+
// 落到 `latestReport`(与 stage.js 共用同一真相):
|
|
1517
|
+
// state = blocked | failed ⇒ stage 派 `person/blocked` / `person/failed`
|
|
1518
|
+
// attention.question ⇒ 若给了 humanAction,stage 会优先派 `person/answer`
|
|
1519
|
+
//
|
|
1520
|
+
// ⚠️ 顺序陷阱:stage.js 里 `answer` 的优先级**高于** `blocked`。
|
|
1521
|
+
// 所以「blocked + 带 attention」会显示成「等你回答」而不是「受阻等你」。
|
|
1522
|
+
// 这其实是对的语义 —— 如果执行者能说清「我要你做什么」,那就是在等你回答;
|
|
1523
|
+
// 说不出(只 report blocked)才落到「受阻等你」。两者都指向「该人动手」。
|
|
1524
|
+
const blockOutcome = normalizeOutcome(fields.outcome, 'block')
|
|
1525
|
+
if (blockOutcome !== undefined && !['blocked', 'failed'].includes(blockOutcome)) {
|
|
1526
|
+
throw new BoardError(
|
|
1527
|
+
`board.block:outcome 只能是 blocked | failed,收到 ${JSON.stringify(blockOutcome)}`
|
|
1528
|
+
+ '(ready/dropped 请用 complete)',
|
|
1529
|
+
'BOARD_BAD_FIELD', { id: taskId, outcome: blockOutcome },
|
|
1530
|
+
)
|
|
1531
|
+
}
|
|
1532
|
+
const blockHumanAction = normalizeHumanAction(fields.humanAction, 'block')
|
|
1533
|
+
if (blockHumanAction !== undefined && String(blockHumanAction).trim() === '') {
|
|
1534
|
+
throw new BoardError(
|
|
1535
|
+
`board.block:humanAction 不能是空白 —— 要么写清要人做什么,要么不传`,
|
|
1536
|
+
'BOARD_BAD_FIELD', { id: taskId, field: 'humanAction' },
|
|
1537
|
+
)
|
|
1538
|
+
}
|
|
1539
|
+
/** @type {Record<string, unknown>} */
|
|
1540
|
+
const blockReport = {
|
|
1541
|
+
state: blockOutcome ?? 'blocked',
|
|
1542
|
+
at,
|
|
1543
|
+
reporter: by,
|
|
1544
|
+
}
|
|
1545
|
+
// attention 只在**明确给了 humanAction** 时才写 —— 绝不用 blockedReason
|
|
1546
|
+
// 冒充「问题」。理由:blockedReason 是「为什么卡住」(给人看的诊断),
|
|
1547
|
+
// attention 是「要你做什么」(给人的行动指令)。把诊断当指令,会让人
|
|
1548
|
+
// 在「等你回答」里看到一句「端口被占用」,然后不知道该回什么。
|
|
1549
|
+
if (blockHumanAction !== undefined) {
|
|
1550
|
+
blockReport.attention = { question: blockHumanAction }
|
|
1551
|
+
}
|
|
1552
|
+
next.latestReport = blockReport
|
|
1553
|
+
break
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// ── 解除阻塞(2026-11-04 新增:tools.js schema 与 README 早已承诺,实现缺失)──
|
|
1557
|
+
case 'unblock': {
|
|
1558
|
+
if (isDead(current.status)) {
|
|
1559
|
+
throw new BoardError(`board.unblock:任务 ${taskId} 已删除,不能 unblock`, 'BOARD_TASK_DELETED', { id: taskId })
|
|
1560
|
+
}
|
|
1561
|
+
if (current.status !== 'blocked') {
|
|
1562
|
+
throw new BoardError(
|
|
1563
|
+
`board.unblock:任务 ${taskId} 当前状态是 ${current.status},只有 blocked 才能 unblock`,
|
|
1564
|
+
'BOARD_BAD_STATE',
|
|
1565
|
+
{ id: taskId, status: current.status },
|
|
1566
|
+
)
|
|
1567
|
+
}
|
|
1568
|
+
// 依赖未完成的仍保持 blocked(依赖是数据约束,不是状态约束)
|
|
1569
|
+
const unmet = (current.blockedBy ?? []).filter((d) => {
|
|
1570
|
+
const dep = tasks.get(d)
|
|
1571
|
+
return dep ? dep.status !== 'completed' && dep.status !== 'deleted' : true
|
|
1572
|
+
})
|
|
1573
|
+
next.status = unmet.length === 0 ? 'pending' : 'blocked'
|
|
1574
|
+
delete next.blockedReason
|
|
1575
|
+
break
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// ── 直接改字段/状态(2026-11-04 新增:tools.js schema 早承诺、实现缺失)──
|
|
1579
|
+
case 'set': {
|
|
1580
|
+
if (isDead(current.status)) {
|
|
1581
|
+
throw new BoardError(`board.set:任务 ${taskId} 已删除,不能 set`, 'BOARD_TASK_DELETED', { id: taskId })
|
|
1582
|
+
}
|
|
1583
|
+
const fieldsGiven = Object.keys(fields).filter((k) => fields[k] !== undefined)
|
|
1584
|
+
if (fieldsGiven.length === 0) {
|
|
1585
|
+
throw new BoardError(
|
|
1586
|
+
`board.set:未提供任何字段(可改:${PATCH_FIELDS.join(' | ')} | status | priority | dueAt)`,
|
|
1587
|
+
'BOARD_BAD_FIELD',
|
|
1588
|
+
{ id: taskId, patchable: PATCH_FIELDS },
|
|
1589
|
+
)
|
|
1590
|
+
}
|
|
1591
|
+
// P0-B 新增字段:走统一校验后落值(不许经 raw 赋值绕过 progress/labels 校验)
|
|
1592
|
+
applyNewFields(next, fields, 'set')
|
|
1593
|
+
for (const k of fieldsGiven) {
|
|
1594
|
+
if (NEW_FIELD_NAMES.includes(k)) continue
|
|
1595
|
+
if (k === 'status') {
|
|
1596
|
+
if (!['pending', 'blocked', 'in_progress'].includes(fields.status)) {
|
|
1597
|
+
throw new BoardError(
|
|
1598
|
+
`board.set:status 只允许 pending | blocked | in_progress(completed 走 complete/accept,deleted 走 delete)`,
|
|
1599
|
+
'BOARD_BAD_FIELD',
|
|
1600
|
+
{ field: 'status', value: fields.status },
|
|
1601
|
+
)
|
|
1602
|
+
}
|
|
1603
|
+
next.status = fields.status
|
|
1604
|
+
if (fields.status !== 'in_progress') delete next.lease
|
|
1605
|
+
} else if (k === 'expectedComplexity') {
|
|
1606
|
+
if (fields[k] === null) delete next.expectedComplexity
|
|
1607
|
+
else next.expectedComplexity = normalizeComplexity(fields[k], 'set')
|
|
1608
|
+
} else if (k === 'requiresPaid') {
|
|
1609
|
+
if (fields[k] === null) delete next.requiresPaid
|
|
1610
|
+
else next.requiresPaid = normalizeRequiresPaid(fields[k], 'set')
|
|
1611
|
+
} else if (PATCH_FIELDS.includes(k)) {
|
|
1612
|
+
next[k] = fields[k]
|
|
1613
|
+
} else {
|
|
1614
|
+
throw new BoardError(
|
|
1615
|
+
`board.set:字段 ${JSON.stringify(k)} 不可直接设置(可改:${PATCH_FIELDS.join(' | ')} | status)`,
|
|
1616
|
+
'BOARD_BAD_FIELD',
|
|
1617
|
+
{ field: k, patchable: PATCH_FIELDS },
|
|
1618
|
+
)
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
break
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// ── Triage 闸门(P0-B §3)───────────────────────────────────────────
|
|
1625
|
+
// 语义:把「创建时随手记下的一条」变成「可以派给人干的一条」。
|
|
1626
|
+
// 这一步才允许指定 assignee / priority / dueAt / labels —— 未 triage 的任务
|
|
1627
|
+
// 不进 ready()(见 requireTriageForReady),避免「没想清楚就派活」。
|
|
1628
|
+
case 'triage': {
|
|
1629
|
+
if (isDead(current.status)) {
|
|
1630
|
+
throw new BoardError(`board.triage:任务 ${taskId} 已删除,不能 triage`, 'BOARD_TASK_DELETED', { id: taskId })
|
|
1631
|
+
}
|
|
1632
|
+
if (current.status !== 'pending') {
|
|
1633
|
+
throw new BoardError(
|
|
1634
|
+
`board.triage:任务 ${taskId} 当前状态是 ${current.status},只有 pending 才能 triage`,
|
|
1635
|
+
'BOARD_BAD_STATE',
|
|
1636
|
+
{ id: taskId, status: current.status, expected: 'pending' },
|
|
1637
|
+
)
|
|
1638
|
+
}
|
|
1639
|
+
if (current.triaged === true) {
|
|
1640
|
+
throw new BoardError(
|
|
1641
|
+
`board.triage:任务 ${taskId} 已 triage 过(rev=${current.rev})——要改派/改优先级请走 edit`,
|
|
1642
|
+
'BOARD_ALREADY_TRIAGED',
|
|
1643
|
+
{ id: taskId },
|
|
1644
|
+
)
|
|
1645
|
+
}
|
|
1646
|
+
next.triaged = true
|
|
1647
|
+
// 顺手可写:assignee / priority / dueAt / labels(同一套校验,不另开规则)
|
|
1648
|
+
if (fields.assignee !== undefined) {
|
|
1649
|
+
if (fields.assignee === null) delete next.assignee
|
|
1650
|
+
else next.assignee = normalizeOptionalString(fields.assignee, 'assignee', 'triage')
|
|
1651
|
+
}
|
|
1652
|
+
if (fields.priority !== undefined && fields.priority !== null) {
|
|
1653
|
+
next.priority = optionalNumber(fields.priority, 'priority', 'triage')
|
|
1654
|
+
}
|
|
1655
|
+
if (fields.dueAt !== undefined && fields.dueAt !== null) {
|
|
1656
|
+
next.dueAt = optionalNumber(fields.dueAt, 'dueAt', 'triage')
|
|
1657
|
+
}
|
|
1658
|
+
if (fields.labels !== undefined && fields.labels !== null) {
|
|
1659
|
+
next.labels = normalizeLabels(fields.labels, 'triage')
|
|
1660
|
+
}
|
|
897
1661
|
break
|
|
898
1662
|
}
|
|
899
1663
|
|
|
@@ -915,6 +1679,7 @@ export function createBoard(ctx, config = {}) {
|
|
|
915
1679
|
}
|
|
916
1680
|
next.status = 'deleted'
|
|
917
1681
|
next.deletedAt = at
|
|
1682
|
+
next.deletedBy = by
|
|
918
1683
|
delete next.lease
|
|
919
1684
|
break
|
|
920
1685
|
}
|
|
@@ -997,6 +1762,48 @@ export function createBoard(ctx, config = {}) {
|
|
|
997
1762
|
if (current.status === 'deleted') {
|
|
998
1763
|
throw new BoardError(`board.accept:任务 ${taskId} 已删除(tombstone),不能验收`, 'BOARD_TASK_DELETED', { id: taskId })
|
|
999
1764
|
}
|
|
1765
|
+
// ⚠️ 修复(2026-11-04 测试,P1-1):原来 accept 不检查任务状态,任何状态的
|
|
1766
|
+
// 任务都能直接被 accept('pass') 置 completed —— 绕过 complete() 的
|
|
1767
|
+
// blockedBy/lease/artifacts 校验,依赖图收敛逻辑形同虚设。
|
|
1768
|
+
// 现在:pass 要求任务已经是 completed(由 owner 完成且带 artifacts 后才验收),
|
|
1769
|
+
// reject 则允许从 completed 打回 pending。
|
|
1770
|
+
if (result === 'pass') {
|
|
1771
|
+
if (current.status !== 'completed') {
|
|
1772
|
+
throw new BoardError(
|
|
1773
|
+
`board.accept/pass:任务 ${taskId} 当前状态是 ${current.status},只有 completed 才能验收通过`,
|
|
1774
|
+
'BOARD_BAD_STATE',
|
|
1775
|
+
{ id: taskId, status: current.status, expected: 'completed' },
|
|
1776
|
+
)
|
|
1777
|
+
}
|
|
1778
|
+
if (current.blockedBy?.length) {
|
|
1779
|
+
const unmet = current.blockedBy.filter((d) => {
|
|
1780
|
+
const dep = tasks.get(d)
|
|
1781
|
+
return dep ? dep.status !== 'completed' && dep.status !== 'deleted' : true
|
|
1782
|
+
})
|
|
1783
|
+
if (unmet.length > 0) {
|
|
1784
|
+
throw new BoardError(
|
|
1785
|
+
`board.accept/pass:任务 ${taskId} 的依赖未全部完成(未完成:${unmet.join(', ')})`,
|
|
1786
|
+
'BOARD_BLOCKED_BY_UNMET',
|
|
1787
|
+
{ id: taskId, unmet },
|
|
1788
|
+
)
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
if (!current.artifacts || Object.keys(current.artifacts).length === 0) {
|
|
1792
|
+
throw new BoardError(
|
|
1793
|
+
`board.accept/pass:任务 ${taskId} 没有产出物(artifacts)—— complete() 的不变量未满足`,
|
|
1794
|
+
'BOARD_NO_ARTIFACTS',
|
|
1795
|
+
{ id: taskId },
|
|
1796
|
+
)
|
|
1797
|
+
}
|
|
1798
|
+
} else if (result === 'reject') {
|
|
1799
|
+
if (current.status !== 'completed') {
|
|
1800
|
+
throw new BoardError(
|
|
1801
|
+
`board.accept/reject:任务 ${taskId} 当前状态是 ${current.status},只有 completed 才能打回`,
|
|
1802
|
+
'BOARD_BAD_STATE',
|
|
1803
|
+
{ id: taskId, status: current.status, expected: 'completed' },
|
|
1804
|
+
)
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1000
1807
|
const at = normalizeNow(now, 'accept')
|
|
1001
1808
|
const before = clone(current)
|
|
1002
1809
|
const next = clone(current)
|
|
@@ -1009,11 +1816,20 @@ export function createBoard(ctx, config = {}) {
|
|
|
1009
1816
|
if (result === 'pass') {
|
|
1010
1817
|
next.status = 'completed'
|
|
1011
1818
|
next.completedAt = current.completedAt ?? at
|
|
1819
|
+
// ⚠️ 修复(2026-11-09):原来只写 `acceptance` 却从不写 `accepted`,
|
|
1820
|
+
// 而 `src/stage.js` 的「已完成且已验收 → idle」分支读的是 `accepted`。
|
|
1821
|
+
// 结果是:验收通过的任务永远显示「等你验收」,`person/review` 永不消失,
|
|
1822
|
+
// `needsAttention` 也永远认为它还需要关注 —— 阶段表被一个写侧缺口卡住。
|
|
1823
|
+
// 现在 `accepted` 必须由 acceptance 记录推出(**不接受手动 patch**):
|
|
1824
|
+
// 有 acceptance.result==='pass' 才有 accepted=true,两者同生同灭。
|
|
1825
|
+
next.accepted = true
|
|
1012
1826
|
} else {
|
|
1013
1827
|
next.status = 'pending'
|
|
1014
1828
|
delete next.ownerId
|
|
1015
1829
|
delete next.completedAt
|
|
1016
1830
|
delete next.artifacts // 验收驳回 → 产出物作废(在事件流的 before 里留档)
|
|
1831
|
+
delete next.latestReport // 旧的收尾结论一并作废(重做后会重新产生)
|
|
1832
|
+
delete next.accepted // 驳回即取消「已验收」,与 acceptance.result='reject' 一致
|
|
1017
1833
|
}
|
|
1018
1834
|
return commit(taskId, 'accept', actor, at, before, next)
|
|
1019
1835
|
}
|
|
@@ -1033,17 +1849,70 @@ export function createBoard(ctx, config = {}) {
|
|
|
1033
1849
|
}
|
|
1034
1850
|
}
|
|
1035
1851
|
|
|
1852
|
+
/**
|
|
1853
|
+
* 软删除墓碑清理(P0-B §4):把 deleted 且 deletedAt 早于阈值的任务从内存与快照移除。
|
|
1854
|
+
* 事件流**仍保留墓碑**(append-only 真相源),所以 `replay()` 后这些任务会重新出现;
|
|
1855
|
+
* purge 是内存/快照层的 housekeeping,不是审计 deletion。
|
|
1856
|
+
* @param {{ olderThanMs?: number, now?: number }} [options]
|
|
1857
|
+
* @returns {string[]} 被移除的 taskId
|
|
1858
|
+
*/
|
|
1859
|
+
function purgeDeleted(options = {}) {
|
|
1860
|
+
const at = normalizeNow(options?.now, 'purgeDeleted')
|
|
1861
|
+
const threshold = Number.isFinite(options?.olderThanMs) && options.olderThanMs >= 0
|
|
1862
|
+
? at - options.olderThanMs
|
|
1863
|
+
: at
|
|
1864
|
+
const removed = []
|
|
1865
|
+
for (const [id, t] of [...tasks.entries()]) {
|
|
1866
|
+
if (t.status !== 'deleted') continue
|
|
1867
|
+
if (typeof t.deletedAt !== 'number' || !Number.isFinite(t.deletedAt)) continue
|
|
1868
|
+
if (t.deletedAt < threshold) {
|
|
1869
|
+
tasks.delete(id)
|
|
1870
|
+
removed.push(id)
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
if (removed.length > 0) {
|
|
1874
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
1875
|
+
diagnostics.snapshotWriteFailures += 1
|
|
1876
|
+
warn(`[cs-bots/board] purgeDeleted 快照写入失败(${snapPath})——事件流仍保留墓碑,replay() 可恢复`)
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return removed
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1036
1882
|
/** 首次构造即回放,保证 `list()` 在重启后马上可用。 */
|
|
1037
1883
|
hydrate()
|
|
1038
1884
|
|
|
1885
|
+
/**
|
|
1886
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
1887
|
+
* ⚠️ 修复(2026-11-04 测试,P2-2):board 之前没有 `stats()`,`/status` 与
|
|
1888
|
+
* UI 概览里任务数恒显示 `—`。
|
|
1889
|
+
*/
|
|
1890
|
+
function stats() {
|
|
1891
|
+
const byStatus = {}
|
|
1892
|
+
let total = 0, ready = 0, blocked = 0, inProgress = 0, completed = 0, deleted = 0
|
|
1893
|
+
for (const t of tasks.values()) {
|
|
1894
|
+
total += 1
|
|
1895
|
+
byStatus[t.status] = (byStatus[t.status] ?? 0) + 1
|
|
1896
|
+
if (t.status === 'in_progress') inProgress += 1
|
|
1897
|
+
else if (t.status === 'blocked') blocked += 1
|
|
1898
|
+
else if (t.status === 'completed') completed += 1
|
|
1899
|
+
else if (t.status === 'deleted') deleted += 1
|
|
1900
|
+
else if (t.status === 'pending' && unfinishedDeps(t).length === 0) ready += 1
|
|
1901
|
+
}
|
|
1902
|
+
return { total, ready, blocked, inProgress, completed, deleted, byStatus, events: events.length }
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1039
1905
|
const board = {
|
|
1040
1906
|
name: config.name ?? 'board',
|
|
1041
1907
|
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
1042
1908
|
create,
|
|
1043
1909
|
get,
|
|
1910
|
+
stats,
|
|
1044
1911
|
list,
|
|
1045
1912
|
update,
|
|
1046
1913
|
ready,
|
|
1914
|
+
needsAttention,
|
|
1915
|
+
purgeDeleted,
|
|
1047
1916
|
expireLeases,
|
|
1048
1917
|
accept,
|
|
1049
1918
|
replay,
|