cs-bots 0.1.1 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/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 = ['subject', 'description', 'blockedBy', 'writeScopes', 'priority', 'dueAt']
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
- /** `complete` 的产出物证据归一:非空字符串或非空数组(元素为字符串/对象)。 */
193
- function normalizeArtifacts(value, taskId) {
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 === status) out.push(clone(t))
537
- } else if (t.status !== 'deleted') {
538
- out.push(clone(t))
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 { subject, description, blockedBy, writeScopes, priority, dueAt, byId, sourceSeq, now } = input ?? {}
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
- const nextCounter = idCounter + 1
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
- if (note !== undefined && note !== null) next.blockedReason = String(note)
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,