nx-kv 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/assets/groups.json +4 -0
  4. package/assets/kv-repo/SKILL.md +83 -0
  5. package/assets/nx-kv/SKILL.md +5 -0
  6. package/assets/nx-kv/references/repo-registry.md +7 -5
  7. package/assets/nx-kv/references/todo-commands.md +1 -1
  8. package/package.json +1 -1
  9. package/src/core/config.js +1 -1
  10. package/src/modules/localtask/view.jsx +6 -18
  11. package/src/modules/repos/boards.js +60 -9
  12. package/src/modules/repos/index.js +22 -5
  13. package/src/modules/repos/service.js +8 -14
  14. package/src/modules/repos/view.jsx +178 -85
  15. package/src/modules/{group/view.jsx → todo/group-popup.jsx} +171 -159
  16. package/src/modules/todo/group.js +194 -0
  17. package/src/modules/todo/index.js +7 -1
  18. package/src/modules/todo/view.jsx +14 -1
  19. package/src/runtime/registry.js +3 -2
  20. package/src/web/frontend/registry.js +2 -1
  21. package/src/web/frontend/style.css +111 -44
  22. package/src/web/public/assets/{CliHints-CrVZoRln.js → CliHints-DltGgA21.js} +1 -1
  23. package/src/web/public/assets/{index-gEMHU8gc.js → index-Cg8J6GZg.js} +2 -2
  24. package/src/web/public/assets/index-DozxuMsm.css +1 -0
  25. package/src/web/public/assets/view-CCiJUhF6.js +1 -0
  26. package/src/web/public/assets/view-CV6w4TG6.js +3 -0
  27. package/src/web/public/assets/view-bE7JPI0N.js +13 -0
  28. package/src/web/public/assets/{view-QRE2Bldq.js → view-k4YFJYVz.js} +1 -1
  29. package/src/web/public/index.html +2 -2
  30. package/src/modules/group/index.js +0 -199
  31. package/src/web/public/assets/index-D3Gh8xAs.css +0 -1
  32. package/src/web/public/assets/view-CQT74IeU.js +0 -3
  33. package/src/web/public/assets/view-DZZz-uRj.js +0 -2
  34. package/src/web/public/assets/view-KyrAiPse.js +0 -1
  35. package/src/web/public/assets/view-PJtYOdms.js +0 -10
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.4] - 2026-10-25
8
+
9
+ ### Fixed
10
+
11
+ - 本地任务页的仓库上下文只显示仓库名与用途,不再展示看板归属(列表 chip 与投递提示同步收敛)。
12
+
13
+ ## [0.11.3] - 2026-10-23 工作空间模块并入清单(子弹窗),不再单独占一个 tab
14
+
15
+ ### Changed
16
+
17
+ - **工作空间(group)不再是独立模块,也不再单独占一个面板 tab**。它本质就是
18
+ **清单的设置**——它决定清单按哪一组隔离读写,单独把它抬成一个功能域反而割裂了。
19
+ - 独立的 `group` 模块目录删除;8 条命令的 **action id / CLI 命令 / HTTP 路由 /
20
+ 参数一字未改**(`nx-kv group list|get|add|update|remove|current|use|members`、
21
+ `/api/groups*`、`/api/group`),只是住进了清单模块(`src/modules/todo/group.js`)。
22
+ - 面板顶栏少了一个「工作空间」tab(本地任务 / 清单 / 仓库 / 登录)。
23
+ 清单页工具栏里那颗原本只读的工作空间药丸**变成可点的入口**,点开是**子弹窗**:
24
+ 当前空间、切换、成员、改名、解散、新建,都在那一层里;「成员」再弹一层。
25
+ - 清单模块同时管理 `todo` 与 `group` 两个资源(`resources: ['todo','group']`),
26
+ registry 的 CRUD 完备性断言两边都仍然覆盖。
27
+
7
28
  ## [0.11.2] - 2026-10-23 仓库页改成中心清单(扁平,不分组)+ 看板只当标签
8
29
 
9
30
  ### Changed
@@ -843,7 +864,7 @@
843
864
 
844
865
  ## [0.1.0] - 2026-09-16
845
866
 
846
- 首个版本。KV 清单(todo)的 CLI + Web 面板,对接 GoFrame KV 后端(默认 `47.110.80.47:8988`)。
867
+ 首个版本。KV 清单(todo)的 CLI + Web 面板,对接 GoFrame KV 后端(默认 `192.0.2.47:8988`)。
847
868
 
848
869
  ### Added
849
870
 
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  KV 清单(todo)管理中枢。一个 `npx` 命令起一个 Web 面板,同时提供同构 CLI——
4
4
  **面板上的每个按钮,底层都是同一条 CLI 命令**,输出 `--json` 即可被任何 agent 直接消费。
5
5
 
6
- 对接 GoFrame KV 后端(默认 `http://47.110.80.47:8988`),
6
+ 对接 GoFrame KV 后端(默认 `http://192.0.2.47:8988`),
7
7
  数据落在**四把 key** 上:`todo:open` / `todo:done` / `todo:freeze` / `todo:topics`。
8
8
 
9
9
  任务正文还能直接**嵌图片与文档**(标准 markdown:`![说明](fileId)` / `[文件名](fileId)`)——
@@ -21,6 +21,10 @@
21
21
  "kv-timegoal": {
22
22
  "skills": ["kv-timegoal"],
23
23
  "summary": "把「持续运行 N 小时」任务固化到 nx-kv:每轮回显原文提示词防偏移 + 时间到点自动停手"
24
+ },
25
+ "kv-repo": {
26
+ "skills": ["kv-repo"],
27
+ "summary": "仓库组识别与关系报告——/kv-repo <组名> 一步加载看板相关项目;board/repo 查询、读并维护领域描述(中心/终端、关键路径、搜索关键词)"
24
28
  }
25
29
  }
26
30
  }
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: kv-repo
3
+ description: 识别并汇报本机仓库的相关分组(仓库组)——快捷入口 `/kv-repo <组名>`:一步到位加载该看板的成员仓库、领域描述与配合关系,输出关系报告。用 nx-kv repo / board 命令回答「这台机器上有哪些仓库、按领域怎么分组、组内谁是中心谁是终端、关键路径与搜索关键词在哪」。看板的领域描述(desc)+ 成员仓库的组内描述(note:它在组里的角色)共同构成写给 agent 读的上下文——领域侧重、仓库间配合关系(如 fr 组内 API 关联、go-ah 为 OIDC 中心其余为终端)、入口路径、搜什么关键词。触发词:/kv-repo、kv-repo、仓库组、相关仓库、这个仓库属于哪个组、组里都有什么、它们什么关系、中心/终端、领域描述、关键路径、加载 fr 的看板、看板相关项目、board list、repo list。反例:登记/扫描/增删仓库与看板用 nx-kv 主 skill 的 repo-registry;git 状态与 diff 一律找 git 自己。
4
+ ---
5
+
6
+ # kv-repo — 仓库组识别与关系报告
7
+
8
+ 回答两类问题:**「这组仓库是什么、怎么配合」**(给人看的关系报告)和
9
+ **「我现在这个任务该去哪些仓库找上下文」**(给 agent 自己用的定位)。
10
+ 数据都在 nx-kv 的仓库模块里,两条命令足够。
11
+
12
+ ## 快捷 SOP:`/kv-repo <组名|仓库名|路径>`
13
+
14
+ **带参数就是一步到位,不要反问、不要展开流程**:定位组 → 拉成员与领域描述 → 直接输出该组的关系报告。
15
+
16
+ ```bash
17
+ nx-kv board list --json
18
+ # → { boards:[{ id, name, desc, repos:[{ id, name, desc, path }] }], unassigned:[…] }
19
+ ```
20
+
21
+ - 参数是**组名**(如 `/kv-repo fr`)→ 在 `boards[]` 里按 name 找到它(找不到再试包含匹配),取该组输出报告;
22
+ - 参数是**仓库名或路径**(如 `/kv-repo go-ah`、`/kv-repo D:\code\a_go\proj\go-ah`)→ `nx-kv repo list --json` 定位仓库,报告它 `boards[]` 里的**每个**组(一个仓库可同时归入多个组);
23
+ - 本地任务的上下文若已带 repo/boards,直接用,不必重查。
24
+
25
+ 报告形状(desc 里的关键路径与关键词**原样带上**——它们是前人留下的搜索入口):
26
+
27
+ ```
28
+ 【fr】Flutter 客户端组 —— 组内共享 API 契约
29
+ fr D:\code\a_dart\prj\fr (组内)API 契约方:lib/api/ 是唯一入口,搜 fr_ 前缀
30
+ …
31
+ (领域描述)<组的 desc 全文>
32
+ ```
33
+
34
+ 每行优先读**组内描述(note)**——它讲这个仓库在本组里的角色;没写 note 再回退仓库自己的 desc。
35
+ 组领域描述(desc)与各仓库的关键路径 / 关键词**原样带上**——它们是前人留下的搜索入口。
36
+
37
+ **无参数**(`/kv-repo`):输出全部组速览——每行「组名 · 成员数 · desc 首行」+ 未分组计数,
38
+ 然后问用户要深入哪一组(只问这一次,别连环追问)。
39
+
40
+ ## 模型(30 秒版)
41
+
42
+ - **仓库登记** = 本机事实(name / path / desc / notes),一张中心清单。
43
+ - **看板** = 领域分组;一个仓库可同时归入多个组,分组只此一条路(没有标签)。
44
+ - **看板的领域描述(desc)+ 成员的组内描述(note)是写给你(agent)读的上下文**:
45
+ desc 讲这个领域的侧重与配合方式;note 讲单个仓库在组里的角色(谁是中心 / 终端、
46
+ API 关联在哪、关键路径、搜什么关键词)。**接到任务先读它们,比翻代码快。**
47
+
48
+ ## 完整命令(速查)
49
+
50
+ ```bash
51
+ nx-kv board list --json # 首选:全部组 + 成员(含组内描述 note)+ 各组领域描述
52
+ # → { boards:[{ id, name, desc, repos:[{ id, name, desc, path, note }] }], unassigned:[…] }
53
+ nx-kv repo list --json # 扁平清单,每条带 boards[](查单个仓库的归属)
54
+ nx-kv repo get <id|绝对路径> --json # 单仓库详情 + 所在组
55
+ nx-kv board get <id> --json # 单组详情(成员 + 组内描述)
56
+ nx-kv board note <id|绝对路径> --board <看板id> --note "角色/配合关系/关键路径/关键词"
57
+ # 写或清(--note 留空 = 清除)组内描述
58
+ ```
59
+
60
+ 定位顺序:拿到任务/目录 → `repo list` 按**路径或目录名**找到仓库
61
+ → 它的 `boards[]` 给出所属组 → 回 `board list` 读该组的 `desc` 与**兄弟仓库**。
62
+
63
+ ## 维护:发现过期就修(这是本 skill 的另一半职责)
64
+
65
+ 读到的 desc 与代码现实不符(路径变了、关系变了、压根没写)时,顺手补上:
66
+
67
+ ```bash
68
+ nx-kv board update <id> --desc "侧重…;谁配合谁(谁是中心/终端、API 关联在哪);关键路径…;搜索关键词…"
69
+ nx-kv board note <仓库id|路径> --board <看板id> --note "这个仓库在组里的角色…;关键路径…;搜什么"
70
+ ```
71
+
72
+ 写领域描述的约定(一个看板一个领域,侧重各不相同):
73
+
74
+ 1. **一句话侧重**:这组仓库共同在做什么;
75
+ 2. **配合关系**:谁是中心、谁是终端、谁调谁的 API(如 fr 组内 API 关联、go-ah 与其接入方)——组级关系写看板 desc,单个仓库的角色写 board note;
76
+ 3. **关键路径**:契约文件 / 配置 / 入口代码的位置(如 `lib/api/`、`issuer 192.0.2.47:8082`);
77
+ 4. **搜索关键词**:下次 agent 该搜什么(如 `fr_` 前缀、模块名)。
78
+
79
+ ## 边界
80
+
81
+ - 本 skill **只读分组与描述**;登记/扫描/增删仓库看板走 nx-kv 主 skill(repo-registry)。
82
+ - 不碰 git:状态 / diff / pull / push 一律直接用 git 自己。
83
+ - 数据按 groupId 隔离,查询前先确认工作空间对不对(`nx-kv group current`)。
@@ -34,6 +34,11 @@ nx-kv group current # 当前工作空间
34
34
  数据按 `groupId` 隔离。**先确认工作空间对不对**——在错的空间里会「什么都查不到」,
35
35
  而那不是「没有任务」,是「看错地方了」。
36
36
 
37
+ > 工作空间是**清单的设置**(不是独立功能域):面板上没有单独的「工作空间」页,
38
+ > 入口在**清单页**工具栏里那颗可点的工作空间药丸 —— 点开是子弹窗,
39
+ > 切换 / 成员 / 改名 / 解散 / 新建都在那儿。命令与路由一字未改
40
+ > (`nx-kv group ...`、`/api/groups*`)。
41
+
37
42
  ## 最常用的四条
38
43
 
39
44
  ```bash
@@ -6,8 +6,9 @@
6
6
 
7
7
  - 用户问「这台机器上有哪些仓库 / 某个仓库在哪」
8
8
  - 需要把某个目录登记为受管仓库,或扫描某目录批量发现仓库
9
- - 需要维护仓库的元信息:名称、描述、标签、备注,或改登记路径
9
+ - 需要维护仓库的元信息:名称、描述、备注,或改登记路径
10
10
  - **需要 git 状态、差异、拉取、推送时不要进这里**——本工具不提供 git 操作(见下)
11
+ - **只查分组与关系(不改动)时用 [[kv-repo]]**——那是仓库组的查询/报告侧
11
12
 
12
13
  ## 边界:本工具不管 git
13
14
 
@@ -35,7 +36,6 @@ git -C <path> pull --no-edit
35
36
  "id": "r_xxx",
36
37
  "name": "ext",
37
38
  "path": "D:\\a_js\\js_proj\\br_controller\\ext",
38
- "tags": ["eco-import"],
39
39
  "desc": "对外扩展模块",
40
40
  "notes": "",
41
41
  "addedAt": "…",
@@ -44,8 +44,10 @@ git -C <path> pull --no-edit
44
44
  ```
45
45
 
46
46
  - **id 或绝对路径**都能定位仓库(agent 直接给路径更省事)
47
- - tags 是自由标签数组,用于分组
48
47
  - 路径不必是 git 仓库:登记不校验目录内容,也不要求目录存在
48
+ - **没有标签**:分组只走看板一条路(2026-10-24 废除仓库 tags,两套平行分组只会打架)
49
+ - **看板的 desc 是领域描述**:写该组仓库的侧重、配合关系(谁是中心/终端)、
50
+ 关键路径与搜索关键词——它是给人和 agent 读的上下文,写法约定见 [[kv-repo]]
49
51
 
50
52
  ## SOP
51
53
 
@@ -59,7 +61,7 @@ nx-kv repo list --json # 程序消费
59
61
  ### 2. 登记
60
62
 
61
63
  ```bash
62
- nx-kv repo add <path> [--name N] [--desc D] [--tags a,b] [--notes T]
64
+ nx-kv repo add <path> [--name N] [--desc D] [--notes T]
63
65
  ```
64
66
 
65
67
  - `name` 缺省取目录名
@@ -80,7 +82,7 @@ nx-kv repo scan [root] [--depth 3] [--dry-run] # 不带 --dry-run 会真的全
80
82
 
81
83
  ```bash
82
84
  nx-kv repo get <id|path> [--json]
83
- nx-kv repo update <id> [--name N] [--desc D] [--tags a,b] [--notes T] [--path P]
85
+ nx-kv repo update <id> [--name N] [--desc D] [--notes T] [--path P]
84
86
  ```
85
87
 
86
88
  - `update --path` 用来纠正登记错位的目录;改到**已被别的记录占用**的路径会显式报错
@@ -7,7 +7,7 @@
7
7
 
8
8
  1. **命令就绪**:`nx-kv` 在可执行路径上(`npx nx-kv` 亦可)
9
9
  2. **登录态**:`nx-kv auth login <email>`,token 存 `~/.nx-kv/config.json`
10
- 3. **后端可达**:默认 `http://47.110.80.47:8988`;用 `--baseUrl` 临时覆盖,或写进配置
10
+ 3. **后端可达**:默认 `http://192.0.2.47:8988`;用 `--baseUrl` 临时覆盖,或写进配置
11
11
 
12
12
  所有清单操作都需要登录,否则报「未登录」并 exit 1。
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@ export function configPathFromEnv() {
14
14
  return process.env.NX_KV_CONFIG || CONFIG_PATH;
15
15
  }
16
16
 
17
- export const DEFAULT_BASE_URL = 'http://47.110.80.47:8988';
17
+ export const DEFAULT_BASE_URL = 'http://192.0.2.47:8988';
18
18
 
19
19
  const EMPTY = () => ({
20
20
  baseUrl: process.env.NX_KV_BASE_URL || DEFAULT_BASE_URL,
@@ -850,21 +850,14 @@ export default function SessionTaskView() {
850
850
  <strong>{row.name}</strong>
851
851
  {row.isGlobal && <span className="tag badge">全局池</span>}
852
852
  {t?.manual && <span className="tag">已登记</span>}
853
- {/* 仓库上下文(= 周围联系):这个目录属于哪个已登记仓库、
854
- 它又关联着哪些看板。一枚 chip,不占额外一行。 */}
853
+ {/* 仓库上下文:这个目录属于哪个已登记仓库。一枚 chip 一行不占。
854
+ 看板归属不在本地任务页显示(太吵)——要看去「仓库」页。 */}
855
855
  {t?.repo && (
856
856
  <span
857
857
  className="tag repo-boards"
858
- title={
859
- `仓库「${t.repo.name}」${t.repo.desc ? ':' + t.repo.desc : ''}` +
860
- (t.repo.boards?.length
861
- ? `\n所属看板:${t.repo.boards.map((b) => b.name).join('、')}`
862
- : '\n(未归入任何看板)')
863
- }
858
+ title={`仓库「${t.repo.name}」${t.repo.desc ? ':' + t.repo.desc : ''}`}
864
859
  >
865
- {t.repo.boards?.length
866
- ? t.repo.boards.map((b) => b.name).join(' · ')
867
- : t.repo.name}
860
+ {t.repo.name}
868
861
  </span>
869
862
  )}
870
863
  {row.cwd && cwdTail.length > 0 && (
@@ -1098,17 +1091,12 @@ export default function SessionTaskView() {
1098
1091
 
1099
1092
  {active && showQueue && (
1100
1093
  <>
1101
- {/* 仓库上下文提示:投递到这个目录时,先告诉用户「这是哪个仓库、
1102
- 周围关联哪些看板」——写任务时不用再切到「仓库」页去查。 */}
1094
+ {/* 仓库上下文提示:投递到这个目录时,先告诉用户「这是哪个仓库、干什么用」——
1095
+ 写任务时不用再切到「仓库」页去查。看板归属不在这里显示。 */}
1103
1096
  {active.repo && (
1104
1097
  <div className="compose-context">
1105
1098
  <span className="repo-name">仓库 {active.repo.name}</span>
1106
1099
  {active.repo.desc ? <span className="repo-desc">{active.repo.desc}</span> : null}
1107
- {active.repo.boards?.length ? (
1108
- <span className="repo-boards-list">
1109
- 看板:{active.repo.boards.map((b) => b.name).join('、')}
1110
- </span>
1111
- ) : null}
1112
1100
  </div>
1113
1101
  )}
1114
1102
  <div className="toolbar compose-toolbar">
@@ -3,21 +3,31 @@
3
3
  // 与 repo 登记的关系:看板只存 id 引用,登记数据(name/desc/path…)的唯一真相在 s.repos——
4
4
  // 删看板不动登记,删登记要同步摘引用(见 service.js 的 removeRepo)。
5
5
  // 本文件不含传输层概念(不认识 argv 与 HTTP)。
6
+ import { resolve } from 'node:path';
6
7
  import { loadStore, mutateStore, newId } from './service.js';
7
8
  import { badInput, notFound, conflict } from '../../core/errors.js';
8
9
 
9
10
  // 卡片投影:面板方格与 CLI 速览共用的最小字段
10
- const card = (r) => ({ id: r.id, name: r.name, desc: r.desc, path: r.path, tags: r.tags || [] });
11
+ const card = (r) => ({ id: r.id, name: r.name, desc: r.desc, path: r.path });
11
12
 
12
13
  const cleanText = (v) => String(v || '').trim();
13
14
 
15
+ // 组内描述:同一个仓库在不同看板里的角色可以各不相同(「fr 组里它是 API 契约方」,
16
+ // 「nx 组里它只是同族工具」),所以描述挂在**看板的成员关系**上(repoNotes),
17
+ // 而不是仓库登记上。登记里的 desc 回答「这个仓库是干什么的」,
18
+ // 组内描述回答「它在这个组里占什么位置、怎么配合」。
19
+ const noteOf = (b, rid) => (b.repoNotes || {})[rid] || '';
20
+
14
21
  export async function listBoards() {
15
22
  const s = await loadStore();
16
23
  const inBoard = new Set(s.boards.flatMap((b) => b.repoIds || []));
17
- const reposOf = (ids) =>
18
- ids.map((id) => s.repos.find((r) => r.id === id)).filter(Boolean).map(card);
24
+ const reposOf = (b) =>
25
+ (b.repoIds || [])
26
+ .map((id) => s.repos.find((r) => r.id === id))
27
+ .filter(Boolean)
28
+ .map((r) => ({ ...card(r), note: noteOf(b, r.id) }));
19
29
  return {
20
- boards: s.boards.map((b) => ({ id: b.id, name: b.name, desc: b.desc, repos: reposOf(b.repoIds || []) })),
30
+ boards: s.boards.map((b) => ({ id: b.id, name: b.name, desc: b.desc, repos: reposOf(b) })),
21
31
  unassigned: s.repos.filter((r) => !inBoard.has(r.id)).map(card),
22
32
  };
23
33
  }
@@ -26,7 +36,10 @@ export async function getBoard(id) {
26
36
  const s = await loadStore();
27
37
  const b = s.boards.find((x) => x.id === id);
28
38
  if (!b) throw notFound('看板不存在: ' + id);
29
- const repos = (b.repoIds || []).map((rid) => s.repos.find((r) => r.id === rid)).filter(Boolean).map(card);
39
+ const repos = (b.repoIds || [])
40
+ .map((rid) => s.repos.find((r) => r.id === rid))
41
+ .filter(Boolean)
42
+ .map((r) => ({ ...card(r), note: noteOf(b, r.id) }));
30
43
  return { id: b.id, name: b.name, desc: b.desc, repos };
31
44
  }
32
45
 
@@ -87,12 +100,15 @@ export async function assignRepo({ id, board, before, remove } = {}) {
87
100
  target.repoIds.splice(at, 1);
88
101
  n = 1;
89
102
  }
103
+ // 摘出成员时组内描述一起带走(它只对这个成员关系有意义)
104
+ if (target.repoNotes && n) delete target.repoNotes[id];
90
105
  } else {
91
106
  for (const b of s.boards) {
92
107
  const at = (b.repoIds || []).indexOf(id);
93
108
  if (at >= 0) {
94
109
  b.repoIds.splice(at, 1);
95
110
  n += 1;
111
+ if (b.repoNotes) delete b.repoNotes[id];
96
112
  }
97
113
  }
98
114
  }
@@ -115,6 +131,32 @@ export async function assignRepo({ id, board, before, remove } = {}) {
115
131
  });
116
132
  }
117
133
 
134
+ // 设置组内描述:id = 仓库(id 或绝对路径均可),board = 看板 id。
135
+ // 只对**已在板内**的成员有意义——不是成员就显式报错,别静默挂到没人读的地方。
136
+ // note 为空 = 清除。
137
+ export async function setBoardRepoNote({ id, board, note } = {}) {
138
+ if (!id) throw badInput('缺少仓库 id 或路径');
139
+ if (!board) throw badInput('缺少看板 id(--board)');
140
+ return mutateStore((s) => {
141
+ let repo = s.repos.find((r) => r.id === id);
142
+ if (!repo) {
143
+ const p = resolve(String(id));
144
+ repo = s.repos.find((r) => r.path.toLowerCase() === p.toLowerCase());
145
+ }
146
+ if (!repo) throw notFound('repo 不存在: ' + id);
147
+ const b = s.boards.find((x) => x.id === board);
148
+ if (!b) throw notFound('看板不存在: ' + board);
149
+ if (!(b.repoIds || []).includes(repo.id)) {
150
+ throw conflict(`仓库「${repo.name}」不在看板「${b.name}」里——组内描述只对成员有意义`);
151
+ }
152
+ const text = String(note || '').trim();
153
+ if (!b.repoNotes) b.repoNotes = {};
154
+ if (text) b.repoNotes[repo.id] = text;
155
+ else delete b.repoNotes[repo.id];
156
+ return { id: repo.id, name: repo.name, board: b.id, boardName: b.name, note: text };
157
+ });
158
+ }
159
+
118
160
  // 导入:一段 JSON = 一批主题。{boards:[{name, desc?, repos:[{path|name, desc?}]}]}
119
161
  // 条目按 path(大小写不敏感)或名称匹配**已登记**仓库——导入不硬造登记,
120
162
  // 匹配不上的如实报告(数量与清单)。同名看板 = 合并(追加匹配到的仓库)。
@@ -162,13 +204,18 @@ export async function importBoards({ json } = {}) {
162
204
  b.repoIds.push(id);
163
205
  matched += 1;
164
206
  }
207
+ const note = cleanText(e.note);
208
+ if (note) {
209
+ if (!b.repoNotes) b.repoNotes = {};
210
+ b.repoNotes[id] = note;
211
+ }
165
212
  }
166
213
  }
167
214
  return { created, merged, matched, unmatched };
168
215
  });
169
216
  }
170
217
 
171
- // 导出:看板 + 仓库的 path/name/desc(可回灌给 import 的同一结构)
218
+ // 导出:看板 + 仓库的 path/name/desc + 组内描述 note(可回灌给 import 的同一结构)
172
219
  export async function exportBoards() {
173
220
  const s = await loadStore();
174
221
  return {
@@ -177,9 +224,13 @@ export async function exportBoards() {
177
224
  name: b.name,
178
225
  desc: b.desc,
179
226
  repos: (b.repoIds || [])
180
- .map((id) => s.repos.find((r) => r.id === id))
181
- .filter(Boolean)
182
- .map((r) => ({ path: r.path, name: r.name, desc: r.desc })),
227
+ .map((id) => {
228
+ const r = s.repos.find((x) => x.id === id);
229
+ if (!r) return null;
230
+ const note = noteOf(b, id);
231
+ return note ? { path: r.path, name: r.name, desc: r.desc, note } : { path: r.path, name: r.name, desc: r.desc };
232
+ })
233
+ .filter(Boolean),
183
234
  })),
184
235
  };
185
236
  }
@@ -42,7 +42,7 @@ function renderRepoList(list) {
42
42
  if (!list.length) return '(暂无仓库,用 repo add <path> 登记)';
43
43
  const lines = [`共 ${list.length} 个仓库`];
44
44
  for (const r of list) {
45
- lines.push(`${r.name.padEnd(18)} ${r.path} ${r.tags.length ? '[' + r.tags.join(',') + ']' : ''}`);
45
+ lines.push(`${r.name.padEnd(18)} ${r.path}`);
46
46
  }
47
47
  return lines.join('\n');
48
48
  }
@@ -52,10 +52,12 @@ function renderRepo(r) {
52
52
  `${r.name}${r.desc ? ' ' + r.desc : ''}`,
53
53
  ` id: ${r.id}`,
54
54
  ` 路径: ${r.path}`,
55
- ` 标签: ${r.tags.length ? r.tags.join(', ') : '-'}`,
56
55
  ` 登记: ${(r.addedAt || '').slice(0, 10)}`,
57
56
  ` 更新: ${(r.updatedAt || '').slice(0, 10)}`,
58
57
  ];
58
+ if (r.boards?.length) {
59
+ lines.push(` 看板: ${r.boards.map((b) => b.name).join('、')}`);
60
+ }
59
61
  if (r.notes) lines.push(' 备注: ' + r.notes);
60
62
  return lines.join('\n');
61
63
  }
@@ -100,7 +102,6 @@ export default {
100
102
  flags: {
101
103
  name: { type: 'string' },
102
104
  desc: { type: 'string' },
103
- tags: { type: 'array' },
104
105
  notes: { type: 'string' },
105
106
  },
106
107
  run: (ctx) => service.addRepo(ctx),
@@ -110,13 +111,12 @@ export default {
110
111
  id: 'repo.update',
111
112
  cli: ['repo', 'update'],
112
113
  http: ['PATCH', '/api/repos/:id'],
113
- summary: '修改仓库路径 / 名称 / 描述 / 标签 / 备注',
114
+ summary: '修改仓库路径 / 名称 / 描述 / 备注',
114
115
  args: ['id'],
115
116
  flags: {
116
117
  path: { type: 'string' },
117
118
  name: { type: 'string' },
118
119
  desc: { type: 'string' },
119
- tags: { type: 'array' },
120
120
  notes: { type: 'string' },
121
121
  },
122
122
  run: (ctx) => service.updateRepo(ctx.id, ctx),
@@ -235,6 +235,23 @@ export default {
235
235
  ? `已移出: ${d.name}(${d.count} 个看板)`
236
236
  : `${d.added ? '已聚合' : '已在(位次已调)'}: ${d.name} → ${d.board}`),
237
237
  },
238
+ {
239
+ // 组内描述:同一个仓库在不同看板里的角色各不相同,描述挂在成员关系上而非登记上。
240
+ // 登记的 desc = 「这个仓库是干什么的」;组内描述 = 「它在这个组里占什么位置、怎么配合」。
241
+ id: 'board.note',
242
+ cli: ['board', 'note'],
243
+ http: ['POST', '/api/boards/note'],
244
+ summary: '写/清组内描述:仓库在看板里的角色与配合关系(--note 留空 = 清除;仓库给 id 或绝对路径)',
245
+ args: ['id'],
246
+ flags: {
247
+ board: { type: 'string', required: true, hint: '看板 id' },
248
+ note: { type: 'string', hint: '组内描述(多行可含关键路径/关键词;留空 = 清除)' },
249
+ },
250
+ run: (ctx) => boards.setBoardRepoNote({ id: ctx.id, board: ctx.board, note: ctx.note }),
251
+ render: (d) => (d.note
252
+ ? `已写组内描述: ${d.name} @ 「${d.boardName}」`
253
+ : `已清除组内描述: ${d.name} @ 「${d.boardName}」`),
254
+ },
238
255
  {
239
256
  id: 'board.import',
240
257
  cli: ['board', 'import'],
@@ -80,19 +80,14 @@ export function newId(prefix) {
80
80
  return prefix + '_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
81
81
  }
82
82
 
83
- function parseTags(tags) {
84
- if (Array.isArray(tags)) return tags.map((t) => String(t).trim()).filter(Boolean);
85
- if (typeof tags === 'string') return tags.split(',').map((t) => t.trim()).filter(Boolean);
86
- return [];
87
- }
88
-
89
- function normalizeRepoInput({ path, name, tags, notes, desc }) {
83
+ // 标签已废除(2026-10-24):分组只走看板一条路,两套平行的分组机制只会互相打架。
84
+ // 旧数据里残留的 tags 字段留在盘上不动,但不再写入、不再外发。
85
+ function normalizeRepoInput({ path, name, notes, desc }) {
90
86
  const abs = resolve(String(path || ''));
91
87
  return {
92
88
  id: newId('r'),
93
89
  name: (name || basename(abs) || 'repo').trim(),
94
90
  path: abs,
95
- tags: parseTags(tags),
96
91
  desc: (desc || '').trim(),
97
92
  notes: (notes || '').trim(),
98
93
  addedAt: new Date().toISOString(),
@@ -119,11 +114,11 @@ export async function listRepos() {
119
114
  for (const b of s.boards) {
120
115
  for (const rid of b.repoIds || []) {
121
116
  if (!boardsOf.has(rid)) boardsOf.set(rid, []);
122
- boardsOf.get(rid).push({ id: b.id, name: b.name, desc: b.desc || '' });
117
+ boardsOf.get(rid).push({ id: b.id, name: b.name, desc: b.desc || '', note: (b.repoNotes || {})[rid] || '' });
123
118
  }
124
119
  }
125
120
  return s.repos
126
- .map((r) => ({ ...r, boards: boardsOf.get(r.id) || [] }))
121
+ .map(({ tags: _dropped, ...r }) => ({ ...r, boards: boardsOf.get(r.id) || [] }))
127
122
  .sort((a, b) => String(a.name).localeCompare(String(b.name)));
128
123
  }
129
124
 
@@ -144,7 +139,6 @@ export async function updateRepo(id, patch) {
144
139
  const r = s.repos.find((x) => x.id === id);
145
140
  if (!r) throw notFound('repo 不存在: ' + id);
146
141
  if (patch.name !== undefined) r.name = String(patch.name).trim();
147
- if (patch.tags !== undefined) r.tags = parseTags(patch.tags);
148
142
  if (patch.desc !== undefined) r.desc = String(patch.desc).trim();
149
143
  if (patch.notes !== undefined) r.notes = String(patch.notes);
150
144
  if (patch.path !== undefined) {
@@ -183,7 +177,8 @@ export async function getRepo(idOrPath) {
183
177
  const boards = s.boards
184
178
  .filter((b) => (b.repoIds || []).includes(r.id))
185
179
  .map((b) => ({ id: b.id, name: b.name, desc: b.desc || '' }));
186
- return { ...r, boards };
180
+ const { tags: _dropped, ...base } = r;
181
+ return { ...base, boards };
187
182
  }
188
183
 
189
184
  const SKIP_DIRS = new Set([
@@ -320,7 +315,7 @@ export async function contextMap() {
320
315
  for (const b of s.boards) {
321
316
  for (const rid of b.repoIds || []) {
322
317
  if (!boardsOf.has(rid)) boardsOf.set(rid, []);
323
- boardsOf.get(rid).push({ id: b.id, name: b.name, desc: b.desc || '' });
318
+ boardsOf.get(rid).push({ id: b.id, name: b.name, desc: b.desc || '', note: (b.repoNotes || {})[rid] || '' });
324
319
  }
325
320
  }
326
321
  const map = new Map();
@@ -329,7 +324,6 @@ export async function contextMap() {
329
324
  id: r.id,
330
325
  name: r.name,
331
326
  path: r.path,
332
- tags: r.tags || [],
333
327
  desc: r.desc || '',
334
328
  notes: r.notes || '',
335
329
  boards: boardsOf.get(r.id) || [],