dsh-plugin-t-expert 0.2.7 → 0.2.10

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.
@@ -70,7 +70,7 @@ export function selectFallbackRoute(current, fallback, failureCode, alreadySwitc
70
70
  return { retry: true, switched: true, selection: fallback };
71
71
  }
72
72
  /** Deliver a durable member report to the live captain at its next model step. */
73
- export function steerCaptainReport(captain, from, content) {
73
+ export function steerCaptainReport(captain, from, content, logger) {
74
74
  try {
75
75
  captain.steer(createUserMessage({
76
76
  content: [{ type: 'text', text: `T Team message from member ${from}:\n\n${content}` }],
@@ -78,7 +78,10 @@ export function steerCaptainReport(captain, from, content) {
78
78
  }));
79
79
  return true;
80
80
  }
81
- catch {
81
+ catch (error) {
82
+ // 投递失败必须留痕:调用方只拿到一个布尔值(true=实时投递 / false=回落持久信箱),
83
+ // 不留日志就只能靠猜「这次为什么走了信箱」。steer 的异常根因在这里唯一可见。
84
+ logger?.warn?.(`[t-team] 向队长实时投递消息失败,已回落到持久信箱:${error instanceof Error ? error.message : String(error)}`);
82
85
  return false;
83
86
  }
84
87
  }
@@ -138,7 +141,7 @@ export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName,
138
141
  // Use the same lease/acknowledgment contract as send_message, outside the
139
142
  // team lock: steering can synchronously start another agent turn.
140
143
  const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
141
- const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content);
144
+ const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content, ctx.logger);
142
145
  await withTeamLock(lockKey, () => delivered
143
146
  ? acknowledgeMailbox(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
144
147
  : releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
@@ -97,14 +97,37 @@ export function pathMatchesScope(path, pattern) {
97
97
  const rawPattern = pattern.trim().replaceAll('\\', '/');
98
98
  if (rawPattern.startsWith('~') || rawPattern.startsWith('/') || /^[A-Za-z]:/.test(rawPattern))
99
99
  return false;
100
+ // 审计 N-20:旧实现只在模式**以 `/` 结尾**时才当目录,于是 `inScope: ["src"]` 对 `src/a.ts`
101
+ // 返回 false —— 写域等于没声明,真正该拦的写冲突会被放过。这里补齐两种常见写法:
102
+ // · `src` —— 不含扩展名的裸目录名,按目录前缀处理(含 `.` 的仍按文件精确匹配,保住 `src/a.ts` 语义)
103
+ // · `src/*` / `src/**` —— 单层 / 任意层通配
100
104
  const directory = rawPattern.endsWith('/');
105
+ const wildcard = rawPattern.includes('*');
106
+ const bareDirectory = !directory && !wildcard && !rawPattern.includes('.') && rawPattern !== '.' && rawPattern !== '';
107
+ if (wildcard) {
108
+ const normalizedWildcardPattern = normalizeWorkspacePath(rawPattern);
109
+ if (normalizedWildcardPattern === undefined)
110
+ return false;
111
+ // 逐段翻译:`*` = 单层,`**` = 任意层;其余段按字面量并转义正则元字符。
112
+ const escaped = normalizedWildcardPattern
113
+ .split('/')
114
+ .map((segment) => {
115
+ if (segment === '**')
116
+ return '.*';
117
+ if (segment === '*')
118
+ return '[^/]+';
119
+ return segment.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
120
+ })
121
+ .join('/');
122
+ return new RegExp(`^${escaped}$`, 'u').test(normalizedPath);
123
+ }
101
124
  const normalizedPattern = normalizeWorkspacePath(rawPattern);
102
125
  if (normalizedPattern === undefined) {
103
126
  if (directory && (rawPattern === './' || rawPattern === '/' || rawPattern === '.'))
104
127
  return true;
105
128
  return false;
106
129
  }
107
- if (directory || rawPattern === './' || rawPattern === '.') {
130
+ if (directory || bareDirectory || rawPattern === './' || rawPattern === '.') {
108
131
  if (normalizedPattern === '')
109
132
  return true;
110
133
  return normalizedPath === normalizedPattern || normalizedPath.startsWith(`${normalizedPattern}/`);
@@ -235,14 +235,26 @@ export function readTeamSync(stateRoot, teamId) {
235
235
  }
236
236
  /**
237
237
  * Persist one team record (inside the caller's lock).
238
+ *
239
+ * **落盘前必须先过读侧同一套判据(审计 N-18)**:`readTeam` 会 `coerceTeamState`,不合法就抛
240
+ * `invalid T Team state in team "<id>"`。曾经的缺陷是 `writeTeam` **完全不校验**,于是状态可以被写成
241
+ * 引擎自己拒绝读的形态(例如 provider 每次 spawn 返回同一个 childId → 成员 id 重复),
242
+ * 之后**任何**团队工具都会永久抛错、整支团队报废,而错误串里没有任何可定位字段。
243
+ * 写侧与读侧用同一判据,两者就无法再分叉:能写下去的,一定读得回来。
238
244
  * @param stateRoot - resolved absolute state root directory.
239
245
  * @param state - the record to persist.
240
246
  */
241
247
  export async function writeTeam(stateRoot, state) {
248
+ const teamId = isRecord(state) ? state['id'] : undefined;
249
+ if (coerceTeamState(JSON.parse(JSON.stringify(state)), teamId) === undefined) {
250
+ throw new Error(`refusing to persist invalid T Team state for team "${String(teamId ?? '(缺少 id)')}":`
251
+ + '写入前校验未通过(与 readTeam 同一判据)。常见原因:成员 id 重复、成员缺 joinedAt、'
252
+ + 'status 不在 idle/working/removed 之内、任务缺 createdAt/updatedAt/dependencies。');
253
+ }
254
+ await mkdir(join(stateRoot, state.id), { recursive: true });
242
255
  await atomicWriteText(join(stateRoot, state.id, 'team.json'), JSON.stringify(state, null, 2));
243
256
  }
244
- /** Read the durable set of member session ids retired by remove/delete. */
245
- function parseRetiredMemberIds(raw) {
257
+ /** Read the durable set of member session ids retired by remove/delete. */function parseRetiredMemberIds(raw) {
246
258
  const parsed = JSON.parse(stripLeadingBom(raw));
247
259
  if (!Array.isArray(parsed) || parsed.some(value => typeof value !== 'string' || value === '')) {
248
260
  throw new Error('invalid T Team retired member index');
@@ -15,6 +15,7 @@ import { appendTeamEvent, captainSessionOf } from "./events.js";
15
15
  import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
16
16
  import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
17
17
  import { TERMINAL_TASK_STATUSES } from "./types.js";
18
+ import { normalizeAssigneeForCreate } from "./assignee-contract.js";
18
19
  import { installTeamScheduler } from "./scheduler.js";
19
20
  import { resolveTeamProfile } from "./profiles.js";
20
21
  export { steerCaptainReport } from "./members.js";
@@ -114,45 +115,62 @@ function trimmedOptional(value) {
114
115
  const trimmed = value?.trim();
115
116
  return trimmed === undefined || trimmed === '' ? undefined : trimmed;
116
117
  }
117
- /** Validate references and cycles before a staged graph can be saved or run. */
118
- function validateStagedGraph(team, requireRunnable) {
118
+ /** Collect every staged-graph problem: empty subject, bad assignee, unknown/self dependency, cycles. */
119
+ function collectStagedGraphProblems(team, requireRunnable) {
120
+ const problems = [];
119
121
  const members = team.members.filter((member) => member.status !== 'removed');
120
122
  if (requireRunnable && members.length === 0)
121
- throw new Error('add at least one member before approving the plan');
123
+ problems.push('add at least one member before approving the plan');
122
124
  if (requireRunnable && team.tasks.length === 0)
123
- throw new Error('add at least one task before approving the plan');
125
+ problems.push('add at least one task before approving the plan');
124
126
  const memberNames = new Set(members.map((member) => member.name));
125
127
  const taskIds = new Set(team.tasks.map((task) => task.id));
126
128
  for (const task of team.tasks) {
127
- if (task.subject.trim() === '')
128
- throw new Error(`task "${task.id}" must have a subject`);
129
- if (task.assignee !== undefined && task.assignee !== CAPTAIN_KEY && !memberNames.has(task.assignee)) {
130
- throw new Error(`task "${task.id}" assignee "${task.assignee}" is not an active member`);
129
+ if (String(task.subject ?? '').trim() === '')
130
+ problems.push(`task "${task.id}" must have a subject`);
131
+ if (task.assignee !== undefined && task.assignee !== null && task.assignee !== CAPTAIN_KEY && !memberNames.has(task.assignee)) {
132
+ problems.push(`task "${task.id}" assignee "${task.assignee}" is not an active member`);
131
133
  }
132
- for (const dependency of task.dependencies) {
134
+ for (const dependency of task.dependencies ?? []) {
133
135
  if (dependency === task.id)
134
- throw new Error(`task "${task.id}" cannot depend on itself`);
135
- if (!taskIds.has(dependency))
136
- throw new Error(`task "${task.id}" depends on unknown task "${dependency}"`);
136
+ problems.push(`task "${task.id}" cannot depend on itself`);
137
+ else if (!taskIds.has(dependency))
138
+ problems.push(`task "${task.id}" depends on unknown task "${dependency}"`);
137
139
  }
138
140
  }
139
141
  const visiting = new Set();
140
142
  const visited = new Set();
141
143
  const byId = new Map(team.tasks.map((task) => [task.id, task]));
142
- const visit = (taskId) => {
143
- if (visiting.has(taskId))
144
- throw new Error(`task dependency graph contains a cycle at "${taskId}"`);
144
+ const visit = (taskId, trail) => {
145
+ if (visiting.has(taskId)) {
146
+ problems.push(`task dependency graph contains a cycle: ${[...trail, taskId].join(' → ')}`);
147
+ return;
148
+ }
145
149
  if (visited.has(taskId))
146
150
  return;
147
151
  visiting.add(taskId);
148
152
  for (const dependency of byId.get(taskId)?.dependencies ?? [])
149
- visit(dependency);
153
+ visit(dependency, [...trail, taskId]);
150
154
  visiting.delete(taskId);
151
155
  visited.add(taskId);
152
156
  };
153
157
  for (const task of team.tasks)
154
- visit(task.id);
158
+ visit(task.id, []);
159
+ return problems;
155
160
  }
161
+ /**
162
+ * Validate references and cycles before a staged graph can be saved or run.
163
+ *
164
+ * 抛**第一条**问题(调用方的门禁语义:越早拒绝越好)。需要"一次报全部"的调用方
165
+ * (插件侧的 `t_team_plan_check` 只读预检)用导出的 `collectStagedGraphProblems`,
166
+ * 不要自己复刻规则 —— 两处规则一旦漂移,预检就会说 ok 而引擎拒绝。
167
+ */
168
+ function validateStagedGraph(team, requireRunnable) {
169
+ const problems = collectStagedGraphProblems(team, requireRunnable);
170
+ if (problems.length > 0)
171
+ throw new Error(problems[0]);
172
+ }
173
+ export { collectStagedGraphProblems };
156
174
  function memberOpenTask(team, memberName, exceptTaskId) {
157
175
  return team.tasks.find(task => task.id !== exceptTaskId
158
176
  && task.assignee === memberName
@@ -1034,7 +1052,7 @@ export function registerTTeamTools(ctx, config) {
1034
1052
  }));
1035
1053
  ctx.tools.register(defineTool({
1036
1054
  name: 't_team_create_task',
1037
- description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
1055
+ description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working. Omit assignee to put the task in the shared pool; the captain cannot be set here — take a task over with t_team_reassign_task(assignee="captain").',
1038
1056
  parameters: {
1039
1057
  subject: { type: 'string', required: true, description: 'Required non-empty title for this task. Never omit it, including for verification or review tasks.' },
1040
1058
  description: { type: 'string', description: 'What needs to be done, in detail.' },
@@ -1043,7 +1061,7 @@ export function registerTTeamTools(ctx, config) {
1043
1061
  items: { type: 'string' },
1044
1062
  description: 'Task ids this task depends on (must be completed before this task can be claimed).',
1045
1063
  },
1046
- assignee: { type: 'string', description: 'Optional member name this task is intended for.' },
1064
+ assignee: { type: 'string', description: 'Optional active member name this task is intended for. Omit for the shared pool. Never "captain": create the task unassigned, then take it over with t_team_reassign_task(assignee="captain").' },
1047
1065
  kind: {
1048
1066
  type: 'string',
1049
1067
  enum: ['work', 'requirements', 'implementation', 'verification', 'review', 'repair', 'integration'],
@@ -1133,8 +1151,13 @@ export function registerTTeamTools(ctx, config) {
1133
1151
  throw new Error(`dependency "${dependency}" does not exist in team "${fresh.name}"`);
1134
1152
  }
1135
1153
  }
1136
- if (args.assignee !== undefined)
1154
+ if (args.assignee !== undefined) {
1155
+ // 预留键 `captain` 必须在这里被拦下并**指向 reassign_task**:
1156
+ // 直接丢给 requireMember 只会回一句 no active member named "captain",
1157
+ // 那是按成员名精确匹配的报错,完全不提示正确入口(曾经的实际缺陷)。
1158
+ normalizeAssigneeForCreate(args.assignee);
1137
1159
  requireMember(fresh, args.assignee);
1160
+ }
1138
1161
  const kind = gate.kind ?? 'work';
1139
1162
  const objective = kind === 'review' || kind === 'requirements'
1140
1163
  ? sanitizeReviewObjective(input.objective)
@@ -1681,7 +1704,7 @@ export function registerTTeamTools(ctx, config) {
1681
1704
  if (prepared.kind === 'captain') {
1682
1705
  let delivered = 'mailbox';
1683
1706
  if (captain !== undefined && prepared.identity.kind === 'member') {
1684
- delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
1707
+ delivered = steerCaptainReport(captain, prepared.from, args.content, ctx.logger) ? 'live' : 'mailbox';
1685
1708
  }
1686
1709
  if (delivered === 'live') {
1687
1710
  await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-plugin-t-expert",
3
- "version": "0.2.7",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
- "description": "T Expert — a 315-expert, 22-division roster with full Chinese translations, as a standalone DeepSeek Harness plugin, with a built-in multi-agent team engine (T Team).",
5
+ "description": "T Expert — a 316-expert, 22-division roster with full Chinese translations, as a standalone DeepSeek Harness plugin, with a built-in multi-agent team engine (T Team).",
6
6
  "license": "MIT",
7
7
  "author": "jiuaiwo",
8
8
  "keywords": [
@@ -67,10 +67,11 @@
67
67
  }
68
68
  },
69
69
  "scripts": {
70
- "build": "node ../build-client.mjs",
71
- "verify": "node ../verify.mjs",
72
- "sync-data": "node ../sync-data.mjs",
73
- "prepublishOnly": "npm run build && npm run verify && node ../sync-data.mjs --check"
70
+ "typecheck": "tsc -p tsconfig.json",
71
+ "build": "node tools/build-client.mjs",
72
+ "verify": "node tools/verify.mjs",
73
+ "sync-data": "node tools/sync-data.mjs",
74
+ "prepublishOnly": "npm run typecheck && npm run build && npm run verify && node tools/sync-data.mjs --check"
74
75
  },
75
76
  "peerDependencies": {
76
77
  "@deepseek-ai/cordis": "^4.0.1",
@@ -100,9 +101,6 @@
100
101
  "@deepseek-ai/dsh": {
101
102
  "optional": true
102
103
  },
103
- "@deepseek-ai/dsh-agent": {
104
- "optional": true
105
- },
106
104
  "@deepseek-ai/dsh-api-remotes": {
107
105
  "optional": true
108
106
  },
@@ -124,33 +122,22 @@
124
122
  "@deepseek-ai/dsh-client-ui-slots": {
125
123
  "optional": true
126
124
  },
127
- "@deepseek-ai/dsh-llm": {
128
- "optional": true
129
- },
130
- "@deepseek-ai/dsh-session": {
131
- "optional": true
132
- },
133
125
  "@deepseek-ai/dsh-settings": {
134
126
  "optional": true
135
127
  },
136
- "@deepseek-ai/dsh-subagent": {
137
- "optional": true
138
- },
139
128
  "@deepseek-ai/dsh-system-prompt": {
140
129
  "optional": true
141
130
  },
142
- "@deepseek-ai/dsh-tools": {
143
- "optional": true
144
- },
145
- "@deepseek-ai/dsh-typert-protocol": {
146
- "optional": true
147
- },
148
- "@deepseek-ai/schemastery": {
149
- "optional": true
150
- },
151
131
  "react": {
152
132
  "optional": true
153
- }
133
+ },
134
+ "@deepseek-ai/schemastery": {},
135
+ "@deepseek-ai/dsh-tools": {},
136
+ "@deepseek-ai/dsh-llm": {},
137
+ "@deepseek-ai/dsh-typert-protocol": {},
138
+ "@deepseek-ai/dsh-session": {},
139
+ "@deepseek-ai/dsh-subagent": {},
140
+ "@deepseek-ai/dsh-agent": {}
154
141
  },
155
142
  "devDependencies": {
156
143
  "@deepseek-ai/cordis": "^4.0.2",
@@ -161,7 +148,6 @@
161
148
  "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
162
149
  "@deepseek-ai/dsh-client-connection": "0.1.5-rc.1",
163
150
  "@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
164
- "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
165
151
  "@deepseek-ai/dsh-client-ui-input-trigger": "0.1.5-rc.1",
166
152
  "@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.1",
167
153
  "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.1",
@@ -178,9 +164,11 @@
178
164
  "@deepseek-ai/dsh-util-time": "^0.1.5-rc.1",
179
165
  "@deepseek-ai/dsh-util-values": "^0.1.5-rc.1",
180
166
  "@deepseek-ai/schemastery": "^3.18.2",
167
+ "@types/node": "^22.20.2",
181
168
  "esbuild": "^0.25.0",
182
169
  "react": "^18.3.1",
183
- "react-dom": "^18.3.1"
170
+ "react-dom": "^18.3.1",
171
+ "typescript": "^5.9.3"
184
172
  },
185
173
  "dependencies": {
186
174
  "zod": "^4.4.3"
@@ -0,0 +1,175 @@
1
+ ---
2
+ name: dsh-harness-languages
3
+ description: Use when writing, reviewing, or debugging any code in the deepseek-harness monorepo and you need that language's rules, layout, or toolchain — TypeScript on Node, the React browser client (TSX/CSS Modules), the Python SDK, the C Node-API addon, Cordis YAML composition, SQLite storage, shell, or the build/test toolchain (pnpm, tsc, tsdown, vitest, tsx, oxlint, Electron, Vite).
4
+ ---
5
+
6
+ # DeepSeek Harness:语言与技术栈
7
+
8
+ 一份按语言/技术栈拆的落地参考。先看清「这段代码属于哪一面、哪个语言面」,再动手。
9
+
10
+ ## 总览
11
+
12
+ | 语言 / 技术 | 位置 | 运行时与工具链 |
13
+ |---|---|---|
14
+ | **TypeScript(Host/Node)** | `packages/**`、`apps/cli`、`apps/desktop*`、`scripts/` | Node ^22.19 \|\| >=24、ESM、`tsc -b` + `tsdown`、`tsx` 跑源码 CLI |
15
+ | **TypeScript/TSX(Client/浏览器)** | `packages/client/**`、`apps/web` | React 18、`react-jsx`、Vite、CSS Modules + `clsx` |
16
+ | **Python 3.10+** | `python/sdk`、`python/sdk-runtime` | hatchling、uv、pydantic v2、pytest;stdio 上的 JSON-RPC 客户端 |
17
+ | **C(C11)** | `native/system/packages/entry/src/*.c` | Node-API(NAPI_VERSION=8)、`cc`/`musl-gcc`、预编译平台包 |
18
+ | **YAML(Cordis 组合)** | `packages/bundle/**/*.cordis.yml`、preset 的 `agent.cordis.yml`、profile patch | Loader + Schemastery `Config` 校验 |
19
+ | **SQLite** | `packages/storage/storage-sqlite`、`packages/session-query/session-query-sqlite` | `node:sqlite` 的 `DatabaseSync`、FTS5 |
20
+ | **Shell** | `packages/shell/*`、`scripts/*.sh` | bash / pwsh executor + sandbox 包装 |
21
+ | **Markdown(文档)** | `docs/**`、包 README、`.agents/notes/**` | 文档门禁(`doc-sync`) |
22
+
23
+ 所有 npm 包名是 `@deepseek-ai/dsh-<name>`;`@deepseek-ai/cordis` 是每个 harness 包的 peerDependency(+ dev)。
24
+
25
+ ---
26
+
27
+ ## TypeScript(Host / Node)
28
+
29
+ **编译形态**(`tsconfig.base.json`):`target: es2024`、`module: esnext`、`moduleResolution: bundler`、`strict: true`、`exactOptionalPropertyTypes: true`。
30
+
31
+ - **到处是 ESM**(`"type": "module"`)。跨包用包名 import,**包内相对导入写 `.ts` 后缀**。
32
+ - `dsh` CLI 的源码启动走 `node --import tsx/esm`(tsx 的 ESM-only hook),所以它能触达的模块必须保持 ESM(不能有 CJS-only 导出)——Node 原生 TS 模式在 engines 范围内不可用。
33
+ - **Host / Client 是两个聚合工程**:Host 包注册进 `tsconfig.host.json`,Client 包注册进 `tsconfig.client.json`;`tsconfig.json` 是 solution(`files: []`),`tsconfig.base.json` 是路径映射门面(**永远不要给它加 `include`/`files`**)。原因:两侧都在同一批 key 上 declaration-merge cordis 的 `Context`,一个 program 同时看到两份会报冲突 —— 该冲突只存在于 `ts.Program` 内,模块解析不会触发。
34
+ - 需要构建整仓 `ts.Program` 的脚本要显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,**绝不能用 root solution**。
35
+ - 六个包是 Host/Client 分裂包,各带两个 leaf config + 只用 solution 的根:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection`、`session-query/session-log-export`。
36
+
37
+ **插件导出形态(最关键的一条)**:
38
+
39
+ - **service 包 `export default` 它的 service class。**
40
+ - **function plugin 命名导出 `name` / `inject` / `Config` / `apply`,且不能有 default export。** 混用会让 Loader 丢弃该 function plugin 的 namespace(见 [postmortem 0001](docs/postmortem/0001-acp-default-export-drops-inject.md))。
41
+ - 可选服务用 **`ctx.get(name)`**;`ctx.<name>` 只留给已声明 injection 的服务 —— 属性代理对拓扑敏感,`ctx.get` 读全局服务存储。
42
+
43
+ **类型与写法**:
44
+
45
+ - 类型化事件用 **declaration merging** 与可合并扩展的 map。`SessionEventMap` 成员默认 required-on-read;事件 JSDoc 需要 `@mode` 和 payload `@param`;payload 里没有的 scoped key 需要 `@dshScopeScan unsupported`。只有结构性格式变化才 bump `SESSION_FORMAT_VERSION`。
46
+ - 判别式 tag 上做 switch;封闭联合以 `assertNever`(`@deepseek-ai/dsh-util-values`)收尾。
47
+ - 跨边界的不透明 id 用 `Branded<B>` / `BrandedNumber<B>` + `brandString` / `brandNumber`(`packages/util/brand`,包名 `@deepseek-ai/dsh-brand`),不用裸 `string`。
48
+ - **每个 module 与 export 都要有简洁 JSDoc**,函数式导出要有 `@param`/`@returns` —— `verify-export-jsdoc` 强制。公有 service 方法要记参数与非 void 返回。
49
+ - 剩下的 `any` 要解释为什么无法收窄。
50
+ - `src/types.ts` 只放类型,不放运行时代码;测试放包级 `tests/`,不放 `src/__tests__/`。
51
+ - 空 `catch` 命名它吞掉什么、为什么别的到不了;`try` 只包一条语句。
52
+ - 注释写局部契约(行为、失败、时序、所有权、模态、例外、后果),**不写推理过程、不复述代码、不写测试走查**。
53
+
54
+ **远程过程调用(Typert)**:业务 service 在 Host 用 `@Remote` / `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 在 `ctx.remote` / `agentCtx.remote` 下加载它们。手工写的 `remote` 类型会漂移。
55
+
56
+ **边界校验**:只在 parser/config、queued、模型/工具 JSON、durable/file、worker、process、wire 边界做运行时校验。同一进程内的静态类型边界信任 TypeScript,不加多余校验。
57
+
58
+ ---
59
+
60
+ ## TypeScript / React(浏览器客户端)
61
+
62
+ 位置:`packages/client/**`(`ui-*` 插件 + `web` 引导内核 + `store`/`connection`/`slots`/`locale`/`modules`/`resources`),应用层在 `apps/web`。
63
+
64
+ - React **18**,`jsx: react-jsx`,浏览器 lib(`ES2024 + DOM + DOM.Iterable`),`types: []` 起步(需要 Node 类型的包局部覆写)。继承 `tsconfig.base.client.json`。
65
+ - **样式**:CSS Modules(`*.module.css`,约 130 个)+ `clsx`。**不要引入组件库,不要用 Tailwind。**
66
+ - **token 归 `ui-theme` 所有**:静态色阶、语义别名、排版、动效、渐变、阴影、滚动条、明暗偏好都在 `packages/client/ui-theme/src/styles/`,对外暴露 `--dsw-*`。特性包只用 `--dsw-alias-*` 语义别名,**不写死调色板值或字面颜色**,也不在特性组件 CSS 里写主题选择器。
67
+ - 全局样式表放 `ui-theme/src/styles/`;组件样式放组件旁边。组件可定义局部自定义属性,但共享的颜色/排版/高度/动效归 theme 包。
68
+ - **先复用再改样式**:[ui-primitives 组件目录](packages/client/ui-primitives/README.md#component-catalog)是唯一跨特性包的通道;刻意的视觉差异做成它的 prop,而不是再复制一份。
69
+ - 抬高面(菜单、popover、modal、面板、浮动按钮、composer)设 `border: 0` 并用 `var(--dsw-elevation-*)`;**不要把 `--dsw-alias-border-*` 边框和 elevation 阴影配在一起**(ui-theme 的 spec 会拒绝)。
70
+ - 中立实色边框与分隔线画 `0.5px`(Chromium 上正好一个设备像素);虚线语义与状态色边框保持 1px。
71
+ - 圆角继承 ui-theme 的 superellipse smoothing;每个整圆 `border-radius`(`50%`、`100%`、胶囊)都要配 `corner-shape: round`。
72
+ - 可点击的产物链接统一用 `--dsw-alias-link` + `font-weight: 500`,静止无下划线,hover/focus 为 3px 偏移点状下划线。
73
+ - 保留键盘焦点可见性与 reduced-motion 行为。
74
+ - **产品文案归 locale 所有**:走类型化字典 + `t` 座位或本地化 primitive props。JSX、模板、helper 返回值、可访问性属性、primitive 默认值里的硬编码产品文案会被 `verify-client-ui-i18n` 拒绝;用户/模型/wire 数据与代码 token 原样保留。
75
+
76
+ ---
77
+
78
+ ## Python 3.10+
79
+
80
+ 位置:`python/sdk`(`deepseek_harness`,PyPI `deepseek-harness-sdk`)与 `python/sdk-runtime`(`deepseek_harness_runtime`,PyPI `deepseek-harness-runtime-bin`)。
81
+
82
+ - `requires-python = ">=3.10"`;`pydantic>=2.12,<3`;构建后端 hatchling(`hatchling==1.30.1`);依赖组测试用 pytest ≥8,`pytest.ini` 在仓库根。
83
+ - 包布局是 `src/` 布局:`python/sdk/src/deepseek_harness/{__init__,api,client,errors,models}.py`。
84
+ - **角色**:通过 stdio 上的换行分隔 JSON-RPC 驱动 dsh 子进程。`HarnessClient` 是同步客户端;`HarnessConfig` 持有 `dsh_bin`、`profile`(默认 `sdk`)、`patches`、`dsh_home`、`cwd`、`env`、各类超时。
85
+ - **每次启动都必须显式指定 Harness home;Python 绝不静默读 `~/.dsh`。**
86
+ - Python 侧暴露的是 profile 选择 + 有序 patch 文件,不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。
87
+ - 上游 `errors.py` 里的 `JsonRpcError` / `TransportClosedError` 是传输层的失败类型;模型结构在 `models.py`(`IncomingRequest`、`InitializeResponse`、`JsonObject`、`JsonValue`、`Notification`)。
88
+ - `sdk-runtime` 把正常 `dsh` CLI 打包成 `deepseek-harness-sdk-runtime-<platform>-<arch>`;`hatch_build.py` 注入运行时可执行文件,editable 安装走 `[tool.uv.sources]`。跨平台 CI 由 master-only 的 Python-runtime 工作流负责。
89
+ - agent-loop / session 生命周期 / `SessionEventMap` 改动必须同步更新 **Python SDK 的单可执行快照**(`scripts/snapshots/python-sdk-single-exe/`)。
90
+
91
+ ---
92
+
93
+ ## C(Node-API 原生插件)
94
+
95
+ 位置:`native/system`(workspace 包 `@deepseek-ai/node-addon-system`,平台包 `darwin-arm64`、`darwin-x64`、`linux-arm64`、`linux-x64` + `entry`)。
96
+
97
+ - 两个能力:**Linux Landlock launcher**(`landlock-run`:`launcherPath`、`probe`、`grantArgs`)与 **POSIX flock**(`tryLockExclusive(fd): Promise<void>`)。
98
+ - 编译:`-std=c11 -Wall -Wextra -Werror`;Node-API 走 `-DNAPI_VERSION=8 -fPIC -fvisibility=hidden`;musl 用 `musl-gcc -static`。
99
+ - 源码是 `packages/entry/src/main.c` 与 `flock.c`,通过 `lib/` 的 TS 门面导出(`tc -b` 之后 `prepack` 跑 `verify-entry-lib.mjs`)。
100
+ - **消费者安装从不编译原生代码**:平台二进制放在平台包里,由 entry 包以 npm optionalDependencies 承载。
101
+ - 语义契约在 `native/system/docs/`(`architecture.md`、`cli-contract.md`、`flock-contract.md`、`naming.md`、`packaging.md`、`release.md`、`support-matrix.md`)—— 改行为同步改契约文档。
102
+ - 重要语义:import 任一 entry **不会**加载 addon;Landlock 可执行文件缺失时 `probe` 报不可用,flock binding 缺失时获取锁直接 reject。两者都不会静默降级或自行编译。
103
+ - flock 语义:非阻塞独占 flock;竞争以 `EAGAIN`/`EWOULDBLOCK` reject;打开文件的最后一个描述符关闭时释放锁;描述符要保持到完成。
104
+
105
+ ---
106
+
107
+ ## YAML:Cordis 组合与配置
108
+
109
+ - `cordis.yml` 里 **`!!js` 只允许出现在 plugin 的 `config` 和 entry 的 `disabled` 下**(注意是双感叹号,不是 `!js`);其他元数据保持字面量。条件组合用 patch overlay 表达。
110
+ - 每个 row 的 `config` 由该插件导出的 Schemastery `Config` 校验;**配置字段都有 JSDoc**,生成的 `docs/config-catalog.md` 是穷尽权威。
111
+ - patch **按 `id` 定位 row**:替换整条 config,或插入新行。
112
+ - 裸插件(bare plugin)必须出现在其 resolver manifest 的 `dependencies` 里,`verify-cordis-config` 强制。
113
+ - agent preset 的 `agent.cordis.yml` 是 **agent 平面**:只放该 session 往注册表里贡献的东西;发布服务必须在带 `isolate` 的 group 内。`baseUrl` 在该文件中可用(preset 自身目录),`{{model}}`/`{{cwd}}` 是 persona 模板变量。
114
+ - preset 显示元数据在旁边的 `preset.yml`,只允许 `name` / `description` / `order`(`id` 是目录名,`trust` 来自发现根,二者不可在此声明)。
115
+ - 读失败一律降级为「无元数据」:**显示文本坏了不该让 preset 起不来**。
116
+
117
+ ---
118
+
119
+ ## SQLite
120
+
121
+ - 用 **`node:sqlite` 的 `DatabaseSync`**(内建,无第三方驱动),不是 better-sqlite3。
122
+ - 两个后端:`packages/storage/storage-sqlite`(kv facet,`STORAGE_SQLITE_SCHEMA_VERSION = 1`)与 `packages/session-query/session-query-sqlite`(`SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8`,FTS5 全文检索)。
123
+ - 版本存在 **`PRAGMA user_version`**:单调递增;`0` 视为未盖章并写入当前版本;**不匹配就报错拒绝打开**,不静默迁移、不降级。
124
+ - 会话数据本身是 append-only JSONL 日志(`session.jsonl[.zstd]`、v1+ 为 `session.vN.jsonl[.zstd]`)。已提交的 generation 路径**永不重命名、覆盖或删除**;SQLite 只服务查询与 kv 面。
125
+
126
+ ---
127
+
128
+ ## Shell
129
+
130
+ - 两个 executor provider:`dsh-bash-local` / `dsh-bash-sandbox`(Windows 上是 pwsh 对应实现),模型可见工具是 `dsh-tool-bash` / `dsh-tool-pwsh`。
131
+ - `ctx.shell` 的 request/spec 分离是「包边界显式优于隐式」的模板:默认值解析是拥有方显式的一步 `resolve(request): Spec`,不是 `run()` 里藏的 `?? default`。
132
+ - 子进程要经 sandbox backend 包装 argv。**不要把宿主的运行环境交给不受信输出**:spawn 的命令使用擦洗过的 env(丢弃 `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`),临时/溢出文件用私有 0700 目录、随机文件名、独占 owner-only 打开(`'wx'`、`0o600`)。
133
+ - 可能是符号链接或 Windows junction 的路径,先 `lstatSync().isSymbolicLink()` 再 `unlinkSync`;递归 `rmSync` 只留给确认的真实目录。
134
+ - 仓库脚本:`scripts/*.ts` 是主入口;只有 7 个 `.sh`(CI/打包辅助)。脚本里的诊断与门禁多数挂在 `scripts/run-gates.ts`。
135
+
136
+ ---
137
+
138
+ ## Markdown 与文档
139
+
140
+ 见 [dsh-harness-project](SKILL.md) 的技能文档分层一节:一段一个物理行、只写当前状态、生成的参考文档不手改、跨引用用相对路径。
141
+
142
+ ---
143
+
144
+ ## 构建与验证工具链
145
+
146
+ | 工具 | 角色 |
147
+ |---|---|
148
+ | **pnpm 11.7.0**(corepack) | workspace + lockfile;workspace 目录:`vendor/*`、`packages/*/*`、`native/system`、`native/system/packages/*`、`apps/*`、`website` |
149
+ | **tsc 6**(project references) | `tsc -b tsconfig.host.json` → `lib/types`;Client 同理 |
150
+ | **tsdown** | 打包 runtime;`--env.DSH_BUILD_FACE host\|client` 选择阶段;只消费前一步 tsc 的产物,**不扫描已有构建产物来发现包** |
151
+ | **tsx** | 跑 TypeScript 脚本与源码 CLI(`node --import tsx/esm`) |
152
+ | **vitest 4** | 全部测试层;所有 vitest config 都通过 vite-tsconfig-paths 指向 `tsconfig.base.json`,**workspace import 永远解析到 `src`**,不走包 `exports` 到已构建的 `lib/`(那里会加载第二份 module singleton) |
153
+ | **oxlint** | lint(`.oxlintrc.json`);`lint:fix` 用 staged 配置 |
154
+ | **jscpd** | `duplication` 跨文件 TS 克隆检测 |
155
+ | **publint + NodeNext 消费者检查** | `hygiene` 门禁组 |
156
+ | **Vite** | `apps/web` 构建前端产物;`pnpm run dev:web` 需要先有一次完整构建 |
157
+ | **Electron 44** | `apps/desktop` 桌面应用;带私有的 Desktop Host 在打包的 Node 进程里加载后端与客户端图,走版本化分帧字节管道,**不开 Web server 或 loopback 端口** |
158
+ | **Playwright/Chromium(测试内)** | `test:web` 浏览器快照 |
159
+ | **hatchling / uv / pytest** | Python 侧 |
160
+ | **cc / musl-gcc** | 原生侧 |
161
+
162
+ **源码面 vs 产物面**:静态门禁与测试通过 tsconfig `paths` 解析到 `src`,必须在干净树上通过;消费已构建 `lib/` 的门禁(built smoke、`lib` 模式子进程)要显式声明该依赖。子进程启动模式由共享 dual-mode launcher 决定,**不要手写 `--import tsx`**。
163
+
164
+ **构建产物污染提醒**:`pnpm run typecheck` 会先跑完整的 Host lib 阶段再跑 Client tsc;`build` 会继续 Client tsdown 与 Web build。构建把版本、7 位源码 commit、脏标记嵌进去——改完代码别指望旧的 `lib/` 还有效。
165
+
166
+ ---
167
+
168
+ ## 各语言共同的硬约束
169
+
170
+ 1. **一个异步操作对应一个生命周期控制器或事务**;把 readiness/cancellation/disposal/reservation/sentinel 拆成多份状态就必须各有独立 owner 或结算点。
171
+ 2. **在提交点发布状态**:通知与派生状态都在操作成功之后;缓存、prompt、UI 回声、replay、查询视图都从同一个权威源派生。
172
+ 3. **边界施加在完整结果上**:字节/token/条目/时间上限要在完整产出(含包装与元数据)已知处施加,并测试极小值、精确值、超大单块、多字节边界。
173
+ 4. **dispose 要到达静默**:teardown 必须 await 子项退出(kill → await `done`),并在 kill 之前关闭监听/通知注册表。
174
+ 5. **回调异常要在 dispatcher 内被兜住**:一个坏监听器不能 reject 它所在的 promise,也不能饿死后面的监听器。
175
+ 6. **正交结果独立上报**:超时 + exit 0 可以同时为真;`timedOut`/`signal`/`exitCode` 各报各的,不要把一个塞进另一个分支。