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/roster.js CHANGED
@@ -58,7 +58,28 @@ import {
58
58
  writeJsonAtomic,
59
59
  } from './store.js'
60
60
  import * as kernel from './kernel.js'
61
- import { normalizeProfile, writeAgentsFile, readAgentsFile, writeSkill } from './botprofile.js'
61
+ import {
62
+ normalizeProfile,
63
+ writeAgentsFile,
64
+ readAgentsFile,
65
+ writeSkill,
66
+ listSkills,
67
+ importMemoryToProfile,
68
+ } from './botprofile.js'
69
+
70
+ // ── store.appendAudit 软导入(P1-D §4)───────────────────────────────────────
71
+ // 软用:拿不到就只 warn 一次 + 计数,绝不阻断主流程,也绝不自己另造审计格式。
72
+ // 动态 `await import` 而非具名静态 import:store.js 正被并行修改,静态具名 import 在
73
+ // 对方重构掉该导出时是**加载期**失败(整个服务起不来);动态判定只少一条审计。
74
+ let storeAppendAudit = /** @type {null | ((dir: string, entry: object) => unknown)} */ (null)
75
+ let storeAuditMissing = false
76
+ try {
77
+ const storeMod = await import('./store.js')
78
+ if (typeof storeMod.appendAudit === 'function') storeAppendAudit = storeMod.appendAudit
79
+ else storeAuditMissing = true
80
+ } catch {
81
+ storeAuditMissing = true
82
+ }
62
83
 
63
84
  // ── 常量 ────────────────────────────────────────────────────────────────────
64
85
 
@@ -75,7 +96,23 @@ const MAX_LABEL = 24
75
96
  /** 租约缺省 TTL(ms),与 board 的 300s 对齐。 */
76
97
  const DEFAULT_LEASE_MS = 300_000
77
98
  /** 事件动作集合(审计流可枚举)。 */
78
- const ACTIONS = ['create', 'rename', 'retire', 'set_cwd', 'claim', 'release', 'heartbeat', 'lease_expired']
99
+ const ACTIONS = [
100
+ 'create',
101
+ 'rename',
102
+ 'retire',
103
+ 'set_cwd',
104
+ 'claim',
105
+ 'release',
106
+ 'heartbeat',
107
+ 'lease_expired',
108
+ // v2 档案增强:三种「改档案」的入口各自留痕,避免审计流里只能看到一条笼统的 profile_updated
109
+ // (排查「这条记忆谁加进来的」「技能怎么关联上的」时,动作名是第一手证据)。
110
+ 'profile_updated',
111
+ 'profile_memory_added',
112
+ 'profile_skills_linked',
113
+ // 改造07(借鉴 Super-agent 的 prepare command):开工前准备命令的变更留痕
114
+ 'prepare_command_set',
115
+ ]
79
116
 
80
117
  // ── 错误 ────────────────────────────────────────────────────────────────────
81
118
 
@@ -325,6 +362,8 @@ export function createRoster(ctx, config = {}) {
325
362
  badLines: 0,
326
363
  skippedEvents: 0,
327
364
  snapshotWriteFailures: 0,
365
+ auditWriteFailures: 0,
366
+ auditWarnedOnce: false,
328
367
  lastReplayAt: /** @type {number|undefined} */ (undefined),
329
368
  }
330
369
 
@@ -392,6 +431,38 @@ export function createRoster(ctx, config = {}) {
392
431
  diagnostics.snapshotWriteFailures += 1
393
432
  warn(`[cs-bots/roster] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
394
433
  }
434
+ // ── 审计接线(P1-D §4)────────────────────────────────────────────────
435
+ // 软用 store.appendAudit:拿不到/写失败 → 只 warn 一次 + 计数,绝不阻断主流程。
436
+ if (storeAuditMissing) {
437
+ diagnostics.auditWriteFailures += 1
438
+ if (!diagnostics.auditWarnedOnce) {
439
+ diagnostics.auditWarnedOnce = true
440
+ warn('[cs-bots/roster] store.appendAudit 尚不可用 → 审计接线跳过;主流程不受影响')
441
+ }
442
+ } else {
443
+ try {
444
+ const digest = (b) => (b
445
+ ? { id: b.id, name: b.name ?? null, state: b.state ?? null, rev: b.rev }
446
+ : null)
447
+ const result = storeAppendAudit(stateDir, {
448
+ ts: at, actor: byId, action, target: botId, before: digest(before), after: digest(after),
449
+ })
450
+ const failed = result === false || Boolean(result && typeof result === 'object' && result.ok === false)
451
+ if (failed) {
452
+ diagnostics.auditWriteFailures += 1
453
+ if (!diagnostics.auditWarnedOnce) {
454
+ diagnostics.auditWarnedOnce = true
455
+ warn('[cs-bots/roster] store.appendAudit 返回失败——主流程不受影响')
456
+ }
457
+ }
458
+ } catch (error) {
459
+ diagnostics.auditWriteFailures += 1
460
+ if (!diagnostics.auditWarnedOnce) {
461
+ diagnostics.auditWarnedOnce = true
462
+ warn(`[cs-bots/roster] store.appendAudit 抛错:${error?.message ?? error}——主流程不受影响`)
463
+ }
464
+ }
465
+ }
395
466
  return clone(after)
396
467
  }
397
468
 
@@ -551,6 +622,33 @@ export function createRoster(ctx, config = {}) {
551
622
  return list({ state: 'retired' })
552
623
  }
553
624
 
625
+ /**
626
+ * 按**会话 id** 判定「这个会话是不是本平台注册的 Bot」(含已退役)。
627
+ *
628
+ * 为什么需要(2026-11-04,复杂度路由 P3-fix):宿主 `llm/stream` 请求里**只有
629
+ * `sessionId`**,而注册表的主键是 `botId`。路由要区分「主对话」(人类会话 ⇒ 平台层
630
+ * 注入 paid_allowed)与「Bot 会话」(子智能体 ⇒ 默认 free_only),就必须有这条查询。
631
+ *
632
+ * 为什么按 `sessionId` **字段**匹配而不是 `bots.get(id)`:`create()` 里宿主可能把
633
+ * 请求的 sessionId 改写成别的值(`pickSessionId` 已按「以宿主返回为准」处理并留档),
634
+ * 拿 botId 当键查会在这种情形下漏判。
635
+ *
636
+ * 为什么包含退役 Bot:退役只释放名字与槽位,**会话本身还在宿主编排里把手上的活干完**,
637
+ * 这段时间它仍是子智能体 —— 按主对话给付费授权属于越权。
638
+ *
639
+ * 纯读、**不抛**(非法入参返回 false)。
640
+ *
641
+ * @param {string} sessionId
642
+ * @returns {boolean}
643
+ */
644
+ function hasSession(sessionId) {
645
+ if (typeof sessionId !== 'string' || sessionId === '') return false
646
+ for (const b of bots.values()) {
647
+ if (b?.sessionId === sessionId) return true
648
+ }
649
+ return false
650
+ }
651
+
554
652
  /**
555
653
  * 注册表统计。
556
654
  * 口径说明:`total` = **含 retired 的全部 Bot**;`byState` 是全部状态的分布;
@@ -596,7 +694,7 @@ export function createRoster(ctx, config = {}) {
596
694
  * @throws {RosterError} 缺字段 / 非法 cwd / 重名 / 会话创建失败 / 事件落盘失败
597
695
  */
598
696
  async function create(input = {}) {
599
- const { name, cwd, label, model, instructions, preset, permission, profile, byId, now } = input ?? {}
697
+ const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now } = input ?? {}
600
698
  const by = requireString(byId, 'byId', 'create')
601
699
  const displayName = requireString(name, 'name', 'create')
602
700
  const dir = assertBotCwd(cwd, 'create')
@@ -626,6 +724,7 @@ export function createRoster(ctx, config = {}) {
626
724
  handle = await kernel.createSession(ctx, {
627
725
  sessionId: id,
628
726
  cwd: dir,
727
+ warn,
629
728
  ...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
630
729
  })
631
730
  } catch (error) {
@@ -659,6 +758,12 @@ export function createRoster(ctx, config = {}) {
659
758
  if (roleLabel !== undefined) bot.label = roleLabel
660
759
  if (mdl !== undefined) bot.model = mdl
661
760
  if (inst !== undefined) bot.instructions = inst
761
+ // 改造07:开工准备命令(借鉴 Super-agent 的 prepare command)
762
+ if (prepareCommand !== undefined && prepareCommand !== null && prepareCommand !== '') {
763
+ bot.prepareCommand = String(prepareCommand).trim()
764
+ bot.prepareCommandSetAt = at
765
+ bot.prepareCommandSetBy = by
766
+ }
662
767
  // ── 档案(身份/经验/技能/预制记忆)→ 落到官方机制上 ──────────────────
663
768
  if (profile !== undefined) {
664
769
  try {
@@ -790,7 +895,10 @@ export function createRoster(ctx, config = {}) {
790
895
  */
791
896
  function getProfile(id) {
792
897
  const bid = requireString(id, 'id', 'getProfile')
793
- const bot = index.get(bid)
898
+ // ⚠️ 修复(2026-11-04 测试):原为 `index.get(bid)` —— 本函数作用域内只有
899
+ // `bots` Map,没有 `index` ⇒ 每次调用必然 ReferenceError(HTTP 400
900
+ // `API_ERROR: index is not defined`),Bot 档案抽屉打开即报错。
901
+ const bot = bots.get(bid)
794
902
  if (!bot) {
795
903
  throw new RosterError(`roster.getProfile:Bot ${bid} 不存在`, 'ROSTER_BOT_NOT_FOUND', { id: bid })
796
904
  }
@@ -807,57 +915,231 @@ export function createRoster(ctx, config = {}) {
807
915
  }
808
916
 
809
917
  /**
810
- * 改一个 Bot 的档案(身份/经验/技能/预制记忆)→ 重写 AGENTS.md 托管段。
918
+ * 退役 Bot 不可改档案 —— 三个改档案入口(setProfile / addProfileMemory / linkSkills)
919
+ * 共用同一守卫:**同一语义只有一处判定**,否则日后加新入口时漏一个就会给退役 Bot 改档案。
811
920
  *
812
- * **只动托管段**,用户在 AGENTS.md 段外写的内容原样保留。
813
- * @param {{ id: string, profile: object, byId?: string, expectedRev?: number, now?: number }} input
921
+ * 错误码沿用 setProfile 既有的 `ROSTER_BOT_RETIRED`(不是 assertNotRetired 的
922
+ * `ROSTER_RETIRED`):API 层与 UI 已按前者识别「退役导致不可改档案」,改码会让提示退化成通用错误。
923
+ *
924
+ * @param {object} current
925
+ * @param {string} action
814
926
  */
815
- function setProfile(input = {}) {
816
- const { id, profile, byId, now } = input ?? {}
817
- const current = loadForCas(input, 'setProfile')
818
- const by = requireString(byId, 'byId', 'setProfile')
927
+ function assertProfileEditable(current, action) {
819
928
  if (current.state === 'retired') {
820
929
  throw new RosterError(
821
- `roster.setProfile:Bot ${current.id} 已退役(名字 ${current.name})——退役 Bot 不可改档案`,
930
+ `roster.${action}:Bot ${current.id} 已退役(名字 ${current.name})——退役 Bot 不可改档案`,
822
931
  'ROSTER_BOT_RETIRED',
823
932
  { id: current.id },
824
933
  )
825
934
  }
826
- const nextProfile = { ...(current.profile ?? {}), ...normalizeProfile(profile) }
935
+ }
827
936
 
828
- // 先落盘,成功再记事件(避免"记录说改了但文件没变")
937
+ /**
938
+ * 把档案写进 AGENTS.md(受管区块),并给**缺失的**技能补一个骨架 SKILL.md。
939
+ *
940
+ * 为什么抽成一个函数:setProfile / addProfileMemory / linkSkills 三条入口必须**同一套落盘口径**。
941
+ * 复制三份的下场是「同一种档案,三个入口写出三种 AGENTS.md」——历史上已经吃过一次
942
+ * (getProfile 引用了不存在的 index 变量,单点 bug 让整个抽屉打不开)。
943
+ *
944
+ * 为什么技能骨架只在文件缺失时补:已存在的 SKILL.md 里可能是用户/模型写好的正文,
945
+ * 旧实现无条件覆盖成骨架,等于每次存档案就静默删掉一次技能内容(数据损失且无提示)。
946
+ *
947
+ * @param {object} current Bot 记录(只读 cwd / name / preset)
948
+ * @param {object} profile 归一后的档案
949
+ * @param {string} where 错误信息里的动作名
950
+ * @param {{ strictSkills?: boolean }} [options] strictSkills=true → 技能落盘失败即抛错
951
+ * @returns {{ path: string }} writeAgentsFile 的返回
952
+ */
953
+ function writeProfileFiles(current, profile, where, options = {}) {
829
954
  let written
830
955
  try {
831
956
  written = writeAgentsFile(current.cwd, {
832
- ...nextProfile,
957
+ ...profile,
833
958
  botName: current.name,
834
959
  preset: current.preset,
835
960
  })
836
- for (const skillName of nextProfile.skills ?? []) {
837
- try {
838
- writeSkill(current.cwd, skillName, { body: `# ${skillName}\n\n(在此写技能说明)` })
839
- } catch (error) {
840
- warn(`[cs-bots/roster] 技能 ${skillName} 落盘失败:${error?.message ?? error}`)
841
- }
842
- }
843
961
  } catch (error) {
844
962
  throw new RosterError(
845
- `roster.setProfile:写 AGENTS.md 失败(cwd=${current.cwd})→ ${error?.message ?? error}`,
963
+ `roster.${where}:写 AGENTS.md 失败(cwd=${current.cwd})→ ${error?.message ?? error}`,
846
964
  'ROSTER_PROFILE_WRITE_FAILED',
847
965
  { id: current.id, cwd: current.cwd, cause: String(error?.message ?? error) },
848
966
  )
849
967
  }
850
968
 
851
- const at = normalizeNow(now, 'setProfile')
969
+ let present = new Set()
970
+ try {
971
+ present = new Set(listSkills(current.cwd))
972
+ } catch (error) {
973
+ // 列技能失败不阻塞:下一步 writeSkill 会给出带 code 的真实失败原因
974
+ warn(`[cs-bots/roster] 列技能失败(${current.cwd}):${error?.message ?? error}`)
975
+ }
976
+ for (const skillName of profile.skills ?? []) {
977
+ if (present.has(skillName)) continue
978
+ try {
979
+ writeSkill(current.cwd, skillName, { body: `# ${skillName}\n\n(在此写技能说明)` })
980
+ } catch (error) {
981
+ if (options.strictSkills) {
982
+ // 技能名非法与写盘失败要给不同 code:前者是调用方参数错(改参数即可),后者是环境问题
983
+ const badName = error?.code === 'PROFILE_BAD_SKILL_NAME'
984
+ throw new RosterError(
985
+ `roster.${where}:技能 ${JSON.stringify(skillName)} 落盘失败 → ${error?.message ?? error}`,
986
+ badName ? 'ROSTER_BAD_SKILL_NAME' : 'ROSTER_SKILL_WRITE_FAILED',
987
+ {
988
+ id: current.id,
989
+ cwd: current.cwd,
990
+ skill: skillName,
991
+ causeCode: error?.code,
992
+ cause: String(error?.message ?? error),
993
+ },
994
+ )
995
+ }
996
+ // 非显式技能操作(setProfile 顺带补骨架)失败只降级 warn:
997
+ // AGENTS.md 已写成功,档案本身有效,缺一个骨架文件不该让整次保存失败。
998
+ warn(`[cs-bots/roster] 技能 ${skillName} 落盘失败(不影响档案):${error?.message ?? error}`)
999
+ }
1000
+ }
1001
+ return written
1002
+ }
1003
+
1004
+ /**
1005
+ * 档案变更的统一收尾:rev+1 + 审计字段 + 提交事件。
1006
+ *
1007
+ * @param {object} current
1008
+ * @param {object} nextProfile
1009
+ * @param {string} by
1010
+ * @param {number} at
1011
+ * @param {{ path: string }} written
1012
+ * @param {string} action
1013
+ * @returns {object} 变更后的 Bot(克隆)
1014
+ */
1015
+ function commitProfileChange(current, nextProfile, by, at, written, action = 'profile_updated') {
852
1016
  const before = clone(current)
853
1017
  const next = clone(current)
854
1018
  next.profile = nextProfile
855
1019
  next.profilePath = written.path
856
- delete next.profileError
1020
+ delete next.profileError // 落盘成功即清掉历史失败标记,否则记录会永远带着一条过期错误
857
1021
  next.rev = current.rev + 1
858
1022
  next.updatedAt = at
859
1023
  next.updatedBy = by
860
- return commit(current.id, 'profile_updated', by, at, before, next)
1024
+ return commit(current.id, action, by, at, before, next)
1025
+ }
1026
+
1027
+ /**
1028
+ * 改一个 Bot 的档案(身份/经验/技能/预制记忆 + v2 的 title/persona/boundaries/disciplineIds)
1029
+ * → 重写 AGENTS.md 托管段。
1030
+ *
1031
+ * **只动托管段**,用户在 AGENTS.md 段外写的内容原样保留。
1032
+ *
1033
+ * 语义是**浅合并**:`{ ...旧档案, ...归一后的新档案 }`。故传 `{ persona: '...' }`
1034
+ * 不会清掉 identity —— 想清空某个字段传空串(`''` 会被 takeText 接受);
1035
+ * 想整份替换请先 `get()` 再传完整对象(UI 的档案抽屉就是这么做的)。
1036
+ *
1037
+ * @param {{ id: string, profile: object, byId?: string, expectedRev?: number, now?: number }} input
1038
+ */
1039
+ function setProfile(input = {}) {
1040
+ const { profile, byId, now } = input ?? {}
1041
+ const current = loadForCas(input, 'setProfile')
1042
+ const by = requireString(byId, 'byId', 'setProfile')
1043
+ assertProfileEditable(current, 'setProfile')
1044
+ const nextProfile = { ...(current.profile ?? {}), ...normalizeProfile(profile) }
1045
+
1046
+ // 先落盘,成功再记事件(避免"记录说改了但文件没变")
1047
+ const written = writeProfileFiles(current, nextProfile, 'setProfile')
1048
+ const at = normalizeNow(now, 'setProfile')
1049
+ return commitProfileChange(current, nextProfile, by, at, written)
1050
+ }
1051
+
1052
+ /**
1053
+ * 往 Bot 的预制记忆里**追加**一批条目(v2,DESIGN-v2 特性 ③)。
1054
+ *
1055
+ * 与 setProfile 的分工:setProfile 是「整份档案覆盖」,本函数是「只加记忆、别的不动」——
1056
+ * 记忆是持续生长的(会话里捞到一条值得常驻的结论就顺手加进来),
1057
+ * 让调用方每次先 get 再整份回写,等于把并发 CAS 的窗口人为拉长,容易互相覆盖。
1058
+ *
1059
+ * 去重、空白、超限的规则全在 `botprofile.importMemoryToProfile`(纯函数,可单独测)。
1060
+ *
1061
+ * 无新增(全是重复/空白)时**不写事件、不 bump rev**:审计流里一条「什么都没改」的记录
1062
+ * 只会污染历史,而且调用方手里的 expectedRev 还能继续用,不必再 get() 一次。
1063
+ *
1064
+ * @param {{ id: string, entries: string[], dedup?: boolean, byId: string,
1065
+ * expectedRev: number, now?: number }} input
1066
+ * @returns {{ bot: object, added: number, skipped: number }}
1067
+ * @throws {RosterError} 不存在 / CAS 冲突 / 已退役 / 落盘失败
1068
+ * @throws {ProfileError} 条目超长或总量超限(不静默截断)
1069
+ */
1070
+ function addProfileMemory(input = {}) {
1071
+ const { entries, dedup, byId, now } = input ?? {}
1072
+ const current = loadForCas(input, 'addProfileMemory')
1073
+ const by = requireString(byId, 'byId', 'addProfileMemory')
1074
+ assertProfileEditable(current, 'addProfileMemory')
1075
+
1076
+ const profile = current.profile ?? {}
1077
+ const existing = Array.isArray(profile.memory) ? profile.memory : []
1078
+ const merged = importMemoryToProfile({ existing, entries, dedup: dedup !== false })
1079
+
1080
+ if (merged.added === 0) {
1081
+ return { bot: clone(current), added: 0, skipped: merged.skipped }
1082
+ }
1083
+
1084
+ const nextProfile = { ...profile, memory: merged.memory }
1085
+ const written = writeProfileFiles(current, nextProfile, 'addProfileMemory')
1086
+ const at = normalizeNow(now, 'addProfileMemory')
1087
+ const bot = commitProfileChange(current, nextProfile, by, at, written, 'profile_memory_added')
1088
+ return { bot, added: merged.added, skipped: merged.skipped }
1089
+ }
1090
+
1091
+ /**
1092
+ * 把技能**关联**到 Bot:写进 `bot.profile.skills` 并落 `.agents/skills/<名>/SKILL.md`(v2)。
1093
+ *
1094
+ * 为什么是「并集」而不是「替换」:函数名是 link(关联),语义是**追加关联**;
1095
+ * 已关联的技能不会被这次调用摘掉。要整体替换技能清单请用 setProfile({ skills })。
1096
+ *
1097
+ * 为什么技能文件写失败要抛错(strictSkills):技能能被官方 `skills.list({ cwd })` 发现
1098
+ * **全靠那个文件**;文件写不成却把名字记进 profile,等于给 Bot 一个「查无此技能」的悬空关联,
1099
+ * 比直接报错更坏(模型会一直找不到技能却不知道为什么)。
1100
+ *
1101
+ * 已关联且文件都在时:不写事件、不 bump rev(幂等,允许 UI 重复点「关联」)。
1102
+ *
1103
+ * @param {{ id: string, skills: string[], byId: string, expectedRev: number, now?: number }} input
1104
+ * @returns {object} 变更后的 Bot(无变更时返回当前记录)
1105
+ * @throws {RosterError} 不存在 / CAS 冲突 / 已退役 / 技能名非法 / 落盘失败
1106
+ */
1107
+ function linkSkills(input = {}) {
1108
+ const { skills, byId, now } = input ?? {}
1109
+ const current = loadForCas(input, 'linkSkills')
1110
+ const by = requireString(byId, 'byId', 'linkSkills')
1111
+ assertProfileEditable(current, 'linkSkills')
1112
+
1113
+ if (!Array.isArray(skills) || skills.length === 0) {
1114
+ throw new RosterError(
1115
+ `roster.linkSkills:skills 必填且不能为空(收到 ${JSON.stringify(skills)})——`
1116
+ + '空数组没有可关联对象,多半是调用方漏传参数',
1117
+ 'ROSTER_BAD_FIELD',
1118
+ { field: 'skills', where: 'linkSkills' },
1119
+ )
1120
+ }
1121
+ // 复用档案校验口径(条数 / 单项长度),避免为「关联技能」另造一套规则后与 setProfile 分叉
1122
+ const incoming = normalizeProfile({ skills }).skills ?? []
1123
+
1124
+ const profile = current.profile ?? {}
1125
+ const existing = Array.isArray(profile.skills) ? profile.skills : []
1126
+ const nextSkills = [...existing]
1127
+ let changed = false
1128
+ for (const name of incoming) {
1129
+ if (nextSkills.includes(name)) continue
1130
+ nextSkills.push(name)
1131
+ changed = true
1132
+ }
1133
+
1134
+ // 先落盘(AGENTS.md + 技能文件),成功再记事件
1135
+ const nextProfile = { ...profile, skills: nextSkills }
1136
+ const written = writeProfileFiles(current, nextProfile, 'linkSkills', { strictSkills: true })
1137
+ if (!changed) {
1138
+ // 技能清单没变(可能只是补写了缺失的技能文件)→ 不改 rev,调用方 CAS 继续有效
1139
+ return clone(current)
1140
+ }
1141
+ const at = normalizeNow(now, 'linkSkills')
1142
+ return commitProfileChange(current, nextProfile, by, at, written, 'profile_skills_linked')
861
1143
  }
862
1144
 
863
1145
  function setCwd(input = {}) {
@@ -889,6 +1171,39 @@ export function createRoster(ctx, config = {}) {
889
1171
  return commit(current.id, 'setCwd', by, at, before, next)
890
1172
  }
891
1173
 
1174
+ /**
1175
+ * 设置/清除开工准备命令(改造07 · 借鉴 Super-agent 的 prepare command)。
1176
+ *
1177
+ * prepare command 在 Bot 首次 claim 任务时执行,用于 `git worktree`、拉代码等
1178
+ * 开工前准备。失败会阻塞 claim(任务保持 pending,由上层记录原因)。
1179
+ *
1180
+ * @param {{ id: string, prepareCommand?: string|null, byId: string, now?: number }} input
1181
+ * @returns {object} Bot
1182
+ * @throws {RosterError} 已退役 / CAS 冲突
1183
+ */
1184
+ function setPrepareCommand(input = {}) {
1185
+ const { prepareCommand, byId, now } = input ?? {}
1186
+ const current = loadForCas(input, 'setPrepareCommand')
1187
+ const by = requireString(byId, 'byId', 'setPrepareCommand')
1188
+ assertNotRetired(current, 'setPrepareCommand', '设置准备命令')
1189
+
1190
+ const at = normalizeNow(now, 'setPrepareCommand')
1191
+ const before = clone(current)
1192
+ const next = clone(current)
1193
+ next.rev = current.rev + 1
1194
+ if (prepareCommand === null || prepareCommand === undefined || prepareCommand === '') {
1195
+ delete next.prepareCommand
1196
+ delete next.prepareCommandSetAt
1197
+ delete next.prepareCommandSetBy
1198
+ } else {
1199
+ const cmd = String(prepareCommand).trim()
1200
+ next.prepareCommand = cmd
1201
+ next.prepareCommandSetAt = at
1202
+ next.prepareCommandSetBy = by
1203
+ }
1204
+ return commit(current.id, 'prepare_command_set', by, at, before, next)
1205
+ }
1206
+
892
1207
  /**
893
1208
  * 取租约(§3.1 草案未列,见文件头「必要增量」第 3 条)。
894
1209
  *
@@ -1090,11 +1405,16 @@ export function createRoster(ctx, config = {}) {
1090
1405
  get,
1091
1406
  list,
1092
1407
  listRetired,
1408
+ hasSession,
1093
1409
  rename,
1094
1410
  retire,
1095
1411
  setCwd,
1412
+ setPrepareCommand,
1096
1413
  getProfile,
1097
1414
  setProfile,
1415
+ // v2 档案增强(DESIGN-v2 特性 ③):追加预制记忆 / 关联技能(都走 CAS)
1416
+ addProfileMemory,
1417
+ linkSkills,
1098
1418
  claim,
1099
1419
  release,
1100
1420
  heartbeat,