nx-kv 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +16 -3
- package/package.json +1 -1
- package/src/core/command-table.js +22 -0
- package/src/modules/bundled/index.js +0 -1
- package/src/modules/docs/index.js +1 -1
- package/src/modules/docs/knowledge.js +23 -16
- package/src/modules/docs/links.js +1 -1
- package/src/modules/file/index.js +0 -1
- package/src/modules/localtask/board-target.js +161 -0
- package/src/modules/localtask/index.js +99 -10
- package/src/modules/localtask/service.js +12 -5
- package/src/modules/repos/boards.js +198 -9
- package/src/modules/repos/index.js +38 -1
- package/src/modules/repos/transfer.js +7 -0
- package/src/modules/setting/index.js +0 -1
- package/src/modules/system/index.js +8 -70
- package/src/modules/system/routes.js +71 -0
- package/src/modules/tasktool/add.js +113 -0
- package/src/modules/tasktool/attach.js +182 -0
- package/src/modules/tasktool/get.js +5 -1
- package/src/modules/tasktool/groupctx.js +21 -9
- package/src/modules/tasktool/index.js +2 -3
- package/src/modules/tasktool/wait.js +27 -2
- package/src/modules/templates/index.js +1 -1
- package/src/modules/todo/index.js +0 -1
- package/src/modules/todo/service.js +1 -1
- package/src/runtime/cli.js +37 -17
- package/src/runtime/registry.js +17 -4
- package/src/runtime/spec.js +14 -0
- package/src/shared/frontmatter.js +80 -0
- package/src/{modules/docs → shared}/wikilink.js +3 -2
- package/src/web/frontend/components/board/member-picker.jsx +257 -0
- package/src/web/frontend/components/cloud/markdown.jsx +1 -1
- package/src/web/frontend/components/md/attachments.js +48 -4
- package/src/web/frontend/components/md/md-panes.jsx +15 -69
- package/src/web/frontend/components/md/sel-menu.jsx +118 -26
- package/src/web/frontend/components/md/tool-bar.jsx +1 -1
- package/src/web/frontend/components/migrate/use-move.js +1 -1
- package/src/web/frontend/components/pager.jsx +71 -0
- package/src/web/frontend/components/sidebar/index.jsx +17 -19
- package/src/web/frontend/components/sidebar/model.js +13 -14
- package/src/web/frontend/components/sidebar/row-menu.jsx +0 -1
- package/src/web/frontend/components/sidebar/task-children.jsx +30 -5
- package/src/{modules/localtask/board.js → web/frontend/modules/board/board-view.js} +35 -4
- package/src/web/frontend/modules/board/reopen-text.js +67 -0
- package/src/web/frontend/modules/md/doc-store.js +6 -1
- package/src/web/frontend/modules/md/md-parse.js +145 -27
- package/src/web/frontend/modules/md/style.css +179 -61
- package/src/web/frontend/page/cloud/index.jsx +0 -2
- package/src/web/frontend/page/dir/index.jsx +64 -6
- package/src/web/frontend/page/dir/landing.jsx +56 -28
- package/src/web/frontend/page/dir/task.jsx +340 -57
- package/src/web/frontend/page/doc/index.jsx +1 -1
- package/src/web/frontend/page/group/index.jsx +129 -82
- package/src/web/frontend/page/overview/index.jsx +1 -1
- package/src/web/frontend/page/progress/index.jsx +10 -6
- package/src/web/frontend/page/repos/index.jsx +151 -102
- package/src/web/frontend/page/settings/index.jsx +0 -10
- package/src/web/frontend/page/settings/style.css +1 -1
- package/src/web/frontend/panels.jsx +10 -1
- package/src/web/frontend/prefs.js +4 -4
- package/src/web/frontend/registry.js +9 -5
- package/src/web/frontend/style-v2.css +184 -44
- package/src/web/frontend/style.css +85 -38
- package/src/web/public/assets/{Introduction-CZvmavXZ.js → Introduction-DdUm5Il3.js} +1 -1
- package/src/web/public/assets/cloud-CRC00qO-.js +3 -0
- package/src/web/public/assets/dir-CrUiP4aQ.js +3 -0
- package/src/web/public/assets/group-CYVEZpI9.js +1 -0
- package/src/web/public/assets/{index-D7TgK5c2.js → index-Did6XKz0.js} +11 -9
- package/src/web/public/assets/index-pgxfM8xe.css +1 -0
- package/src/web/public/assets/md-history-rfsLK89W.js +4 -0
- package/src/web/public/assets/member-picker-BGUlAVgA.js +1 -0
- package/src/web/public/assets/overview-BFoqWgXk.js +1 -0
- package/src/web/public/assets/progress-Df-DZ6uP.js +3 -0
- package/src/web/public/assets/reopen-text-DSB76_rF.js +5 -0
- package/src/web/public/assets/repos-Bvg8dLSB.js +11 -0
- package/src/web/public/assets/settings-DM779jcF.css +1 -0
- package/src/web/public/assets/{settings-xaB1ARF_.js → settings-DtmMN_KG.js} +1 -1
- package/src/web/public/assets/task-D5YReySK.js +2 -0
- package/src/web/public/assets/templates-BLGE6arn.js +2 -0
- package/src/web/public/assets/upload-DLBQnUn4.js +1 -0
- package/src/web/public/index.html +2 -2
- package/src/modules/localtask/use-cloud-dir.js +0 -74
- package/src/web/frontend/components/CliHints.jsx +0 -25
- package/src/web/public/assets/CliHints-BDC8qSBg.js +0 -1
- package/src/web/public/assets/board-BBYw95q0.js +0 -1
- package/src/web/public/assets/cloud-DDTf0Ljk.js +0 -3
- package/src/web/public/assets/dir-Bauem8qN.js +0 -3
- package/src/web/public/assets/doc-store-CRH31l1Q.js +0 -1
- package/src/web/public/assets/group-BWeJJ-4E.js +0 -1
- package/src/web/public/assets/index-BzFIjdgF.css +0 -1
- package/src/web/public/assets/md-history-DbrRw05Z.js +0 -4
- package/src/web/public/assets/overview-DuQqT7Sm.js +0 -1
- package/src/web/public/assets/progress-CcuQs9HB.js +0 -3
- package/src/web/public/assets/repos-CR5N8i-H.js +0 -10
- package/src/web/public/assets/settings-UGe4AuO-.css +0 -1
- package/src/web/public/assets/task-1grt3vdp.js +0 -2
- package/src/web/public/assets/templates-CRf1uhuz.js +0 -2
- /package/src/{modules/todo → shared}/attach.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.16.0] - 2026-11-04 占位成员可救可清 + 仓库页可读性 + 前后端分层解耦
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`board rekey <from> --to <仓库>`**:把组里的**占位 key** 重绑到本机仓库——
|
|
10
|
+
改的是**关系**(`repoKeys`),仓库登记与 key 不动;组内描述(`repoNotes`)与
|
|
11
|
+
组内文档绑定(`repoDocs`)跟着 key 搬。不给 `--board` = 全组一起重绑
|
|
12
|
+
(假 key 是身份问题,只修一个组会留下另一半残局)。目标已在组里则合并。
|
|
13
|
+
- **`board prune [--board] [--all] [--apply]`**:清理占位成员。默认只清**无来源**的
|
|
14
|
+
(没有远程地址 → 克隆不回来 → 永远转不了正),带 remote 的加 `--all` 才清;
|
|
15
|
+
默认预览,`--apply` 落盘。
|
|
16
|
+
- **占位成员带线索**:导入布局时把对方的 `name / remote / desc / type` 记进
|
|
17
|
+
`board.repoHints`,占位从此能显示名字、能「克隆到本地」;`board export` 也不再
|
|
18
|
+
丢掉占位(以前导出的看板比屏幕上少人)。
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **修掉「用本机仓库接管」的方向错误**:它原先把**本机仓库的 key** 改成占位 key
|
|
23
|
+
(`local/xxx`),真实身份被降级并随导出传播。现在改为重绑组里的 key,登记不动。
|
|
24
|
+
- **仓库页可读性**:正文字号整体提一档(表格 13.5px、描述两行、路径 12px 等宽),
|
|
25
|
+
待初始化区改成「一条占位一张卡」:名字 / 来源组 / key 分层,三个出口
|
|
26
|
+
(绑定到候选仓库 / 克隆 / 移除)+ 批量清理。
|
|
27
|
+
|
|
28
|
+
### Changed(架构:前后端彻底解耦)
|
|
29
|
+
|
|
30
|
+
- **模块声明里不再有 `view`**。`src/modules/<x>/index.js` 曾经写着
|
|
31
|
+
`view: () => import('../../web/frontend/page/x/index.jsx')`——后端声明里出现前端文件
|
|
32
|
+
路径,前端换个目录就要改后端代码;而 `localtask` 那条 `import('./view.jsx')` 指向的
|
|
33
|
+
文件**早就不存在**(没人读它,所以坏了也没报错)。视图现在只在前端
|
|
34
|
+
`web/frontend/registry.js` 登记,后端对自己的面板一无所知。
|
|
35
|
+
- **新增同构层 `src/shared/`**:`attach.js`(附件语法)、`wikilink.js`(双链)、
|
|
36
|
+
`frontmatter.js`(任务头)以前长在后端模块里,却被前端直接 import(12 处)——
|
|
37
|
+
等于浏览器包依赖后端的目录结构。现在只有这一层能同时被两端加载,且**零依赖**
|
|
38
|
+
(不许 `node:`、不许第三方包)。
|
|
39
|
+
- **两个纯前端文件归位**:`localtask/board.js`(显示序 / 队列拆分,7 处前端引用)、
|
|
40
|
+
`localtask/reopen-text.js`(重开正文拼装)没有任何后端消费方,移到
|
|
41
|
+
`web/frontend/modules/board/`;顺手删掉同目录里没人引用的 `use-cloud-dir.js`。
|
|
42
|
+
- **`routes` 改成平台命令**:它描述的是 `ALL_COMMANDS` 本身,只有 runtime 拿得到。
|
|
43
|
+
以前 system 模块为它 `await import('../../runtime/cli.js')`(分层倒置),现在逻辑
|
|
44
|
+
留在 `modules/system/routes.js`(纯函数,表由调用方传入),命令挂在 `cli.js` 的
|
|
45
|
+
BUILTINS 上;`bootstrap` 下发的命令表改读 `core/command-table.js` 的快照。
|
|
46
|
+
- **边界写成机器检查**:`eslint.config.js` 新增 modules→web / modules→runtime /
|
|
47
|
+
shared 零依赖 / 模块内禁 react 四类规则;新增 `tests/unit/layering.test.mjs`,
|
|
48
|
+
解析真实 import 路径比对层次(`web` 只能看到 `web + shared`),并断言后端目录里
|
|
49
|
+
没有 `.jsx`、模块不再声明 `view`。
|
|
50
|
+
|
|
51
|
+
## [0.15.0] - 2026-11-04 工作组两级口径(任务带归属)+ 划线评论与完成验收闭环 + 任务附件 + 队列显示序与分页
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **任务带工作组归属(--board / --project)**:`localtask add --project <项目>` 让任务
|
|
56
|
+
挂到某个项目上(落点目录由归属项目的登记路径推导,任务正文自动写 frontmatter:
|
|
57
|
+
`board` / `project` + 双方介绍**引用式**字段);`--board <项目组>` 要求归属项目必须是
|
|
58
|
+
该组成员。改文案(`update --text` / `reopen`)不丢 frontmatter;正文自带 frontmatter
|
|
59
|
+
时同样认归属(fail-open:补介绍引用,落点跟到项目队列;显式给了 `--cwd` 就不挪窝)。
|
|
60
|
+
- 领到组内任务时,交付 `instruction` 封装**项目组上下文**:组定位、组介绍、本项目
|
|
61
|
+
位置、兄弟成员角色、组绑定参考文件的可读路径(`task.text` 保持原文)。
|
|
62
|
+
- CLI、HTTP、面板提交、远程清单导入同走一条 `submitTask`(tasktool/add.js)——
|
|
63
|
+
落点判定只此一份实现。
|
|
64
|
+
|
|
65
|
+
- **任务附件**:`localtask attach` 给任务追加附件(--path 上传本机文件,或 --fileId
|
|
66
|
+
追加已上传附件,以 markdown 嵌入正文末尾,与云端 todo 同构);`localtask attachments`
|
|
67
|
+
列出某条任务引用的附件与本地是否已有。`get` / `wait` / `show` 的交付自带附件清单与
|
|
68
|
+
本地绝对路径(自动下载跟随本机设置;标注/同步 fail-open,零附件时载荷不变)。
|
|
69
|
+
面板编辑器贴图即嵌附件(图床优先,图床不可达时退回本地旧路)。
|
|
70
|
+
|
|
71
|
+
- **划线评论 → 直接提交成任务**:MD 工作台划选正文 → 「评论」→ 提问**直接提交**成
|
|
72
|
+
新任务(不再先开编辑页),并**整块继承原任务的 frontmatter**——原任务属于某工作组,
|
|
73
|
+
评论出的新任务也归该组,组面板「本组任务」里能看见。
|
|
74
|
+
|
|
75
|
+
- **完成任务的验收闭环**:任务完成后给出 agent 的完成总结(ack)+ 验收条——
|
|
76
|
+
**通行**(验收通过)/ **重做**(评论模式下带反馈重开,反馈加粗进正文)/
|
|
77
|
+
**提详细问题**(划线评论生成新任务)。重开时正文自动追加**上一轮完成总结**与
|
|
78
|
+
**验收反馈**两段(`modules/localtask/reopen-text.js` 唯一实现:任务页评论框与
|
|
79
|
+
列表页行内重做共用;同一份总结不追加两遍)。
|
|
80
|
+
|
|
81
|
+
- **队列显示序与分页**:显示序改「**未完成(待领取)优先**」——队列数组是追加序,
|
|
82
|
+
新任务会被历史埋底;显示序与队列序分开(`sortTasksForDisplay`,桶内待领取仍按
|
|
83
|
+
队列原序)。侧栏任务子行不再硬截断 12 条(静默丢弃第 13 条起),改**分页**
|
|
84
|
+
(12 条/页);目录任务表同样分页(20 条/页)+「N 待领取 · M 个任务」计数。
|
|
85
|
+
|
|
86
|
+
- **项目组「添加成员」就地选择器**:组面板里就地弹窗——搜索框 + 「项目组」「项目」
|
|
87
|
+
两段列表;项目行加入/移出本组(行尾标出它还在哪些组),项目组行「并入 N」把该组
|
|
88
|
+
成员一并纳入(归属是复制不是移动);不再跳仓库管理页,去掉说明性段落。
|
|
89
|
+
仓库页「成员管理 / 导入仓库」复用同一组件。
|
|
90
|
+
|
|
91
|
+
- **dev-nxkv 项目级 skill**(`.claude/skills/dev-nxkv/`):改本仓库代码的施工单——
|
|
92
|
+
分层边界、模块骨架、action 字段全表、CRUD 契约、注册三步、常见坑与验收命令。
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- **进程界面时间起点改为领取时刻**(原为任务创建时刻)。
|
|
97
|
+
- 面板组任务提交走与 CLI 同一条严格口径(组 → 严格 board+project,成员校验一致)。
|
|
98
|
+
- MD 工作台修正:能打出空格(块↔正文往返不再吃掉尾随空白);标题不再与正文融合进
|
|
99
|
+
同一块;frontmatter 渲染成属性卡;划选不误触编辑。
|
|
100
|
+
- cloud 清单页移除「CLI 等价」提示块。
|
|
101
|
+
|
|
102
|
+
### Fixed
|
|
103
|
+
|
|
104
|
+
- 项目组「添加成员」的 UI/UX(见上:就地选择器 + 搜索 + 归属继承)。
|
|
105
|
+
|
|
5
106
|
## [0.14.0] - 2026-11-03 wait 收尾指令模板化 + 回调提示词/持续 get 可配多条 + timegoal 面板入口 + 前缀归组改一次性命令 + 删全局默认时长
|
|
6
107
|
|
|
7
108
|
### Changed
|
package/README.md
CHANGED
|
@@ -268,9 +268,10 @@ nx-kv todo get --ref "旧记录" --topic fr # 或按主题收窄
|
|
|
268
268
|
|
|
269
269
|
```
|
|
270
270
|
src/core/ 零业务语义:kvapi / fileapi(后端客户端) / attachstore(附件本地镜像) / config / errors / fstree
|
|
271
|
+
src/shared/ 同构纯函数:附件语法 / 双链 / frontmatter——Node 与浏览器加载同一份,零依赖
|
|
271
272
|
src/modules/ 功能域:system / setting / auth / group / localtask / timegoal / todo / file / bundled
|
|
272
273
|
src/runtime/ 装配层:registry / spec / cli / api / server
|
|
273
|
-
src/web/frontend/ React
|
|
274
|
+
src/web/frontend/ React 壳(视图全在这里,后端不知道它们的路径)
|
|
274
275
|
assets/nx-kv/ 内置 skill:KV 清单操作手册(远程)
|
|
275
276
|
assets/kv-waittask/ 内置 skill:对话内本地任务接力(等待侧)
|
|
276
277
|
assets/kv-submit/ 内置 skill:需求提炼与委托提交(提交侧)
|
|
@@ -278,8 +279,20 @@ assets/kv-localget/ 内置 skill:领取本目录本地任务(领取
|
|
|
278
279
|
assets/kv-timegoal/ 内置 skill:持续 N 小时任务的时间目标与提示词回显
|
|
279
280
|
```
|
|
280
281
|
|
|
281
|
-
分层由 `eslint.config.js` 的 `no-restricted-imports`
|
|
282
|
-
|
|
282
|
+
分层由 `eslint.config.js` 的 `no-restricted-imports` 与 `tests/unit/layering.test.mjs` 双重强制:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
core ← 谁都不依赖 shared ← 零依赖(不许 node: / 第三方包)
|
|
286
|
+
modules ← core + shared runtime ← 装配全部 web ← 只有 shared
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**前端够不着 modules / runtime / core**——它消费后端的唯一方式就是 HTTP `/api`。
|
|
290
|
+
`tests/unit/registry.test.mjs` 断言模块目录 ↔ 后端注册表 ↔ 前端视图注册表三方对齐,
|
|
291
|
+
并检查面板里出现的每个 `/api` 路径都真实存在。
|
|
292
|
+
|
|
293
|
+
> **改本仓库代码时**:先看项目级 skill `.claude/skills/dev-nxkv/`——它是一份施工单
|
|
294
|
+
> (分层地图、模块骨架、action 字段全表、CRUD 契约、注册三步、常见坑与验收命令)。
|
|
295
|
+
> 在本仓库里开工的 agent 会自动带上它。
|
|
283
296
|
|
|
284
297
|
## 给 agent 用
|
|
285
298
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 命令表快照:runtime 装配后写一次,模块只读。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这个格子:`/api/bootstrap` 要给面板下发「每个操作对应的 CLI 命令」,
|
|
4
|
+
// 而这张表由 runtime 装配(cli.js / registry.js)。模块直接 import runtime 会成环
|
|
5
|
+
// (registry → modules/system → cli → registry),历史写法是用 `await import(...)`
|
|
6
|
+
// 绕开:命令能跑,但分层倒了,且 loader 顺序一变就可能拿到半初始化的模块。
|
|
7
|
+
//
|
|
8
|
+
// 现在方向是单向的:
|
|
9
|
+
// runtime → core(写快照) modules → core(读快照)
|
|
10
|
+
// 谁都不再反过来依赖谁。没有快照时返回空数组——调用方(bootstrap)允许空表降级,
|
|
11
|
+
// 面板仍能打开,只是没有 CLI 提示。
|
|
12
|
+
let rows = [];
|
|
13
|
+
|
|
14
|
+
/** 由 runtime 在装配完成时调用。只保留最后一次写入(CLI 的表是注册表的超集)。 */
|
|
15
|
+
export function publishCommandTable(list) {
|
|
16
|
+
rows = Array.isArray(list) ? list : [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 当前命令表快照(每行 { id, module, command, usage, http, summary })。 */
|
|
20
|
+
export function commandTable() {
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
@@ -190,15 +190,29 @@ function renderMd(k) {
|
|
|
190
190
|
return lines.join('\n');
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* **介绍文档**:绑定优先(外部绝对路径 / 自有 rel),缺省走约定路径。
|
|
195
|
+
* 返回 { body, path }——path 给 agent 直接读盘(自有树给绝对路径)。
|
|
196
|
+
*/
|
|
197
|
+
async function readIntroDoc(ref, fallbackRel, { warnings, label }) {
|
|
198
|
+
if (ref && ref.path) {
|
|
199
|
+
const ext = await readExternalDoc(ref.path, { warnings, label });
|
|
200
|
+
return { body: ext.body || '', path: ref.path };
|
|
201
|
+
}
|
|
202
|
+
const rel = (ref && ref.rel) || fallbackRel;
|
|
203
|
+
const own = await readOwn(rel, { warnings, label });
|
|
204
|
+
return { body: own.body || '', path: join(paths.docsRoot(), rel) };
|
|
205
|
+
}
|
|
206
|
+
|
|
193
207
|
/**
|
|
194
208
|
* **项目组上下文**(交付层用):某个工作目录所属仓库所在的项目组材料。
|
|
195
209
|
*
|
|
196
210
|
* 与 repoKnowledge 的区别:那是「一份完整的知识报告」(CLI 给人看);
|
|
197
|
-
*
|
|
198
|
-
*
|
|
211
|
+
* 这里只取**交付时必须带上的那部分**——项目介绍、组定位、组介绍、
|
|
212
|
+
* 本项目在组里的位置、兄弟成员的组内描述、组绑定参考文件的**可读路径**。
|
|
199
213
|
*
|
|
200
|
-
* 返回 { repo:{name,key}, groups:[{name,key,desc,intro:{body,path},note,peers,docLinks}] }
|
|
201
|
-
* 不在项目组里 → groups
|
|
214
|
+
* 返回 { repo:{name,key,intro:{body,path}}, groups:[{name,key,desc,intro:{body,path},note,peers,docLinks}] }
|
|
215
|
+
* 不在项目组里 → groups 为空数组(组上下文段不拼,但项目介绍照给)。
|
|
202
216
|
*/
|
|
203
217
|
export async function groupContextFor(cwd) {
|
|
204
218
|
const { loadStore, deriveKey, boardKeyOf, boardRepoKeys, normalizePathKey } = await reposBridge();
|
|
@@ -212,21 +226,14 @@ export async function groupContextFor(cwd) {
|
|
|
212
226
|
const closure = await store.externalClosure(s);
|
|
213
227
|
const groups = [];
|
|
214
228
|
|
|
229
|
+
// 项目介绍(项目级任务也要带——它回答「这个项目是干什么的」)
|
|
230
|
+
const repoIntro = await readIntroDoc(repo.doc, paths.repoDocRel(rk), { warnings, label: '项目介绍' });
|
|
231
|
+
|
|
215
232
|
for (const b of s.boards) {
|
|
216
233
|
const members = boardRepoKeys(b, s);
|
|
217
234
|
if (!members.includes(rk)) continue;
|
|
218
235
|
// 组介绍(绑定优先,缺省约定路径)
|
|
219
|
-
const
|
|
220
|
-
const introRel = paths.boardDocRel(b.name);
|
|
221
|
-
let intro = { body: '', path: '' };
|
|
222
|
-
if (introRef && introRef.path) {
|
|
223
|
-
const ext = await readExternalDoc(introRef.path, { warnings, label: `组介绍·${b.name}` });
|
|
224
|
-
intro = { body: ext.body || '', path: introRef.path };
|
|
225
|
-
} else {
|
|
226
|
-
const rel = (introRef && introRef.rel) || introRel;
|
|
227
|
-
const own = await readOwn(rel, { warnings, label: `组介绍·${b.name}` });
|
|
228
|
-
intro = { body: own.body || '', path: join(paths.docsRoot(), rel) };
|
|
229
|
-
}
|
|
236
|
+
const intro = await readIntroDoc(b.doc, paths.boardDocRel(b.name), { warnings, label: `组介绍·${b.name}` });
|
|
230
237
|
// 本项目在组里的位置
|
|
231
238
|
const noteRef = (b.repoDocs || {})[rk];
|
|
232
239
|
const noteOwn = await readRef(noteRef, paths.groupNoteRel(b.name, rk), { warnings, label: `组内描述·${b.name}` });
|
|
@@ -264,7 +271,7 @@ export async function groupContextFor(cwd) {
|
|
|
264
271
|
docLinks,
|
|
265
272
|
});
|
|
266
273
|
}
|
|
267
|
-
return { repo: { name: repo.name, key: rk }, groups };
|
|
274
|
+
return { repo: { name: repo.name, key: rk, intro: repoIntro }, groups };
|
|
268
275
|
}
|
|
269
276
|
|
|
270
277
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as paths from './paths.js';
|
|
6
6
|
import * as store from './store.js';
|
|
7
7
|
import { reposBridge } from './repos-bridge.js';
|
|
8
|
-
import { parseWikiLinks, resolveWikiLink, normalizeWikiTarget } from '
|
|
8
|
+
import { parseWikiLinks, resolveWikiLink, normalizeWikiTarget } from '../../shared/wikilink.js';
|
|
9
9
|
|
|
10
10
|
const baseNameOf = (p) => String(p || '').replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
|
|
11
11
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// CLI/面板提交任务时的**工作组目标解析**:--board / --project → frontmatter 字段 + 落点目录。
|
|
2
|
+
//
|
|
3
|
+
// 纯逻辑(store 由调用方注入):单元测试直接喂数组,不碰磁盘。
|
|
4
|
+
//
|
|
5
|
+
// **输入是归一化后的形状**(调用方用 repos 服务的 boardRepoKeys / deriveKey 归一):
|
|
6
|
+
// boards[i] = { id, name, key, memberKeys: string[] } ← 成员键(不是 repoIds)
|
|
7
|
+
// repos[i] = { key, name, path }
|
|
8
|
+
// 归一化放在调用方,是因为 repos 模块在**模块互依禁列**里(localtask 不能静态引它)。
|
|
9
|
+
//
|
|
10
|
+
// 语义(**正逻辑:项目优先**):
|
|
11
|
+
// --project <key|name|path> 归属项目 = 任务挂在哪个项目上;落点目录由它推导
|
|
12
|
+
// (进那个项目的队列)。给了它就不用管 --cwd。
|
|
13
|
+
// --board <id|key|name> 工作组(看板):归属项目**必须是它的成员**,一起写进
|
|
14
|
+
// 正文 frontmatter(board + project),任务才出现在组面板里。
|
|
15
|
+
// --cwd 目录 只在没有 --project 时用来指定「落在哪个队列」;
|
|
16
|
+
// 与 --board 并用时**不参与归属推断**(归属只能是项目)。
|
|
17
|
+
//
|
|
18
|
+
// frontmatter 字段(正文头部,写给 agent 读):
|
|
19
|
+
// board / project 归属(组 id / 项目 key)
|
|
20
|
+
// boardIntro / projectIntro 介绍文档的**可寻址路径**(绑定优先,缺省约定路径)
|
|
21
|
+
// boardDesc / projectDesc 一句话摘要(组 desc / 项目 desc)
|
|
22
|
+
//
|
|
23
|
+
// 为什么不能从 cwd 反推项目:--cwd 是「队列地址」,--project 是「归属」——
|
|
24
|
+
// 两件事。反推等于让地址决定归属,用户以为在挂项目,实际被目录牵着走。
|
|
25
|
+
// 组内任务必须显式指明组内项目:归属不明就不该静默落在某个目录。
|
|
26
|
+
import { badInput } from '../../core/errors.js';
|
|
27
|
+
import { boardDocRel, repoDocRel } from '../docs/paths.js';
|
|
28
|
+
|
|
29
|
+
const normPath = (p) => String(p || '').replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 介绍文档的**可寻址位置**(引用式:frontmatter 给路径,agent 拿到就能读盘)。
|
|
33
|
+
* 绑定优先(DocRef:自有 rel / 外部绝对路径),缺省走约定路径。
|
|
34
|
+
*/
|
|
35
|
+
function introPath(ref, conventionRel) {
|
|
36
|
+
if (ref && typeof ref === 'object') {
|
|
37
|
+
if (ref.rel) return ref.rel;
|
|
38
|
+
if (ref.path) return ref.path;
|
|
39
|
+
}
|
|
40
|
+
return conventionRel;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 看板按 id / key / 名称匹配(组名最像人话,agent 上下文里只有组名) */
|
|
44
|
+
export function findBoard(boards, want) {
|
|
45
|
+
const w = String(want ?? '').trim();
|
|
46
|
+
if (!w) return null;
|
|
47
|
+
return (
|
|
48
|
+
(boards || []).find((b) => b.id === w) ||
|
|
49
|
+
(boards || []).find((b) => b.key === w) ||
|
|
50
|
+
(boards || []).find((b) => String(b.name || '').toLowerCase() === w.toLowerCase()) ||
|
|
51
|
+
null
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 仓库按 key / 名称 / 路径匹配 */
|
|
56
|
+
export function findRepo(repos, want) {
|
|
57
|
+
const w = String(want ?? '').trim();
|
|
58
|
+
if (!w) return null;
|
|
59
|
+
const wp = normPath(w);
|
|
60
|
+
return (
|
|
61
|
+
(repos || []).find((r) => r.key === w) ||
|
|
62
|
+
(repos || []).find((r) => String(r.name || '').toLowerCase() === w.toLowerCase()) ||
|
|
63
|
+
(repos || []).find((r) => normPath(r.path) === wp) ||
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 看板成员键(归一化后的形状;兼容直接给 repoKeys 的用法) */
|
|
69
|
+
export function memberKeysOf(board) {
|
|
70
|
+
if (!board) return new Set();
|
|
71
|
+
if (Array.isArray(board.memberKeys)) return new Set(board.memberKeys.filter(Boolean));
|
|
72
|
+
if (Array.isArray(board.repoKeys)) return new Set(board.repoKeys.filter(Boolean));
|
|
73
|
+
if (Array.isArray(board.repos)) {
|
|
74
|
+
return new Set(board.repos.map((m) => (typeof m === 'string' ? m : m && m.key)).filter(Boolean));
|
|
75
|
+
}
|
|
76
|
+
return new Set();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 解析提交目标。
|
|
81
|
+
* @param {{board?: string, project?: string, cwd?: string}} want --board / --project / --cwd 原始值
|
|
82
|
+
* @param {{boards: Array, repos: Array}} store 仓库登记与看板(由调用方注入,归一化后)
|
|
83
|
+
* @returns {{boardId: string, boardName: string, projectKey: string, projectName: string, cwd: string, fields: object}}
|
|
84
|
+
*/
|
|
85
|
+
export function resolveGroupTarget(want, store) {
|
|
86
|
+
const boards = (store && store.boards) || [];
|
|
87
|
+
const repos = (store && store.repos) || [];
|
|
88
|
+
const boardWant = String(want.board ?? '').trim();
|
|
89
|
+
const projWant = String(want.project ?? '').trim();
|
|
90
|
+
const cwdWant = String(want.cwd ?? '').trim();
|
|
91
|
+
if (!boardWant && !projWant) {
|
|
92
|
+
return { boardId: '', boardName: '', projectKey: '', projectName: '', cwd: '', fields: {} };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let board = null;
|
|
96
|
+
if (boardWant) {
|
|
97
|
+
board = findBoard(boards, boardWant);
|
|
98
|
+
if (!board) {
|
|
99
|
+
throw badInput(
|
|
100
|
+
`找不到项目组「${boardWant}」(可用 nx-kv board list 看清单;id / key / 名称都认)`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 成员集合:有组时只能在组内选(--project 必须是该组成员)
|
|
106
|
+
const memberKeys = board ? memberKeysOf(board) : null;
|
|
107
|
+
let repo = null;
|
|
108
|
+
if (projWant) {
|
|
109
|
+
repo = findRepo(repos, projWant);
|
|
110
|
+
if (!repo) {
|
|
111
|
+
throw badInput(`找不到项目「${projWant}」(可用 nx-kv repo list 看登记;key / 名称 / 路径都认)`);
|
|
112
|
+
}
|
|
113
|
+
if (memberKeys && !memberKeys.has(repo.key)) {
|
|
114
|
+
throw badInput(
|
|
115
|
+
`项目「${repo.name || repo.key}」不是项目组「${board.name}」的成员(先 nx-kv board assign 挂进去)`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
// --project 与 --cwd 都给了却指向不同项目 → 用错了,不静默取一个
|
|
119
|
+
if (cwdWant && repo.path && normPath(cwdWant) !== normPath(repo.path)) {
|
|
120
|
+
throw badInput(
|
|
121
|
+
`--cwd 指向「${cwdWant}」,--project 指向「${repo.name || repo.key}」(${repo.path})——不是同一个项目;给了 --project 就不用再给 --cwd`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} else if (board) {
|
|
125
|
+
// 有组就必须有归属项目:组内任务不能归属不明。
|
|
126
|
+
// 列出成员**名字**(用户真会敲进 --project 的值);认不出名字的退回 key。
|
|
127
|
+
const members = [...(memberKeys || [])].map((k) => {
|
|
128
|
+
const r = (repos || []).find((x) => x.key === k);
|
|
129
|
+
return r && r.name ? r.name : k;
|
|
130
|
+
});
|
|
131
|
+
throw badInput(
|
|
132
|
+
`--board「${board.name}」的任务要指明归属项目:加 --project <项目>` +
|
|
133
|
+
(members.length ? `(本组成员:${members.join(' / ')})` : '')
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// frontmatter 字段:**归属**(board id / project key)+ **介绍引用**(路径 + 一行摘要)。
|
|
138
|
+
// 介绍用引用式而不是内联正文:正文是 md 文件、路径即地址,agent 拿到就能读盘;
|
|
139
|
+
// 内联既撑爆 frontmatter,又会在介绍更新后留下过期副本。
|
|
140
|
+
const fields = {};
|
|
141
|
+
if (board) {
|
|
142
|
+
fields.board = board.id;
|
|
143
|
+
fields.boardIntro = introPath(board.doc, boardDocRel(board.name));
|
|
144
|
+
fields.boardDesc = String(board.desc || '').trim();
|
|
145
|
+
}
|
|
146
|
+
if (repo) {
|
|
147
|
+
fields.project = repo.key;
|
|
148
|
+
fields.projectIntro = introPath(repo.doc, repoDocRel(repo.key));
|
|
149
|
+
fields.projectDesc = String(repo.desc || '').trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
boardId: board ? board.id : '',
|
|
154
|
+
boardName: board ? board.name || '' : '',
|
|
155
|
+
projectKey: repo ? repo.key : '',
|
|
156
|
+
projectName: repo ? repo.name || repo.key : '',
|
|
157
|
+
// 落点:有项目就用项目登记的路径(进它的队列);否则调用方给的 cwd 说了算
|
|
158
|
+
cwd: repo ? repo.path || '' : '',
|
|
159
|
+
fields,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -43,10 +43,30 @@ const renderTask = (t) =>
|
|
|
43
43
|
`#${t.id} [${STATUS_TAG[t.status] || t.status}] ${t.text}`,
|
|
44
44
|
` 创建: ${t.createdAt}`,
|
|
45
45
|
t.consumedAt ? ` 领取: ${t.consumedAt}` : '',
|
|
46
|
+
// 附件清单(正文里的  嵌入;本地已有 → 直接给绝对路径)
|
|
47
|
+
...(t.attachments || []).map(
|
|
48
|
+
(a) => ` 附件: ${a.localPath || a.name}${a.localPath ? '' : `(未同步;nx-kv file sync --ids ${a.id})`}`
|
|
49
|
+
),
|
|
46
50
|
]
|
|
47
51
|
.filter(Boolean)
|
|
48
52
|
.join('\n');
|
|
49
53
|
|
|
54
|
+
// `localtask attachments` 的人读输出(与 todo attachments 同构)
|
|
55
|
+
function renderTaskAttachments(d) {
|
|
56
|
+
const lines = [
|
|
57
|
+
`#${d.task.id} 附件 ${d.count} 个 · 本地目录: ${d.localDir}(自动下载 ${d.autoSync ? '开' : '关'})`,
|
|
58
|
+
];
|
|
59
|
+
if (!d.count) {
|
|
60
|
+
lines.push(' (这条任务没有引用附件)');
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
}
|
|
63
|
+
for (const a of d.attachments) {
|
|
64
|
+
lines.push(` ${a.kind === 'image' ? '图片' : '附件'} ${a.name} [${a.id}]`);
|
|
65
|
+
lines.push(` ${a.localPath || '(未同步;nx-kv file sync --ids ' + a.id + ')'}`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
function renderAdd(d) {
|
|
51
71
|
const where = d.isGlobal ? '全局任务(共享池)' : `主题「${d.name}」`;
|
|
52
72
|
const head = `#${d.task.id} [${d.task.status}] ${d.task.text}`;
|
|
@@ -114,7 +134,6 @@ export default {
|
|
|
114
134
|
id: 'localtask',
|
|
115
135
|
title: '本地任务(= Web「本地任务」页)',
|
|
116
136
|
order: 20,
|
|
117
|
-
view: () => import('./view.jsx'),
|
|
118
137
|
|
|
119
138
|
// CRUD 资源声明:主题任务的增删查改齐备,CLI 与面板都能调用
|
|
120
139
|
resource: 'localtask',
|
|
@@ -135,9 +154,18 @@ export default {
|
|
|
135
154
|
// 领任务(见 localtask.claim),查看走 `localtask show`。HTTP 路由不变。
|
|
136
155
|
cli: ['localtask', 'show'],
|
|
137
156
|
http: ['GET', '/api/localtasks/item'],
|
|
138
|
-
summary: '查看单条任务(按 --id,定位到 --cwd 主题或 --key
|
|
157
|
+
summary: '查看单条任务(按 --id,定位到 --cwd 主题或 --key);正文引用附件时带附件清单与本地路径(自动下载跟随本机设置)',
|
|
139
158
|
flags: { id: { type: 'number', required: true, hint: '任务编号' }, cwd: CWD, key: KEY },
|
|
140
|
-
run: (ctx) =>
|
|
159
|
+
run: async (ctx) => {
|
|
160
|
+
const task = await service.getTask({ id: ctx.id, cwd: ctx.cwd, key: ctx.key });
|
|
161
|
+
// 附件标注(任务 #15):解析正文嵌入 + 按设置自动下载 + 标本地路径。
|
|
162
|
+
// fail-open:标注坏了照常返回任务本体。
|
|
163
|
+
const { annotateTaskDelivery } = await import('../tasktool/attach.js');
|
|
164
|
+
const att = await annotateTaskDelivery(task).catch(() => null);
|
|
165
|
+
return att
|
|
166
|
+
? { ...task, attachments: att.task.attachments, ...(att.sync ? { attachmentsSync: att.sync } : {}) }
|
|
167
|
+
: task;
|
|
168
|
+
},
|
|
141
169
|
render: renderTask,
|
|
142
170
|
},
|
|
143
171
|
{
|
|
@@ -244,15 +272,26 @@ export default {
|
|
|
244
272
|
cli: ['localtask', 'add'],
|
|
245
273
|
http: ['POST', '/api/localtasks'],
|
|
246
274
|
summary:
|
|
247
|
-
'向某主题(工作目录)的队列回填一条任务(有 wait 在等则即时投递,否则排队);--global 推到全局共享池;--duration 给任务带时间目标(领取即计时,完成前必须 check
|
|
275
|
+
'向某主题(工作目录)的队列回填一条任务(有 wait 在等则即时投递,否则排队);--global 推到全局共享池;--duration 给任务带时间目标(领取即计时,完成前必须 check);--board/--project 让任务带工作组语义(正文 frontmatter:board + project + 双方介绍引用),并落进该项目的队列;正文自带 frontmatter 时同样认归属——补介绍引用,落点跟到项目队列(显式给过 --cwd 时不挪窝)',
|
|
248
276
|
args: ['text'],
|
|
249
277
|
flags: {
|
|
250
|
-
cwd:
|
|
278
|
+
cwd: {
|
|
279
|
+
type: 'string',
|
|
280
|
+
hint: '工作目录(作为主题;默认当前目录)。归属由 --project 决定:给了 --project 就用它推导落点,这里再给且不一致会报错',
|
|
281
|
+
},
|
|
251
282
|
summary: { type: 'string', hint: '顺带刷新该主题的当前现状(可选)' },
|
|
252
283
|
global: {
|
|
253
284
|
type: 'boolean',
|
|
254
285
|
hint: '推到全局主题(共享池):任何目录的 wait 在本地空时自动领取;--cwd 被忽略',
|
|
255
286
|
},
|
|
287
|
+
board: {
|
|
288
|
+
type: 'string',
|
|
289
|
+
hint: '工作组(看板)id / key / 名称:任务正文自动带上 frontmatter(board + 组介绍引用),该任务才会出现在组面板里;必须同时给 --project 指明归属项目',
|
|
290
|
+
},
|
|
291
|
+
project: {
|
|
292
|
+
type: 'string',
|
|
293
|
+
hint: '归属项目(仓库 key / 名称 / 路径):任务挂在哪个项目上,落点目录由它推导,frontmatter 带上 project + 项目介绍引用;配 --board 时必须是该组成员',
|
|
294
|
+
},
|
|
256
295
|
duration: {
|
|
257
296
|
type: 'string',
|
|
258
297
|
hint: '时间目标时长(2h / 90m / 5小时):领取即计时,未到点不许标完成',
|
|
@@ -260,15 +299,20 @@ export default {
|
|
|
260
299
|
'goal-prompt': { type: 'string', hint: '时间目标的附加要求(随 check 指令一起固化,可省)' },
|
|
261
300
|
goalPrompt: { type: 'string', hint: '同 --goal-prompt(驼峰写法)' },
|
|
262
301
|
},
|
|
263
|
-
run: (ctx) => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
302
|
+
run: async (ctx) => {
|
|
303
|
+
// 派任务编排已迁 tasktool/add.js(任务工具语义归 tasktool;命令名不变)。
|
|
304
|
+
// 落点判定(--board/--project 严格口径 / 正文 frontmatter fail-open)只此一份实现,
|
|
305
|
+
// CLI、HTTP、面板提交同走这一条链路。
|
|
306
|
+
const { submitTask } = await import('../tasktool/add.js');
|
|
307
|
+
return submitTask({
|
|
267
308
|
text: ctx.text,
|
|
268
309
|
cwd: ctx.cwd,
|
|
269
310
|
summary: ctx.summary,
|
|
270
311
|
global: ctx.global,
|
|
271
|
-
|
|
312
|
+
board: ctx.board,
|
|
313
|
+
project: ctx.project,
|
|
314
|
+
duration: ctx.duration,
|
|
315
|
+
goalPrompt: ctx.goalPrompt ?? ctx['goal-prompt'],
|
|
272
316
|
});
|
|
273
317
|
},
|
|
274
318
|
render: renderAdd,
|
|
@@ -310,6 +354,51 @@ export default {
|
|
|
310
354
|
? '(时间目标已清除)'
|
|
311
355
|
: ''),
|
|
312
356
|
},
|
|
357
|
+
{
|
|
358
|
+
// 附件追加(与云端 todo attach 同构):上传/复用 fileId → markdown 嵌入正文末尾。
|
|
359
|
+
// 面板编辑器贴的是同一条语法(components/md/attachments.js),两端渲染一致。
|
|
360
|
+
id: 'localtask.attach',
|
|
361
|
+
cli: ['localtask', 'attach'],
|
|
362
|
+
http: ['POST', '/api/localtasks/attach'],
|
|
363
|
+
summary: '给任务追加附件:--path 上传本机文件,或 --fileId 追加已上传附件,以 markdown 嵌入正文末尾(不替换正文)',
|
|
364
|
+
flags: {
|
|
365
|
+
id: { type: 'number', required: true, hint: '任务编号' },
|
|
366
|
+
cwd: CWD,
|
|
367
|
+
key: KEY,
|
|
368
|
+
path: { type: 'string', hint: '本机文件路径(先上传再嵌入)' },
|
|
369
|
+
fileId: { type: 'string', hint: '已上传附件的 id(32 位 hex,可带扩展名)' },
|
|
370
|
+
name: { type: 'string', hint: '嵌入文字与本地文件名(默认取原文件名)' },
|
|
371
|
+
kind: { type: 'string', hint: 'image | file(不给按文件名/扩展名判)' },
|
|
372
|
+
accessLevel: { type: 'string', hint: 'public | private | protected(默认 public;仅 --path 上传时生效)' },
|
|
373
|
+
},
|
|
374
|
+
run: async (ctx) => {
|
|
375
|
+
const { attachToTask } = await import('../tasktool/attach.js');
|
|
376
|
+
return attachToTask({
|
|
377
|
+
id: ctx.id,
|
|
378
|
+
cwd: ctx.cwd,
|
|
379
|
+
key: ctx.key,
|
|
380
|
+
path: ctx.path,
|
|
381
|
+
fileId: ctx.fileId,
|
|
382
|
+
name: ctx.name,
|
|
383
|
+
kind: ctx.kind,
|
|
384
|
+
accessLevel: ctx.accessLevel,
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
render: (d) =>
|
|
388
|
+
`已附加 ${d.fileId}(${d.kind === 'image' ? '图片' : '文件'})到主题「${d.name}」#${d.task.id}\n嵌入: ${d.markdown}`,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
id: 'localtask.attachments',
|
|
392
|
+
cli: ['localtask', 'attachments'],
|
|
393
|
+
http: ['GET', '/api/localtasks/attachments'],
|
|
394
|
+
summary: '列出某条任务引用的附件(正文里的  嵌入),以及本地是否已有(给 agent 直接读本地文件)',
|
|
395
|
+
flags: { id: { type: 'number', required: true, hint: '任务编号' }, cwd: CWD, key: KEY },
|
|
396
|
+
run: async (ctx) => {
|
|
397
|
+
const { listTaskAttachments } = await import('../tasktool/attach.js');
|
|
398
|
+
return listTaskAttachments({ id: ctx.id, cwd: ctx.cwd, key: ctx.key });
|
|
399
|
+
},
|
|
400
|
+
render: renderTaskAttachments,
|
|
401
|
+
},
|
|
313
402
|
{
|
|
314
403
|
id: 'localtask.remove',
|
|
315
404
|
cli: ['localtask', 'remove'],
|