@marsken/dsh-studio 0.1.22 → 0.1.23

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/AGENTS.md CHANGED
@@ -7,7 +7,7 @@
7
7
  | 路径 | 是什么 | 谁能改 |
8
8
  | --- | --- | --- |
9
9
  | `index.js` | 宿主半边:`apply(ctx)`、39 个 `studio_*` 工具、`/studio-api/*` 路由、SQLite 存储、派单引擎、门禁 | ✅ 直接改 |
10
- | `src/host/*.js` | 宿主层模块:`cases.js`(跨卡用例聚合与回归汇总:`collectCases` / `groupCaseKeys` / `summarizeRun`,纯函数)、`team-memory.js`(专家团的团级记忆,存 side 键)、`quality.js`(AI 质量:返工识别与按专家汇总,纯函数)、`gitlab-facts.js`(执行记录 vs MR 事实的对账,纯函数)、`impact.js`(需求改版的影响面:受影响的卡与要重跑的用例,纯函数)、`design.js`(设计评审记录与进开发前的闸门,纯函数)、`shell-runner.js`(shell 用例执行器,默认关闭)、`shell-case.js`(shell 用例的数据形状,纯函数)、`runners.js`(用例执行器注册表:match + run,加新执行方式不用动 runOneCase)、`limits.js`(数量上限的默认值与清洗,可配置)、`db-runner.js`(SQLite 用例执行器与数据形状,默认关闭)、`discovery.js`(需求澄清必答项与拆卡门禁,纯函数)、`bugs.js`(失败用例 → 缺陷卡严重级别,纯函数)、`usage.js`(会话用量解析与计价,纯函数)、`session-usage.js`(读 session.v4 日志并按卡/专家汇总)、`events.js`(进程内事件总线,同步扇出)、`webhooks.js`(事件 → 出站 webhook,失败不重试)、`domain.js`(板列/数据形状/`sanitize*`/默认配置,纯函数)、`dispatcher.js`(派单引擎)、`executor.js`(用例执行、代码事实核对、健康检查)、`store.js`(`StudioStore`/`SecretStore`:SQLite 台账 + 快照 + 登录会话)、`integrations.js`(GitLab / 飞书 / 平台健康)、`routes/*.js`(按前缀拆出的 HTTP 处理:`exports.js` 导出 CSV、`auth.js` 统一登录、`tasks.js` 卡片/专家/列、`requirements.js` 需求/文档/公开提单、`releases.js` 版本/灰度/部署、`insights.js` 指标与工作台、`skills.js` 技能库读写上传、`admin.js` 配置/权限成员/审计/快照、`connect.js` 市场/集成健康/GitLab/仓库/飞书、`agents.js` 派单运行态与起停、`coding.js` 执行记录/失败重试/worktree/项目配置、`tools.js` 39 个 `studio_*` 工具) | ✅ 直接改,新逻辑优先放这里 |
10
+ | `src/host/*.js` | 宿主层模块:`cases.js`(跨卡用例聚合与回归汇总:`collectCases` / `groupCaseKeys` / `summarizeRun`,纯函数)、`team-memory.js`(专家团的团级记忆,存 side 键)、`quality.js`(AI 质量:返工识别与按专家汇总,纯函数)、`gitlab-facts.js`(执行记录 vs MR 事实的对账,纯函数)、`impact.js`(需求改版的影响面:受影响的卡与要重跑的用例,纯函数)、`design.js`(设计评审记录与进开发前的闸门,纯函数)、`shell-runner.js`(shell 用例执行器,默认关闭)、`shell-case.js`(shell 用例的数据形状,纯函数)、`runners.js`(用例执行器注册表:match + run,加新执行方式不用动 runOneCase)、`limits.js`(数量上限的默认值与清洗,可配置)、`db-runner.js`(SQLite 用例执行器与数据形状,默认关闭)、`discovery.js`(需求澄清必答项与拆卡门禁,纯函数)、`bugs.js`(失败用例 → 缺陷卡严重级别,纯函数)、`bitable.js`(卡片 → 飞书多维表格记录,分批 200,纯函数)、`usage.js`(会话用量解析与计价,纯函数)、`session-usage.js`(读 session.v4 日志并按卡/专家汇总)、`events.js`(进程内事件总线,同步扇出)、`webhooks.js`(事件 → 出站 webhook,失败不重试)、`domain.js`(板列/数据形状/`sanitize*`/默认配置,纯函数)、`dispatcher.js`(派单引擎)、`executor.js`(用例执行、代码事实核对、健康检查)、`store.js`(`StudioStore`/`SecretStore`:SQLite 台账 + 快照 + 登录会话)、`integrations.js`(GitLab / 飞书 / 平台健康)、`routes/*.js`(按前缀拆出的 HTTP 处理:`exports.js` 导出 CSV、`auth.js` 统一登录、`tasks.js` 卡片/专家/列、`requirements.js` 需求/文档/公开提单、`releases.js` 版本/灰度/部署、`insights.js` 指标与工作台、`skills.js` 技能库读写上传、`admin.js` 配置/权限成员/审计/快照、`connect.js` 市场/集成健康/GitLab/仓库/飞书、`agents.js` 派单运行态与起停、`coding.js` 执行记录/失败重试/worktree/项目配置、`tools.js` 39 个 `studio_*` 工具) | ✅ 直接改,新逻辑优先放这里 |
11
11
  | `client.src.js` | 浏览器半边的手写源码(通过 `window.__ModuleLoader__` 注册面板) | ✅ 直接改(前端逻辑) |
12
12
  | `web/src/**` | shadcn/Radix 组件、样式、`legacy.js` 桥;`web/src/studio/*.js` 是从 `client.src.js` 抬出来的纯模块(`markdown.js` 渲染器、`format.js` 日期格式、`labels.js` 标签表、`roster.js` 负责人与候选人、`fields.js` 表单适配层、`shell.js` 共享抽屉、`card.js` 看板卡片、`board.js` 看板本体、`mentions.js` @ 选择器、`cases.js` 多步用例解析、`drawer.js` 卡片详情抽屉、`editor.js` 通用编辑器、`palette.js` 命令面板(⌘K 全局搜索卡片/需求/版本/专家/专家团/项目/技能 + 动作项 + Ctrl/⌘+Alt+Shift+N/T/D 全局快捷键;带 Shift 是为了避开宿主的 Ctrl+Alt+N 新会话)、`charts.js` 进度条与燃尽图、`context.js` 共享上下文(`provideClient`/`client`)、`panels/*.js` 设置页 17 个面板、`pages/*.js` 六个页面组件(看板之外的工作台/需求/用例/版本/效能/自助提单);**`StudioPage` 是组合根,故意留在 `client.src.js`**,它负责顶栏、页签与「+ 新建」菜单并调用 `provideClient()`,别为了行数把它搬走),由 `make-legacy` 生成的 `legacy.js` 顶部统一 import | ✅ 直接改(样式与组件) |
13
13
  | `dist/client.js` | `pnpm build` 的产物(单文件 IIFE,注意在仓库根 `dist/`,不是 `web/dist/`) | ❌ 生成物 |
package/README.md CHANGED
@@ -636,7 +636,7 @@ DSH 的 web 只有一张登录 cookie,所有人共用;所以平台自己记
636
636
  成本怎么来的(0.1.12 起):宿主仍然没有把 usage 暴露给插件,所以改成自己读会话日志——`assistant/message` 上带 `usage`(入/出/缓存读/缓存写),模型名在请求上下文里继承。同一条消息里 usage 会出现两次(消息体 + 流式 chunk),只认消息上的那份。价目表要自己配,没配的模型花费记「未知」而不是 0。(`dsh-ui-usage-billing` 那类插件是自带解析器 + 价目表的)。要真算钱得先解决这两件事,已单开一张卡跟踪。
637
637
  ## 已知限制
638
638
 
639
- - 多维表格镜像(`baseToken` / `tableId`)只存配置,尚未实现写入。
639
+ - 多维表格镜像是**手动触发**的(`POST /studio-api/feishu/mirror`,支持 `dryRun` 先看会写多少条):一条记录 = 一张卡,200 条一批串行写;表里要有「编号 / 标题 / 状态 / 负责人 / 优先级 / 需求 / 更新时间」这几列。**还没在真实表格上验证成功路径**——本机 lark-cli 未完成用户授权时,它会如实返回 authorization 错误而不是假成功。
640
640
  - GitLab 同步 Issue 与**关联 MR**(`/issues/:iid/related_merge_requests`);卡上挂 MR 后「从 GitLab 核对」会拉 MR 详情、`/changes` 与 `/pipelines`(流水线状态进 `coding.gitlab`)。**不同步 MR 的评论**;MR 的合并/关闭状态要等下一次同步才反映到卡上。
641
641
  - 用例记录存在卡上(`task.cases`):跨卡的**用例总览**页签可以勾选批量跑(结果回写各自卡、失败照旧开缺陷),也支持**回归套件**(`/suite/*`:按规则或手选固定一批,一键重跑,`by` 记「套件:名字」)。但还没有「回归套件」这个对象——固定的用例集合、一键重跑、历次通过率趋势都还没有,批量跑是临时勾选、跑完即散。
642
642
  - 缺陷卡的严重级别默认 `P2`、优先级 `normal`:自动建卡不猜严重程度,由人或在评论里改。
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 项 | 值 |
8
8
  | --- | --- |
9
- | dsh-studio | `package.json` `version: 0.1.22` |
9
+ | dsh-studio | `package.json` `version: 0.1.23` |
10
10
  | DeepSeek Harness | `0.2.2-rc.1`(`dsh --version`) |
11
11
  | Node | `v24.22.2`(Linux 目标机);本机 Windows `v24.19.0` |
12
12
  | 目标系统 | Ubuntu 24.04,`dsh web` 监听 `18080`,nginx `:80` 反代并注入 `dsh-auth-*` cookie |
package/docs/publish.md CHANGED
@@ -127,6 +127,7 @@ token 只写进临时 npmrc(放在仓库外或 `tmp/` 且不提交),发完
127
127
  | --- | --- | --- |
128
128
  | `0.1.0` | 2026-11-13 | 首个公开版:看板 / 需求 / 用例回归 / 效能 / 设计评审四期 |
129
129
  | `0.1.1` | 2026-11-13 | 补三张 README 截图(npm 页可看图);默认值与文档去掉公司内网信息 |
130
+ | `0.1.23` | 2026-11-15 | 卡片镜像到飞书多维表格(手动触发,失败不谎报) |
130
131
  | `0.1.22` | 2026-11-15 | 缺陷严重级按用例优先级(回归升级)+ 角色模板编辑界面 |
131
132
  | `0.1.21` | 2026-11-15 | 需求基线可回滚(版本 +1、签字作废、留痕) |
132
133
  | `0.1.20` | 2026-11-15 | 拆卡时提示「有后端但功能点没覆盖后端」 |
package/index.js CHANGED
@@ -29,6 +29,7 @@ export const inject = ['tools', 'systemPrompt']
29
29
 
30
30
  import { ACCESS_ROLE_LABEL, ACCESS_ROLES, ADMIN_ROUTES, ARTIFACT_KINDS, ARTIFACT_STATUS, CASE_HTTP_METHODS, CASE_PRIORITIES, CASE_RESULTS, CASE_TYPE_LABEL, CASE_TYPES, cleanId, CODE_PREFIX, CODING_FILE_CHANGES, CODING_REVIEW_STATUS, codingSummary, COLUMN_STRATEGIES, DEFAULT_ROLES, defaultColumns, defaultConfig, defaultDeployEnvs, defaultTemplates, DEPLOY_ENV_KINDS, FALLBACK_AGENT_MODEL, hashLocalPassword, INTAKE_HTML, ISOLATION_MODES, LEGACY_BLOCKED_STATUS, LEVEL_RANK, LOCAL_DEFAULT_PASSWORD, mergeCoding, mergeConfig, mergeSection, MINE_HTML, nextRequirementCode, nextTaskCode, normalizePatch, normalizeStrategy, publicArtifact, publicCases, publicChecklist, publicComments, PRIORITIES, ROLE_LABELS, canonicalAuthor, RELEASE_STATUSES, RELEASE_STATUS_LABEL, REQ_DOC_KIND_LABEL, REQ_DOC_KINDS, REQ_DOC_STATUS, REQ_REVIEW_LABEL, REQ_REVIEW_STATUS, REQ_STATUS_LABEL, REQ_STATUSES, ROLE_IDS, ROLE_LEVEL, SANDBOX_MODES, sanitizeAcceptanceItem, sanitizeAccess, sanitizeAllowHosts, sanitizeArtifact, sanitizeArtifacts, sanitizeBaseline, sanitizeBots, sanitizeBugSource, sanitizeCase, sanitizeCaseHttp, sanitizeCaseRun, sanitizeCases, sanitizeChangedFile, sanitizeChangedFiles, sanitizeChecklist, sanitizeChecklistItem, sanitizeCoding, sanitizeColumns, sanitizeCommit, sanitizeCommits, sanitizeCosts, sanitizeDeployEnvs, sanitizeEnvs, sanitizeExperts, sanitizeIterations, sanitizeLdap, sanitizeLocalAuth, sanitizeProjects, sanitizeReqDoc, sanitizeReqDocs, sanitizeRelease, sanitizeRequirement, sanitizeRequirementChange, sanitizeRequirementHistory, sanitizeRequirementPoint, sanitizeRequirementPoints, sanitizeRequirementPool, sanitizeRequirementQuestions, sanitizeRequirementReview, sanitizeRequirements, sanitizeServices, sanitizeSla, sanitizeTeams, sanitizeTemplates, sanitizeTesting, sanitizeTestRun, sanitizeTestRuns, sanitizeUsers, sanitizeWorkProjects, seedExperts, SEVERITIES, STATUS_HTML, STATUS_IDS, STATUS_SET, STATUSES, TASK_TYPES, text, verifyLocalPassword, worktreeRoot } from './src/host/domain.js'
31
31
  import { Dispatcher, RUN_LOG, attachRunLog, claimDispatchLock, clearAgentFailure, configureDispatcher, noteAgentFailure, parseMentions, runSummary } from './src/host/dispatcher.js'
32
+ import { mirrorCards } from './src/host/bitable.js'
32
33
  import { bugSeverityFor } from './src/host/bugs.js'
33
34
  import { sanitizeLimits } from './src/host/limits.js'
34
35
  import { attachWebhooks, sanitizeWebhooks } from './src/host/webhooks.js'
@@ -1402,6 +1403,8 @@ function isEnvFailure(text) {
1402
1403
  * 用例失败 → 自动开一张缺陷卡,挂同一个需求、同一个项目/迭代,父任务是这张测试卡。
1403
1404
  * 同一张卡的同一用例只会开一次;重新标失败只是更新证据。
1404
1405
  */
1406
+ const mirrorCardsToBitable = (store, options) => mirrorCards(store, store.snapshot().config, options, { runCommand, markHealth })
1407
+
1405
1408
  async function autoBugFromCase(store, task, item, by) {
1406
1409
  const snapshot = store.snapshot()
1407
1410
  const existing = snapshot.tasks.find(entry => entry.bugSource != null
@@ -1556,8 +1559,7 @@ function serviceConflicts(state, task) {
1556
1559
 
1557
1560
  /** 一卡一分支名:优先用卡号(DEV-001),没编号就用 id。 */
1558
1561
  function worktreeBranch(task) {
1559
- // 卡号只在一条需求内唯一(两条需求都会拆出 REQ-001):带上 id 后半段,否则 git 不让同一分支挂两个 worktree
1560
- const code = String(task?.code ?? '').trim(), id = String(task?.id ?? '').trim()
1562
+ const code = String(task?.code ?? '').trim(), id = String(task?.id ?? '').trim() // 卡号只在需求内唯一,分支要带 id 后缀
1561
1563
  const tail = id.replace(/[^a-z0-9]/gi, '').slice(-8)
1562
1564
  const label = code === '' ? id : `${code}-${tail}`
1563
1565
  return `task/${label}`.slice(0, 120)
@@ -2626,6 +2628,7 @@ export function apply(ctx) {
2626
2628
 
2627
2629
  configureExecutor({
2628
2630
  autoBugFromCase,
2631
+ mirrorCardsToBitable,
2629
2632
  probeEnv,
2630
2633
  publicState,
2631
2634
  serviceDependencies,
@@ -2656,7 +2659,7 @@ export function apply(ctx) {
2656
2659
  gitlabRequest,
2657
2660
  gitlabMergeRequestDetail, applyConfig, ensureWorktree, publicState, readJsonBody, removeWorktree, sendJson, serviceOf, upsertById, workProjectOf })
2658
2661
  configureAgentsRoutes({ publicState, readJsonBody, sendJson, getDispatcher: () => activeDispatcher })
2659
- configureConnectRoutes({ applyConfig, installedPackages, publicState, readJsonBody, resolvePackageName, runPluginCli, sendJson, upsertById, getSyncTimer: () => syncTimer })
2662
+ configureConnectRoutes({ mirrorCardsToBitable, applyConfig, installedPackages, publicState, readJsonBody, resolvePackageName, runPluginCli, sendJson, upsertById, getSyncTimer: () => syncTimer })
2660
2663
  configureAdminRoutes({ applyConfig, checkToolAccess, operatorFromSessionId, operatorOf, publicState, readJsonBody, sendJson })
2661
2664
  configureSkillsRoutes({ isSkillName, readJsonBody, sendJson, skillFromMarkdown, skillFromZip, skillsRoot, getSkills: () => activeSkills })
2662
2665
  configureInsightsRoutes({ getDispatcher: () => activeDispatcher, sendJson, buildMetrics, buildAnalytics, workloadReport, burndownReport })
@@ -2674,6 +2677,7 @@ export function apply(ctx) {
2674
2677
  applyConfig,
2675
2678
  getDispatcher: () => activeDispatcher,
2676
2679
  autoBugFromCase,
2680
+ mirrorCardsToBitable,
2677
2681
  publicState,
2678
2682
  upsertById,
2679
2683
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsken/dsh-studio",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "DSH Studio: role-based multi-agent workspace (product / architect / developer / QA) with a live work board, GitLab + Feishu connection settings, all configurable from the web GUI.",
6
6
  "main": "index.js",
@@ -0,0 +1,74 @@
1
+ // 把看板卡片镜像到飞书多维表格:一条 Record = 一张卡。
2
+ // 只负责“卡片 → 记录字段”和分批;真正跑 lark-cli 在 index.js 里(要 markHealth、要复用 runCommand)。
3
+ //
4
+ // 表里需要有这些列(名字要对得上):编号 / 标题 / 状态 / 负责人 / 优先级 / 需求 / 更新时间
5
+
6
+ export const BITABLE_FIELDS = ['编号', '标题', '状态', '负责人', '优先级', '需求', '更新时间']
7
+ export const BATCH_LIMIT = 200
8
+
9
+ export function cardToRecord(task) {
10
+ return {
11
+ '编号': String(task?.code ?? '').trim() || String(task?.id ?? ''),
12
+ '标题': String(task?.title ?? '').slice(0, 200),
13
+ '状态': String(task?.status ?? ''),
14
+ '负责人': String(task?.expert ?? ''),
15
+ '优先级': String(task?.priority ?? ''),
16
+ '需求': String(task?.requirementId ?? ''),
17
+ '更新时间': Number(task?.updatedAt) > 0 ? new Date(Number(task.updatedAt)).toISOString() : '',
18
+ }
19
+ }
20
+
21
+ /** 一批最多 200 条,超过就分批(同一张表要串行写,否则会撞并发错误)。 */
22
+ export function recordsPayload(cards) {
23
+ const list = (Array.isArray(cards) ? cards : []).map(cardToRecord)
24
+ const batches = []
25
+ for (let index = 0; index < list.length; index += BATCH_LIMIT) batches.push(list.slice(index, index + BATCH_LIMIT))
26
+ return batches
27
+ }
28
+
29
+ /**
30
+ * 真正镜像:按 200 条一批串行写多维表格。
31
+ * runCommand / markHealth 由 index.js 注入(子模块不反向依赖宿主)。
32
+ */
33
+ export async function mirrorCards(store, config, options, deps) {
34
+ // 用别名接收注入能力:同名的 runCommand / markHealth 属于宿主模块,子模块不该 import 它们
35
+ const { runCommand: run, markHealth: health } = deps ?? {}
36
+ const gaps = mirrorConfigGaps(config)
37
+ if (gaps.length > 0) return { ok: false, error: '还没配多维表格:' + gaps.join('、') + '(设置 → 飞书机器人)' }
38
+ const requirementId = String(options?.requirementId ?? '')
39
+ const cards = (store.snapshot().tasks ?? []).filter(task => requirementId === '' || task.requirementId === requirementId)
40
+ const batches = recordsPayload(cards)
41
+ if (options?.dryRun === true) return { ok: true, dryRun: true, cards: cards.length, batches: batches.map(batch => batch.length) }
42
+ const probe = await run('lark-cli', ['--version'], 8000)
43
+ if (!probe.ok) {
44
+ health?.('feishu', 'fail', '没有可用的 lark-cli')
45
+ return { ok: false, error: '服务器上没有可用的 lark-cli,镜像需要它在服务器上并完成用户授权' }
46
+ }
47
+ const created = []
48
+ for (const batch of batches) {
49
+ const result = await run('lark-cli', [
50
+ 'base', '+record-batch-create',
51
+ '--base-token', String(config.feishu.baseToken).trim(),
52
+ '--table-id', String(config.feishu.tableId).trim(),
53
+ '--json', JSON.stringify({ create_records: batch }),
54
+ '--as', 'user',
55
+ ], 60000)
56
+ if (!result.ok) {
57
+ health?.('feishu', 'fail', '多维表格写入失败:' + String(result.stderr || '').slice(0, 160))
58
+ return { ok: false, error: '多维表格写入失败:' + String(result.stderr || result.stdout || '').slice(0, 300), created }
59
+ }
60
+ let parsed = {}
61
+ try { parsed = JSON.parse(String(result.stdout || '{}')) } catch { parsed = {} }
62
+ for (const id of (parsed.record_id_list ?? [])) created.push(String(id))
63
+ }
64
+ health?.('feishu', 'ok', '多维表格镜像 ' + created.length + ' 条')
65
+ return { ok: true, cards: cards.length, created: created.length, batches: batches.map(batch => batch.length) }
66
+ }
67
+
68
+ /** 没配表就直说,别让调用方以为写进去了。 */
69
+ export function mirrorConfigGaps(config) {
70
+ const gaps = []
71
+ if (String(config?.feishu?.baseToken ?? '').trim() === '') gaps.push('baseToken(多维表格 app_token)')
72
+ if (String(config?.feishu?.tableId ?? '').trim() === '') gaps.push('tableId(数据表 id)')
73
+ return gaps
74
+ }
@@ -1,7 +1,8 @@
1
1
  import { INTEGRATION, feishuPush, gitlabReady, gitlabRequest, gitlabSync, healthReport, projectReady } from '../integrations.js'
2
2
  import { cleanId } from '../domain.js'
3
- const INJECTED = ["applyConfig","installedPackages","publicState","readJsonBody","resolvePackageName","runPluginCli","sendJson","getSyncTimer","upsertById"]
3
+ const INJECTED = ['mirrorCardsToBitable', "applyConfig","installedPackages","publicState","readJsonBody","resolvePackageName","runPluginCli","sendJson","getSyncTimer","upsertById"]
4
4
  let host = {
5
+ mirrorCardsToBitable: () => ({ ok: false, error: '未注入' }),
5
6
  applyConfig: () => {},
6
7
  installedPackages: () => {},
7
8
  publicState: () => {},
@@ -140,6 +141,13 @@ if (method === 'POST' && route === '/bots/delete') {
140
141
  host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
141
142
  return true
142
143
  }
144
+ if (method === 'POST' && route === '/feishu/mirror') {
145
+ // 把卡片镜像进飞书多维表格;dryRun 只算条数不写
146
+ const body = await host.readJsonBody(req)
147
+ const result = await host.mirrorCardsToBitable(store, { requirementId: body.requirementId ?? '', dryRun: body.dryRun === true })
148
+ host.sendJson(res, 200, { ok: result.ok === true, ...result, state: result.ok === true ? host.publicState(store) : undefined })
149
+ return true
150
+ }
143
151
  if (method === 'POST' && route === '/feishu/test') {
144
152
  const body = await host.readJsonBody(req).catch(() => ({}))
145
153
  const config = store.snapshot().config