# v0.3 接口与实现契约(冻结) > 状态:**已冻结**。实现方只依赖本文件,有异议先找 Lead,不要自行改契约。 > 目标版本:`0.3.0` > 日期:2026-10-18 --- ## 0. 一句话 在 DSH 设置面板新增一页「并行开发配置」:**查看插件自身**的状态、运行条件、安装信息、插件事件;**配置插件自身**的行为。 **不展示任何任务、团队、验收记录、豁免记录、门禁决定流水。** 那些是看板的职责。 --- ## 1. 范围 ### 做 - 设置左栏新增「并行开发配置」整页(`settings.section`)。 - Host 新增 `GET /pdev/plugin/status`、`POST /pdev/plugin/config`。 - Host 声明 Config schema(8 个 volatile 字段),配置存进当前 profile 的 `cordis.patch.yml`。 - 配置真实作用于:提示词注入、时间线、自动打开、门禁模式、三项严格校验。 - 插件级事件环形缓冲(50 条),页面展示最近 20 条。 - 自动打开从 localStorage 迁移到配置。 ### 不做 - 不展示任务状态、团队概览、验收记录、豁免记录、门禁逐任务决定。 - 不重画依赖图、不改看板渲染、不改现有侧栏面板(iframe 承载保留)。 - 不做删除验收/豁免记录的按钮。 - 不做多语言(v1 中文;文案集中,后续再接 locale)。 - 不做项目级配置(全局一份,profile 级)。 --- ## 2. 命名与固定 ID | 项 | 值 | |---|---| | 包名 | `dsh-parallel-dev` | | 版本 | `0.3.0` | | 设置 section id | `dsh-parallel-dev` | | 设置 nav 文案 | `并行开发配置` | | 设置 nav order | `50` | | 设置页眉文案 | `并行开发配置` | | 设置页副标题 | `让「智能体团队模式并行开发」从建议变成有门禁、可校验的机制` | | 配置 entry id | `parallel-dev`(运行时用 `ctx.loader` 解析,不要硬编码) | | 提示词段 | `pdev:parallel-dev-discipline`,order `610` | | 看板路由 | `/pdev/board`、`/pdev/board/data` | | Host 新路由 | `/pdev/plugin/status`、`/pdev/plugin/config` | | 侧栏 tab kind | `parallel-dev-board`(保持不变) | --- ## 3. 配置 ### 3.1 Schema(`src/host/config-schema.js`) 使用 `import z from "@deepseek-ai/schemastery"`(**不要**用公共 `schemastery` 包)。 所有字段都 `.volatile()`,保存后不重载插件。 **Schema 单独放 `config-schema.js`,不要和纯逻辑混在 `config.js`**:单测 `scripts/check-host.mjs` 不加载 schemastery(worktree 里没有 node_modules),只 import `config.js` / `gate-decision.js`。 `config.js` 里只放默认值、夹取、白名单校验等纯函数。 ```js export const CONFIG_DEFAULTS = Object.freeze({ injectPolicy: true, autoOpen: true, timelineEnabled: true, timelineMaxEntries: 60, gateMode: "enforce", requireEvidence: true, requireCommitMatch: true, blockSelfCertification: true, }); export const Config = z.object({ injectPolicy: z.boolean().default(true).volatile(), autoOpen: z.boolean().default(true).volatile(), timelineEnabled: z.boolean().default(true).volatile(), timelineMaxEntries: z.number().step(1).min(10).max(500).default(60).volatile(), gateMode: z.union([z.const("enforce"), z.const("advise")]).default("enforce").volatile(), requireEvidence: z.boolean().default(true).volatile(), requireCommitMatch: z.boolean().default(true).volatile(), blockSelfCertification: z.boolean().default(true).volatile(), }); ``` `index.js` 必须重新导出:`export { Config } from "./config-schema.js";`(schema 只在 `config-schema.js`,`config.js` 保持零 schemastery 依赖) ### 3.2 字段语义 | 字段 | 默认 | 语义 | |---|---|---| | `injectPolicy` | `true` | 是否把编排纪律注入系统提示。关掉只影响这一段,其它功能正常 | | `autoOpen` | `true` | 检测到未完成团队任务时,是否自动打开侧栏看板 | | `timelineEnabled` | `true` | 是否记录任务状态变更(内存 + `.pdev/timeline.jsonl`) | | `timelineMaxEntries` | `60` | 内存中每个任务最多保留条数;整数 10–500 | | `gateMode` | `"enforce"` | `enforce` 拒绝;`advise` 只记录、不拒绝 | | `requireEvidence` | `true` | 登记验收记录时是否必须至少一条证据 | | `requireCommitMatch` | `true` | complete 时是否要求 worktree HEAD 等于记录 sha | | `blockSelfCertification` | `true` | 是否禁止任务所有者给自己登记验收 | ### 3.3 读取(Host) ```js readPrefs() // 返回完整 8 字段对象 ``` 规则: 1. `CONFIG_DEFAULTS` 打底; 2. 叠加 Loader 传入的 `config`(apply 第二参数); 3. 如果 `settings` 服务可用:`settings.describe({ redactSecrets: true })` 里找 `ns === ownEntryId()`,用它的 `value` 再覆盖; 4. 任何一步抛错都不能让门禁/工具崩,退回上一层。 5. 结果缓存 **1000ms**;`POST /pdev/plugin/config` 成功后立即失效。 6. 数字字段做整数与 10–500 夹取;`gateMode` 非法值退回 `enforce`。 ### 3.4 写入(Host) 走 `settings.update(ns, patch, expectedRevision)`。 - `ns` 用 `ownEntryId()` 动态解析。 - `patch` 只允许上面 8 个 key;空 patch、未知 key、类型/范围错误 → `400`。 - 保存成功后:失效缓存 → 重新同步提示词段 → 记一条 `info` 插件事件 → 返回新的 config view。 - 保存失败:`409`(修订冲突)、`400`(校验失败)、`503`(设置服务不可用)。 - **不允许**直接写 `cordis.patch.yml`。 ### 3.5 Config view(接口与页面共用) ```json { "value": { "injectPolicy": true, "...": "..." }, "defaults": { "injectPolicy": true, "...": "..." }, "user": { "autoOpen": false }, "revision": 3, "writable": true, "readonlyReason": null, "source": "settings", "entryId": "parallel-dev" } ``` - `user`:profile 覆盖层(原样),没有覆盖时是 `{}`。 - `revision`:设置服务读到的修订号;读不到时 `null`。 - `writable=false` 时必须有 `readonlyReason`(中文,页面直接展示)。 - `source`:`"settings"` | `"captured"`。 --- ## 4. HTTP 契约 ### 4.1 公共规则 - 仅本机同源;沿用现有 `sameOriginOk()`。 - 响应 `content-type: application/json; charset=utf-8`。 - POST 额外要求:`content-type` 以 `application/json` 开头;带 `Origin` 时,`Origin` 的 `host` 必须与 `Host` 头完全一致。 - 不设 CORS 头。 ### 4.2 `GET /pdev/plugin/status` ```json { "plugin": { "name": "dsh-parallel-dev", "version": "0.3.0", "description": "DSH 并行开发编排插件:让「智能体团队模式并行开发」从建议变成有门禁、可校验的机制。", "loadedAt": "2026-10-18T14:10:00.000Z", "uptimeMs": 3720000, "nodeVersion": "v22.20.0", "nodeEngine": ">=20" }, "host": { "gateInstalled": true, "tools": [ { "name": "pdev_preflight", "summary": "开工前门禁检查" }, { "name": "pdev_verify_record", "summary": "登记独立验收记录" }, { "name": "pdev_waive", "summary": "登记用户豁免" }, { "name": "pdev_gate_status", "summary": "查看门禁与验收记录" }, { "name": "pdev_board", "summary": "给出看板地址与依赖图摘要" } ], "policy": { "status": "injected", "name": "pdev:parallel-dev-discipline", "order": 610, "detail": "已注入" }, "board": { "status": "registered", "path": "/pdev/board", "detail": "registered at /pdev/board" }, "services": { "tools": true, "webServer": true, "settings": true, "systemPrompt": true, "agentTeams": true } }, "config": { "...": "见 3.5" }, "paths": { "packageDir": "/Users/.../node_modules/dsh-parallel-dev", "profileDir": "/Users/.../.dsh/profiles/desktop", "patchFile": "/Users/.../.dsh/profiles/desktop/cordis.patch.yml", "dataDir": "/Users/.../default-workspace/.pdev" }, "events": [ { "at": "2026-10-18T14:10:00.000Z", "level": "info", "message": "插件已加载 v0.3.0" } ] } ``` 约束: - `policy.status`:`injected` | `disabled` | `skipped` | `failed`。 - `board.status`:`registered` | `failed` | `skipped`。 - `services`:只报该服务是否存在,不读任务。 - `paths` 任一拿不到时填 `null`。 - `events` 最多 50 条,按时间升序;页面倒序展示。 - **响应里绝不能出现 task_id、任务状态、团队、验收记录。** ### 4.3 `POST /pdev/plugin/config` 请求: ```json { "patch": { "autoOpen": false }, "expectedRevision": 3 } ``` 响应: | 状态码 | body | 场景 | |---|---|---| | 200 | `{ "ok": true, "config": { ... } }` | 保存成功 | | 400 | `{ "ok": false, "code": "invalid", "message": "...", "field": "timelineMaxEntries" }` | 字段非法 | | 409 | `{ "ok": false, "code": "stale", "message": "...", "config": { ... } }` | 修订冲突,附带最新 view | | 503 | `{ "ok": false, "code": "settings-unavailable", "message": "..." }` | 设置服务不可用 | ### 4.4 `GET /pdev/board/data` 增量 在现有响应顶层增加: ```json { "prefs": { "autoOpen": true } } ``` 只加这一个字段;老客户端读到 `undefined` 时按 `true` 处理。 --- ## 5. Host 实现要点 ### 5.1 文件 | 文件 | 内容 | |---|---| | `src/host/config.js`(新) | 默认值、`normalizeConfig`、`validatePatch` 等纯函数,**不 import schemastery** | | `src/host/config-schema.js`(新) | schemastery 的 `Config`(8 个 volatile 字段) | | `src/host/gate-decision.js`(新) | `decideGate()` 纯函数 + 决策码 | | `src/host/team-key.js`(新) | `keyForAgent()` / `teamKey()`,纯逻辑、可 mock 测试,不 import dsh-tools | | `src/host/plugin-api.js`(新) | 两个 HTTP handler、事件环形缓冲、status 组装 | | `src/host/index.js` | 导出 Config;接线;保持原有工具/看板行为 | | `scripts/check-host.mjs`(新) | 纯函数单测,`node scripts/check-host.mjs` 必须全绿 | | `package.json` | 版本、`@deepseek-ai/schemastery` 依赖 | ### 5.2 `ownEntryId()` 仿 `dsh-better-sidebar`: ```js function ownEntryId(ctx) { let fallback; try { for (const entry of ctx.loader?.entries?.() ?? []) { const id = entry?.options?.id; if (entry?.options?.name !== "dsh-parallel-dev" || typeof id !== "string" || id === "") continue; if (entry.fiber === ctx.fiber) return id; if (entry.disabled !== true && fallback === undefined) fallback = id; } } catch {} return fallback ?? "parallel-dev"; } ``` ### 5.3 设置服务接线 ```js ctx.inject(["settings"], (sctx) => { ctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber), "dsh-parallel-dev: settings policy"); }); ``` - 设置服务不存在时,页面只读,门禁按默认/captured 配置工作。 - `describe` 拿不到自有 entry 时,`writable=false`,`readonlyReason="当前 profile 找不到 parallel-dev 这条配置项"`。 ### 5.4 提示词段实时开关 - `syncPolicy()`:读 prefs。 - `injectPolicy=true` 且系统提示服务可用且未注册 → `section(...)`,保存 disposer,status=`injected`。 - `injectPolicy=false` 且已注册 → 调 disposer,status=`disabled`。 - 系统提示服务不可用 → status=`skipped`。 - 在 `ctx.inject(["systemPrompt"], ...)` 回调里做首次同步;配置保存成功后再次 `syncPolicy()`。 - 记录插件事件:`info` 注入成功 / `warn` 跳过或失败。 - 注意:不要把 `ctx.inject` 写在 gate handler 里;每个服务只接一次。 ### 5.5 时间线 `session/event` 的 `team/task` 处理: - `prefs.timelineEnabled === false` → 直接 return。 - 容量用 `prefs.timelineMaxEntries`。 - 落盘失败:记 `warn` 插件事件(原有 console 保留)。 ### 5.6 门禁决策(纯函数) `src/host/gate-decision.js`: ```js export const GATE_CODES = ["waiver", "no_record", "not_passed", "head_unreadable", "sha_drift", "ok"]; export function decideGate(prefs, { waiver, record, head }) { // 返回 { action: "allow" | "deny" | "advise", code, reason } } ``` 判定顺序: 1. `waiver?.waived` → allow / `waiver` 2. 无 record → deny/advise / `no_record` 3. `record.verdict !== "passed"` → deny/advise / `not_passed` 4. `prefs.requireCommitMatch && record.worktree`: - `!head?.sha` → deny/advise / `head_unreadable` - `head.sha !== record.commit_sha` → deny/advise / `sha_drift` 5. allow / `ok` `action === "advise"` 时 `gateReason` 返回 `null`(放行),但: - console 打一行 `[dsh-parallel-dev] 仅提醒: ` - **不要**写进插件事件(那是任务流水,页面不展示)。 `gateMode === "enforce"` 且有 deny 时,保持现有拒绝文案(可加一行「当前为拦截模式」)。 ### 5.7 验收工具 `pdev_verify_record`: - `prefs.requireEvidence === false`:证据可为空;record 增 `evidence_waived: true`,返回文案说明「已按配置放宽」。 - `prefs.blockSelfCertification === false`:跳过自证检查;record 增 `self_certification_waived: true`、`self_certification_check.note = "用户关闭了自证检查"`。 - 其它行为(verdict 校验、独立读 HEAD、写文件)不变。 ### 5.8 插件事件 ```js const MAX_EVENTS = 50; function logEvent(level, message) // level: "info" | "warn" | "error" ``` 埋点(只记插件自身,不记任务): - 启动加载(info) - 门禁安装成功(info) - 提示词注入/跳过/失败(info/warn) - 看板路由注册/失败(info/warn) - 设置服务不可用(warn) - 配置保存成功/失败(info/warn) - 时间线落盘失败(warn) - 门禁自身异常(error;不写 task_id) - 设置接口未处理异常(error) ### 5.9 status 组装 - `packageDir`:`fileURLToPath(new URL("../../", import.meta.url))` 后去掉尾部斜杠。 - `profileDir`:`process.env.DSH_PROFILE_DIR || null`。 - `patchFile`:`profileDir ? join(profileDir, "cordis.patch.yml") : null`。 - `dataDir`:`ctx.get("workingDirectory")?.defaultDirectory` + `/.pdev`;拿不到填 `null`。 - `services`:逐个 `try { ctx.get(name) !== undefined } catch { false }`。 - `tools`:注册时登记 `{ name, summary }`;summary 用一句话中文。 - 未注册的看板/提示词状态如实填 `failed` / `skipped`。 ### 5.10 兼容 - 老客户端(没有设置页)不受影响;`/pdev/board/data` 只多一个字段。 - 老 Host(没有新路由)时,新客户端页面显示错误面板(见文案),不白屏。 ### 5.11 修复 `teamKey`:一次性验收 subagent 的归属(v0.3 必须) **问题(2026-10-18 实测)**:一次性 `subagent` 不是团队成员,`listMembers()` 直接抛 `is not a member of an active Agent Team`,旧 `teamKey()` 回退成 `session-`。 于是验收记录落成 `session-__task-1.json`,而 Lead 调 `complete` 时按 `team-__task-1.json` 找不到记录 → **门禁拒绝,整个验收流程走不通**。 探测证据:`session-888c706a-e8a7-4d56-b2f7-7990ce71e292__probe-teamkey-20261018.json`。 **新算法**(放在 `src/host/team-key.js`,保留所有旧行为): ```js function keyForAgent(ctx, agent) { try { const members = ctx.get("agentTeams")?.listMembers?.(agent) ?? []; const lead = members.find((m) => m?.target === "lead" || m?.name === "lead"); if (lead?.id) return `team-${lead.id}`; } catch { /* 不是团队成员 */ } return null; } function teamKey(ctx, exec) { const agent = exec?.agent; const own = keyForAgent(ctx, agent); if (own) return own; // Lead / teammate:完全不变 const sid = agent?.session?.id ?? agent?.id; // 一次性 subagent:找到它所属的根会话,按“根会话自己会算出的 key”返回 try { const agents = ctx.get("agents"); const roots = agents?.roots?.() ?? []; const root = roots.find((r) => r?.id === sid) ?? roots.find((r) => { try { return agents.isOwnedBy?.(sid, r) === true; } catch { return false; } }); if (root) { const rootKey = keyForAgent(ctx, root); if (rootKey) return rootKey; return `session-${root?.session?.id ?? root?.id ?? sid}`; } } catch { /* agents 服务不可用 */ } return `session-${sid ?? "unknown"}`; } ``` 必须覆盖的行为: | 调用方 | 期望 key | |---|---| | 团队 Lead | `team-`(不变) | | 团队成员(teammate) | `team-`(不变) | | 无团队的根会话 | `session-`(不变) | | 团队 Lead 下的一次性 subagent | `team-`(修复点) | | 无团队根会话下的一次性 subagent | `session-`(与根一致) | | `agents`/`agentTeams` 都不可用 | 旧回退 `session-<自己>`,不能抛错 | 测试用 mock ctx 覆盖这 6 行;不依赖真实团队。 --- ## 6. Client 实现要点 ### 6.1 注册 在 `apply()` 里: ```js ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "dsh-parallel-dev", order: 50, label: () => "并行开发配置", inject: () => ({ ctx, client: { version: CLIENT_VERSION, tabRegistered } }), }, SettingsSection)); ``` - `CLIENT_VERSION = "0.3.0"`。 - `tabRegistered` 在现有 tab 注册成功时置 true。 - 仍然只 `require("react")`;不 import 任何 Harness 客户端包。 - 不写 `document.body`,不加全局 DOM。 ### 6.2 页面结构(顺序固定) 1. 页眉:标题 + 副标题 + 版本徽标 2. **插件状态**卡片 3. **运行条件**列表(5 个服务 + 缺哪个的影响) 4. **安装信息**(安装目录 / profile / 配置文件 / 数据目录 / Node 版本) 5. **插件提供**(5 个工具 + 看板路由 + 侧栏面板 + 提示词段) 6. **最近插件事件**(默认 8 条,「展开全部」最多 20 条) 7. **配置**(分组:功能 / 时间线 / 验收门禁) 8. 底部操作:保存 / 恢复默认 / 保存状态 / 修订号 9. 「复制诊断信息」 ### 6.3 数据与刷新 - `GET /pdev/plugin/status`,`accept: application/json`。 - 加载中:骨架/「正在读取插件状态…」。 - 失败:错误面板 + 「重新检查」。文案: > 读不到插件状态:``。可能原因:Host 半边还没重启、当前 profile 没挂 host-webserver,或 `/pdev/plugin/status` 注册失败。 - 成功后每 **10 秒**刷新一次,仅当 `document.visibilityState === "visible"`;`visibilitychange` 回前台立即刷新。 - 手动「重新检查」。 - 组件卸载 / 请求重叠:用 `AbortController` + 递增请求号,只接受最后一次响应。 ### 6.4 表单 - `form` 初始值 = `config.value`;`dirty` = 与 `value` 逐字段比较。 - 每个字段旁:若 `user` 里有该 key,显示小徽标「已改」;否则「默认」。 - 保存:只提交变化的 key;`expectedRevision = config.revision`。 - 保存中:所有控件与按钮禁用;按钮显示「保存中…」。 - 成功:更新页面数据,显示「已保存 · 修订 N」。 - 409:显示「配置已被其他地方修改,已刷新为最新值,请重新修改」;用响应里的 `config` 替换。 - 400:显示字段错误。 - 503:显示 `readonlyReason`。 - `config.writable === false` 时:表单只读、隐藏保存按钮、显示提示条。 - 「恢复默认」= 把表单填成 `defaults`(不直接写),仍需点保存。 - **放宽确认**:当表单会导致以下任一情况,且尚未确认时,第一次点保存只把按钮变成「确认保存(会放宽门禁)」并显示警告条;第二次才真正提交: - `gateMode === "advise"` - 或 `requireEvidence` / `requireCommitMatch` / `blockSelfCertification` 任一为 false ### 6.5 控件要求 - 开关:真实 `