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.
- package/README.md +67 -15
- package/THIRD-PARTY-NOTICES +16 -8
- package/data/experts/engineering/engineering-deepseek-harness-project-expert.md +256 -0
- package/data/source.json +2 -2
- package/data/t-team.config.json +16 -1
- package/data/team-profiles.py +80 -78
- package/data/teams.json +103 -47
- package/data/teams.resolved.json +17 -1
- package/data/zh/COVERAGE.json +15 -14
- package/data/zh/descriptions.json +2 -1
- package/data/zh/names.json +2 -1
- package/lib/bootstrap.js +1 -0
- package/lib/catalog.js +155 -32
- package/lib/client.js +132 -128
- package/lib/command.js +22 -6
- package/lib/i18n.js +78 -11
- package/lib/index.js +492 -115
- package/lib/plan-check.js +50 -57
- package/lib/remote-schemas.js +156 -0
- package/lib/remote.js +111 -148
- package/lib/skill.js +107 -44
- package/lib/squads.js +195 -28
- package/lib/teams/assignee-contract.js +47 -0
- package/lib/teams/harness-compat.js +36 -0
- package/lib/teams/index.js +11 -3
- package/lib/teams/members.js +6 -3
- package/lib/teams/quality-gates.js +24 -1
- package/lib/teams/state.js +14 -2
- package/lib/teams/tools.js +44 -21
- package/package.json +18 -30
- package/skills/dsh-harness-languages/SKILL.md +175 -0
- package/skills/dsh-harness-project/SKILL.md +209 -0
- package/skills/t-expert-manager/SKILL.md +15 -5
- package/skills/t-expert-manager/references/ops-reference.md +4 -4
- package/vendor/third-party-licenses/README.md +1 -1
package/lib/plan-check.js
CHANGED
|
@@ -1,43 +1,19 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* DAG 预检(只读):把「打算创建的一整套任务」先跑一遍引擎的校验,**一次报出全部问题**。
|
|
3
4
|
*
|
|
4
5
|
* 为什么需要它:引擎建任务是**逐条**的(依赖只能引用已存在的任务),每条硬校验都单独拒绝,
|
|
5
6
|
* 中途失败会留下半成品,模型再重试就容易出现重复任务(本机实测踩过)。
|
|
6
|
-
* 这里复用引擎自己的 `validateCreateTask
|
|
7
|
-
* `
|
|
7
|
+
* 这里复用引擎自己的 `validateCreateTask`(契约/写域重叠/质量门槛),**并复用引擎导出的
|
|
8
|
+
* `collectStagedGraphProblems`** 做引用与环检查 —— 全部在内存里模拟、不写任何状态。
|
|
9
|
+
*
|
|
10
|
+
* 规则**只有一份**(`lib/teams/tools.js`):过去这里按同规则复刻过一遍,两处一旦漂移,
|
|
11
|
+
* 预检就会说 ok、真建任务却被引擎拒 —— 而预检的全部意义正是提前发现这种岔路。
|
|
8
12
|
*/
|
|
9
|
-
import { CAPTAIN_KEY
|
|
13
|
+
import { CAPTAIN_KEY } from "./teams/state.js";
|
|
14
|
+
import { normalizeAssigneeForCreate } from "./teams/assignee-contract.js";
|
|
10
15
|
import { validateCreateTask } from "./teams/quality-gates.js";
|
|
11
|
-
|
|
12
|
-
/** 与引擎 `validateStagedGraph` 对齐的引用/环检查(该函数未导出,这里按同规则复刻)。 */
|
|
13
|
-
function graphProblems(tasks) {
|
|
14
|
-
const problems = [];
|
|
15
|
-
const ids = new Set(tasks.map((task) => task.id));
|
|
16
|
-
for (const task of tasks) {
|
|
17
|
-
if (String(task.subject ?? "").trim() === "") problems.push(`任务 ${task.id} 缺少 subject`);
|
|
18
|
-
for (const dependency of task.dependencies ?? []) {
|
|
19
|
-
if (dependency === task.id) problems.push(`任务 ${task.id} 不能依赖自己`);
|
|
20
|
-
else if (!ids.has(dependency)) problems.push(`任务 ${task.id} 依赖不存在的 ${dependency}`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
// 环路检测(DFS 三色)
|
|
24
|
-
const visiting = new Set();
|
|
25
|
-
const visited = new Set();
|
|
26
|
-
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
27
|
-
const walk = (id, trail) => {
|
|
28
|
-
if (visiting.has(id)) {
|
|
29
|
-
problems.push(`依赖成环:${[...trail, id].join(" → ")}`);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
if (visited.has(id)) return;
|
|
33
|
-
visiting.add(id);
|
|
34
|
-
for (const dependency of byId.get(id)?.dependencies ?? []) walk(dependency, [...trail, id]);
|
|
35
|
-
visiting.delete(id);
|
|
36
|
-
visited.add(id);
|
|
37
|
-
};
|
|
38
|
-
for (const task of tasks) walk(task.id, []);
|
|
39
|
-
return [...new Set(problems)];
|
|
40
|
-
}
|
|
16
|
+
import { collectStagedGraphProblems } from "./teams/tools.js";
|
|
41
17
|
|
|
42
18
|
/** 拓扑序(依赖在前);有环时返回已排好的部分。 */
|
|
43
19
|
function topologicalOrder(tasks) {
|
|
@@ -76,37 +52,61 @@ export function checkPlan(team, tasks) {
|
|
|
76
52
|
// 逐条模拟:先做静态检查,再跑引擎的 validateCreateTask(它只看已存在的任务 + 已接受的模拟任务)
|
|
77
53
|
const sim = { ...(team ?? {}), tasks: [...existing] };
|
|
78
54
|
const taken = new Set(existing.map((task) => task.id));
|
|
79
|
-
|
|
55
|
+
// 本批 id(标签)→ 引擎会分配的模拟 id。**按声明顺序累积**,不预先解析全批 —— 见
|
|
56
|
+
// `resolveDependencies` 的说明:引擎要求依赖已存在,前向引用本就建不出来。
|
|
57
|
+
const labels = new Map();
|
|
58
|
+
// 每个位置(含**没有 id 的**任务)→ 模拟 id:发号与标签解析必须共用同一次发号,
|
|
59
|
+
// 否则「只给带 id 的发号」会让报告里的 id 与实际创建顺序不一致。
|
|
60
|
+
const simulatedIds = [];
|
|
80
61
|
// 引擎分配的是 `t${team.taskSeq + 1}` 并随后自增(不是"现有任务条数")。两者只在
|
|
81
62
|
// taskSeq === tasks.length 时才一致 —— 有任务被删除/取消过就会分叉,所以以 taskSeq 为准。
|
|
82
63
|
let counter = Number.isSafeInteger(team?.taskSeq) ? team.taskSeq : existing.length;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
proposed.forEach((raw, index) => {
|
|
86
|
-
const local = [];
|
|
87
|
-
const wantId = typeof raw?.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : undefined;
|
|
64
|
+
const issueSimulatedId = () => {
|
|
88
65
|
let simulatedId;
|
|
89
66
|
do {
|
|
90
67
|
counter += 1;
|
|
91
68
|
simulatedId = `t${counter}`;
|
|
92
|
-
} while (taken.has(simulatedId));
|
|
69
|
+
} while (taken.has(simulatedId) || existing.some((task) => task.id === simulatedId));
|
|
93
70
|
taken.add(simulatedId);
|
|
71
|
+
return simulatedId;
|
|
72
|
+
};
|
|
73
|
+
const resolveDependencies = (raw) => (Array.isArray(raw?.dependencies) ? raw.dependencies : [])
|
|
74
|
+
.map((dependency) => String(dependency))
|
|
75
|
+
.map((dependency) => labels.get(dependency) ?? dependency);
|
|
76
|
+
const report = [];
|
|
77
|
+
|
|
78
|
+
proposed.forEach((raw, index) => {
|
|
79
|
+
const local = [];
|
|
80
|
+
const wantId = typeof raw?.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : undefined;
|
|
81
|
+
const simulatedId = issueSimulatedId();
|
|
82
|
+
simulatedIds[index] = simulatedId;
|
|
83
|
+
// 登记在本条**解析依赖之后**:这样 `dependencies` 里的同批标签只有「已声明过的」
|
|
84
|
+
// 才能解析成模拟 id,与引擎的「依赖必须已存在」一致。
|
|
94
85
|
if (wantId !== undefined) labels.set(wantId, simulatedId);
|
|
95
86
|
|
|
96
87
|
const subject = String(raw?.subject ?? "").trim();
|
|
97
88
|
if (subject === "") local.push("缺少 subject");
|
|
98
89
|
const kind = typeof raw?.kind === "string" && raw.kind !== "" ? raw.kind : "work";
|
|
99
|
-
const dependencies = (
|
|
100
|
-
.map((dependency) => String(dependency))
|
|
101
|
-
.map((dependency) => labels.get(dependency) ?? dependency);
|
|
90
|
+
const dependencies = resolveDependencies(raw);
|
|
102
91
|
if (dependencies.includes(simulatedId)) local.push("不能依赖自己");
|
|
92
|
+
// 判定「不存在的依赖」时,本批**已声明**的标签算作已知(它们会在模拟里成为真实任务 id)。
|
|
103
93
|
const unknown = dependencies.filter(
|
|
104
|
-
(dependency) => !taken.has(dependency) && !existing.some((task) => task.id === dependency),
|
|
94
|
+
(dependency) => !taken.has(dependency) && !labels.has(dependency) && !existing.some((task) => task.id === dependency),
|
|
105
95
|
);
|
|
106
96
|
if (unknown.length > 0) local.push(`依赖不存在的任务:${unknown.join(", ")}`);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
97
|
+
const rawAssignee = typeof raw?.assignee === "string" && raw.assignee.trim() !== "" ? raw.assignee.trim() : undefined;
|
|
98
|
+
let assignee = rawAssignee;
|
|
99
|
+
if (rawAssignee !== undefined) {
|
|
100
|
+
// 预检必须与引擎的 create_task **同规**:captain 不能在这里被"通过",
|
|
101
|
+
// 否则预检说 ok、真建任务却被拒 —— 而预检的全部意义就是提前发现这种岔路。
|
|
102
|
+
try {
|
|
103
|
+
assignee = normalizeAssigneeForCreate(rawAssignee) ?? rawAssignee;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
local.push(error instanceof Error ? error.message : String(error));
|
|
106
|
+
}
|
|
107
|
+
if (local.length === 0 && assignee !== undefined && !memberNames.has(assignee)) {
|
|
108
|
+
local.push(`assignee「${assignee}」不是当前成员(用 t_team_status 看成员名,或先 add_member)`);
|
|
109
|
+
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// 引擎的契约/写域/质量门槛校验
|
|
@@ -131,8 +131,9 @@ export function checkPlan(team, tasks) {
|
|
|
131
131
|
report.push({ index, id: wantId ?? simulatedId, simulatedId, subject, ok: local.length === 0, error: local.join(";") });
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
//
|
|
135
|
-
|
|
134
|
+
// 环/引用检查(含已存在的任务):**调用引擎导出实现**,不再本地复刻。
|
|
135
|
+
// 用 requireRunnable=false —— 预检允许"成员还没加",那是 approve 阶段才要求的。
|
|
136
|
+
problems.push(...collectStagedGraphProblems(sim, false).map((error) => ({ index: -1, id: "graph", subject: "", error })));
|
|
136
137
|
|
|
137
138
|
// 提示(不阻塞):成员同时只能有 1 个未完成任务(引擎在 claim/reassign 时才会拒)
|
|
138
139
|
const openStatuses = new Set(["pending", "claimed", "in_progress"]);
|
|
@@ -163,11 +164,3 @@ export function checkPlan(team, tasks) {
|
|
|
163
164
|
existing: existing.map((task) => ({ id: task.id, status: task.status, kind: task.kind ?? "work", assignee: task.assignee ?? "" })),
|
|
164
165
|
};
|
|
165
166
|
}
|
|
166
|
-
|
|
167
|
-
/** 从磁盘读当前团队(captain 会话 → 团队)。 */
|
|
168
|
-
export async function readCaptainTeam({ stateRoot, captainSessionId, findTeamByCaptain }) {
|
|
169
|
-
const team = await findTeamByCaptain(stateRoot, captainSessionId);
|
|
170
|
-
return team;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export { readTeam };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Remote 线格式的**单一 schema 源**(A-2 / A-3)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有这个文件:这些 schema 过去在 host(`lib/remote.js`)与客户端(`src/client.jsx`)
|
|
6
|
+
* 各写了一份,**13 个同名定义逐字重复**。加一个 remote 方法或改一个字段要改两处,漏改一处
|
|
7
|
+
* 不会在加载期报错,只会在调用期炸(或更糟:静默把字段剥掉)。
|
|
8
|
+
*
|
|
9
|
+
* 权威在 **host**:它同时是生产方与校验方(`lib/remote.js` 的 DESCRIPTORS 用的就是这些对象),
|
|
10
|
+
* 客户端只是消费方。所以统一后 host 的校验强度不变,客户端变严——方向是安全的。
|
|
11
|
+
* 统一过程中修正了两处真实不一致:
|
|
12
|
+
* 1. `squadInputSchema.key` 客户端原来没有 `min(1).max(64)`;
|
|
13
|
+
* 2. 客户端原来把团队快照叫 `catalogSchema`、把 `teamMemberSchema` 内联写了一遍。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 本文件只放**纯 schema**,不放 host 专属的 `descriptor()` 或客户端专属的 `direct()` 信封
|
|
16
|
+
* —— 两端信封的字段(`id` 前缀、`result` 形状)本来就不同,强行合并反而更脆。
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
export const expertSchema = z.object({
|
|
21
|
+
slug: z.string(),
|
|
22
|
+
name: z.string(),
|
|
23
|
+
nameEn: z.string(),
|
|
24
|
+
description: z.string(),
|
|
25
|
+
descriptionEn: z.string(),
|
|
26
|
+
emoji: z.string(),
|
|
27
|
+
division: z.string(),
|
|
28
|
+
divisionZh: z.string(),
|
|
29
|
+
divisionEn: z.string(),
|
|
30
|
+
conflict: z.boolean().optional(),
|
|
31
|
+
/** 用户自建专家(可编辑/可删除);内置专家不带此标记。 */
|
|
32
|
+
custom: z.boolean().optional(),
|
|
33
|
+
/** 自建专家文件指纹:编辑/删除时回传,用于拦住并发覆盖。 */
|
|
34
|
+
hash: z.string().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** 新建/编辑自建专家的入参(面板表单字段)。 */
|
|
38
|
+
export const customExpertInputSchema = z.object({
|
|
39
|
+
slug: z.string().min(1).max(128).optional(),
|
|
40
|
+
name: z.string().min(1).max(200),
|
|
41
|
+
nameEn: z.string().max(200).optional(),
|
|
42
|
+
description: z.string().min(1).max(1000),
|
|
43
|
+
descriptionEn: z.string().max(1000).optional(),
|
|
44
|
+
emoji: z.string().max(16).optional(),
|
|
45
|
+
body: z.string().min(1).max(60000),
|
|
46
|
+
/** 落点分区(自建分区目录名);留空=默认分区。服务端仍会自己校验一遍。 */
|
|
47
|
+
division: z.string().max(128).optional(),
|
|
48
|
+
/** 新建分区时的中文显示名(落在 <customRoot>/divisions.json)。 */
|
|
49
|
+
divisionLabel: z.string().max(40).optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** `getCatalog` 及分类写回方法的返回快照。 */
|
|
53
|
+
export const catalogSnapshotSchema = z.object({
|
|
54
|
+
experts: z.array(expertSchema),
|
|
55
|
+
enabled: z.array(z.string()),
|
|
56
|
+
revision: z.number().int().min(0),
|
|
57
|
+
// 这两个字段必须声明:zod 会把 schema 里没有的键**直接剥掉**(实测),
|
|
58
|
+
// 漏声明时面板拿到的快照里根本没有它们,界面上表现为"分区标签是空的"。
|
|
59
|
+
customDivision: z.string().optional(),
|
|
60
|
+
customDivisionLabel: z.string().optional(),
|
|
61
|
+
/** 可选的落点分类(面板「分类」下拉与管理页):官方在前,其余按目录/声明发现。 */
|
|
62
|
+
categories: z.array(z.object({
|
|
63
|
+
key: z.string(),
|
|
64
|
+
label: z.string(),
|
|
65
|
+
official: z.boolean(),
|
|
66
|
+
count: z.number().int().min(0),
|
|
67
|
+
customCount: z.number().int().min(0),
|
|
68
|
+
})).optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const enabledStateSchema = z.object({
|
|
72
|
+
enabled: z.array(z.string()),
|
|
73
|
+
revision: z.number().int().min(0),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const promptSchema = z.object({ prompt: z.string() });
|
|
77
|
+
|
|
78
|
+
export const squadMemberSchema = z.object({
|
|
79
|
+
slug: z.string(),
|
|
80
|
+
role: z.string(),
|
|
81
|
+
name: z.string(),
|
|
82
|
+
nameZh: z.string(),
|
|
83
|
+
division: z.string(),
|
|
84
|
+
divisionZh: z.string(),
|
|
85
|
+
known: z.boolean(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const squadSchema = z.object({
|
|
89
|
+
key: z.string(),
|
|
90
|
+
description: z.string(),
|
|
91
|
+
aliases: z.array(z.string()),
|
|
92
|
+
taskPlanning: z.enum(["captain", "seed"]),
|
|
93
|
+
enabled: z.boolean(),
|
|
94
|
+
members: z.array(squadMemberSchema),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const rosterExpertSchema = z.object({
|
|
98
|
+
slug: z.string(),
|
|
99
|
+
name: z.string(),
|
|
100
|
+
nameZh: z.string(),
|
|
101
|
+
division: z.string(),
|
|
102
|
+
divisionZh: z.string(),
|
|
103
|
+
enabled: z.boolean(),
|
|
104
|
+
conflict: z.boolean(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const squadsSnapshotSchema = z.object({
|
|
108
|
+
revision: z.string(),
|
|
109
|
+
squads: z.array(squadSchema),
|
|
110
|
+
experts: z.array(rosterExpertSchema),
|
|
111
|
+
lastBuild: z.object({ at: z.number(), ok: z.boolean(), output: z.string() }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const teamMemberSchema = z.object({
|
|
115
|
+
name: z.string(),
|
|
116
|
+
role: z.string(),
|
|
117
|
+
status: z.string(),
|
|
118
|
+
activity: z.string(),
|
|
119
|
+
done: z.number(),
|
|
120
|
+
total: z.number(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const teamSchema = z.object({
|
|
124
|
+
teamId: z.string(),
|
|
125
|
+
name: z.string(),
|
|
126
|
+
workspace: z.string(),
|
|
127
|
+
captainSessionId: z.string(),
|
|
128
|
+
phase: z.string(),
|
|
129
|
+
halted: z.boolean(),
|
|
130
|
+
members: z.array(teamMemberSchema),
|
|
131
|
+
tasks: z.array(z.object({ id: z.string(), subject: z.string(), status: z.string(), assignee: z.string() })),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const teamsSnapshotSchema = z.object({ teams: z.array(teamSchema) });
|
|
135
|
+
|
|
136
|
+
export const startResultSchema = z.object({
|
|
137
|
+
profile: z.string(),
|
|
138
|
+
label: z.string(),
|
|
139
|
+
members: z.number(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const stopResultSchema = z.object({
|
|
143
|
+
teamName: z.string(),
|
|
144
|
+
cancelledTasks: z.number(),
|
|
145
|
+
alreadyHalted: z.boolean(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** 面板提交的小队定义(saveSquads 的入参)。 */
|
|
149
|
+
export const squadInputSchema = z.object({
|
|
150
|
+
key: z.string().min(1).max(64),
|
|
151
|
+
description: z.string(),
|
|
152
|
+
aliases: z.array(z.string()),
|
|
153
|
+
taskPlanning: z.enum(["captain", "seed"]),
|
|
154
|
+
enabled: z.boolean(),
|
|
155
|
+
members: z.array(z.string()),
|
|
156
|
+
});
|
package/lib/remote.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* T专家 remote 服务:把 host 侧的名册/启用状态/persona 通过 Typert Gateway 暴露给客户端面板。
|
|
3
4
|
*
|
|
@@ -6,142 +7,39 @@
|
|
|
6
7
|
*/
|
|
7
8
|
import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
8
9
|
import { z } from "zod";
|
|
9
|
-
import { CATALOG_SERVICE, SETTINGS_NAMESPACE, SQUAD_SERVICE, TEAM_SERVICE } from "./index.js";
|
|
10
|
+
import { CATALOG_SERVICE, SETTINGS_NAMESPACE, SQUAD_SERVICE, TEAM_SERVICE, UNKNOWN_SLUG_ERROR_CODE } from "./index.js";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const catalogSnapshotSchema = z.object({
|
|
44
|
-
experts: z.array(expertSchema),
|
|
45
|
-
enabled: z.array(z.string()),
|
|
46
|
-
revision: z.number().int().min(0),
|
|
47
|
-
// 这两个字段必须声明:zod 会把 schema 里没有的键**直接剥掉**(实测),
|
|
48
|
-
// 漏声明时面板拿到的快照里根本没有它们,界面上表现为"分区标签是空的"。
|
|
49
|
-
customDivision: z.string().optional(),
|
|
50
|
-
customDivisionLabel: z.string().optional(),
|
|
51
|
-
/** 可选的落点分类(面板「分类」下拉与管理页):官方在前,其余按目录/声明发现。 */
|
|
52
|
-
categories: z.array(z.object({
|
|
53
|
-
key: z.string(),
|
|
54
|
-
label: z.string(),
|
|
55
|
-
official: z.boolean(),
|
|
56
|
-
count: z.number().int().min(0),
|
|
57
|
-
customCount: z.number().int().min(0),
|
|
58
|
-
})).optional(),
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const enabledStateSchema = z.object({
|
|
62
|
-
enabled: z.array(z.string()),
|
|
63
|
-
revision: z.number().int().min(0),
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const promptSchema = z.object({ prompt: z.string() });
|
|
67
|
-
|
|
68
|
-
const squadMemberSchema = z.object({
|
|
69
|
-
slug: z.string(),
|
|
70
|
-
role: z.string(),
|
|
71
|
-
name: z.string(),
|
|
72
|
-
nameZh: z.string(),
|
|
73
|
-
division: z.string(),
|
|
74
|
-
divisionZh: z.string(),
|
|
75
|
-
known: z.boolean(),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const squadSchema = z.object({
|
|
79
|
-
key: z.string(),
|
|
80
|
-
description: z.string(),
|
|
81
|
-
aliases: z.array(z.string()),
|
|
82
|
-
taskPlanning: z.enum(["captain", "seed"]),
|
|
83
|
-
enabled: z.boolean(),
|
|
84
|
-
members: z.array(squadMemberSchema),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const rosterExpertSchema = z.object({
|
|
88
|
-
slug: z.string(),
|
|
89
|
-
name: z.string(),
|
|
90
|
-
nameZh: z.string(),
|
|
91
|
-
division: z.string(),
|
|
92
|
-
divisionZh: z.string(),
|
|
93
|
-
enabled: z.boolean(),
|
|
94
|
-
conflict: z.boolean(),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const squadsSnapshotSchema = z.object({
|
|
98
|
-
revision: z.string(),
|
|
99
|
-
squads: z.array(squadSchema),
|
|
100
|
-
experts: z.array(rosterExpertSchema),
|
|
101
|
-
lastBuild: z.object({ at: z.number(), ok: z.boolean(), output: z.string() }),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const teamMemberSchema = z.object({
|
|
105
|
-
name: z.string(),
|
|
106
|
-
role: z.string(),
|
|
107
|
-
status: z.string(),
|
|
108
|
-
activity: z.string(),
|
|
109
|
-
done: z.number(),
|
|
110
|
-
total: z.number(),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const teamSchema = z.object({
|
|
114
|
-
teamId: z.string(),
|
|
115
|
-
name: z.string(),
|
|
116
|
-
workspace: z.string(),
|
|
117
|
-
captainSessionId: z.string(),
|
|
118
|
-
phase: z.string(),
|
|
119
|
-
halted: z.boolean(),
|
|
120
|
-
members: z.array(teamMemberSchema),
|
|
121
|
-
tasks: z.array(z.object({ id: z.string(), subject: z.string(), status: z.string(), assignee: z.string() })),
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const teamsSnapshotSchema = z.object({ teams: z.array(teamSchema) });
|
|
125
|
-
const startResultSchema = z.object({
|
|
126
|
-
profile: z.string(),
|
|
127
|
-
label: z.string(),
|
|
128
|
-
members: z.number(),
|
|
129
|
-
});
|
|
130
|
-
const stopResultSchema = z.object({
|
|
131
|
-
teamName: z.string(),
|
|
132
|
-
cancelledTasks: z.number(),
|
|
133
|
-
alreadyHalted: z.boolean(),
|
|
134
|
-
});
|
|
12
|
+
/**
|
|
13
|
+
* 领域码形态:`<domain>/<reason>`(如 `tTeam/squads-invalid`)。
|
|
14
|
+
*
|
|
15
|
+
* 只有通过它校验的 `error.code` 才允许当 wire code 透传 —— 系统 errno(`EACCES`…)不是领域码,
|
|
16
|
+
* 透传出去会让客户端按 code 分支时把「文件权限」误认成业务失败类型(D-17)。口径与宿主网关一致
|
|
17
|
+
* (`<domain>/<reason>`,见 docs/cookbook/adding-a-remote-api.md)。
|
|
18
|
+
*/
|
|
19
|
+
const DOMAIN_CODE_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*\/[a-z0-9-]+$/u;
|
|
20
|
+
/** 与 `lib/index.js` 共用的「未知专家 slug」稳定码(不用显示文案做分类,见 D-3)。 */
|
|
21
|
+
const UNKNOWN_SLUG_CODE = UNKNOWN_SLUG_ERROR_CODE;
|
|
22
|
+
|
|
23
|
+
// A-2/A-3:线格式 schema 的**单一源**在 ./remote-schemas.js(客户端也从这里取),
|
|
24
|
+
// 不再在本文件重复定义一份。host 仍是权威(本文件是生产方与校验方)。
|
|
25
|
+
import {
|
|
26
|
+
catalogSnapshotSchema,
|
|
27
|
+
customExpertInputSchema,
|
|
28
|
+
enabledStateSchema,
|
|
29
|
+
expertSchema,
|
|
30
|
+
promptSchema,
|
|
31
|
+
rosterExpertSchema,
|
|
32
|
+
squadInputSchema,
|
|
33
|
+
squadMemberSchema,
|
|
34
|
+
squadSchema,
|
|
35
|
+
squadsSnapshotSchema,
|
|
36
|
+
startResultSchema,
|
|
37
|
+
stopResultSchema,
|
|
38
|
+
teamMemberSchema,
|
|
39
|
+
teamSchema,
|
|
40
|
+
teamsSnapshotSchema,
|
|
41
|
+
} from "./remote-schemas.js";
|
|
135
42
|
|
|
136
|
-
/** 面板提交的小队定义(saveSquads 的入参)。 */
|
|
137
|
-
const squadInputSchema = z.object({
|
|
138
|
-
key: z.string().min(1).max(64),
|
|
139
|
-
description: z.string(),
|
|
140
|
-
aliases: z.array(z.string()),
|
|
141
|
-
taskPlanning: z.enum(["captain", "seed"]),
|
|
142
|
-
enabled: z.boolean(),
|
|
143
|
-
members: z.array(z.string()),
|
|
144
|
-
});
|
|
145
43
|
|
|
146
44
|
/** 一个 direct 调用描述符。 */
|
|
147
45
|
function descriptor(method, parameters, typeSymbol, schema) {
|
|
@@ -337,6 +235,8 @@ function exposeRemoteMethods(Class, methods) {
|
|
|
337
235
|
access: { has: (object) => method in object, get: (object) => object[method] },
|
|
338
236
|
addInitializer: (initializer) => initializer.call(Object.create(Class.prototype)),
|
|
339
237
|
};
|
|
238
|
+
// @ts-expect-error 上游类型缺口:ClassMethodDecoratorContext 的形状比这里手工构造的上下文更严。
|
|
239
|
+
// 运行时有效(dsh-typert-protocol 的 addMarkerInitializer 只读 kind/name/static/private),见 verify 的 remote 小节。
|
|
340
240
|
Remote(method)(Class.prototype[method], context);
|
|
341
241
|
}
|
|
342
242
|
}
|
|
@@ -346,19 +246,44 @@ class TTeamRemote extends TypertRemoteService {
|
|
|
346
246
|
|
|
347
247
|
constructor(ctx) {
|
|
348
248
|
super(ctx, "tTeam");
|
|
249
|
+
// @ts-expect-error 上游类型缺口:TypertRegistry.register(contribution) 未写进 TypertRegistryContract。
|
|
250
|
+
// 运行时有效:DSH 实际提供的 TypertRegistry 类实现了它(dsh-typert-registry/lib/index.js:398)。
|
|
349
251
|
this.ctx.typert.register(TYPERT);
|
|
350
252
|
}
|
|
351
253
|
|
|
254
|
+
/**
|
|
255
|
+
* host 侧服务缺失的统一领域错误。
|
|
256
|
+
*
|
|
257
|
+
* 三个访问器(catalog / squads / teams)共用它:**不要**裸抛 `Error` —— 裸 Error 过线会被
|
|
258
|
+
* 网关折成 `gateway/internal`,领域信息全丢;也**不要**把「host 没加载」塞进别的领域码
|
|
259
|
+
* (例如 `tTeam/missing-expert`),那会把「服务不可用」显示成「专家不存在」(D-4)。
|
|
260
|
+
* @param name - 服务名(写进 details,便于面板区分是哪一个)。
|
|
261
|
+
* @param hint - 面向用户/面板的原因说明。
|
|
262
|
+
* @returns 带稳定 code 的 RemoteError。
|
|
263
|
+
*/
|
|
264
|
+
hostUnavailable(name, hint) {
|
|
265
|
+
// @ts-expect-error 上游类型缺口:RemoteErrorDetailsMap 是**闭合**的 gateway 错误码集合,业务码不在其中。
|
|
266
|
+
// 运行时有效且是设计用法:plugin 自己的 tTeam/* 稳定码(映射文本是**远端**它时自行分类的,见 remote.js 顶部的业务码说明)。
|
|
267
|
+
return new RemoteError("tTeam/host-unavailable", hint, { service: name });
|
|
268
|
+
}
|
|
269
|
+
|
|
352
270
|
/** 名册服务由 host 主插件通过 ctx.reflect.provide 提供。 */
|
|
353
271
|
catalog() {
|
|
354
272
|
const service = this.ctx.get(CATALOG_SERVICE);
|
|
355
|
-
if (service === undefined)
|
|
273
|
+
if (service === undefined) {
|
|
274
|
+
throw this.hostUnavailable(CATALOG_SERVICE, "T专家 名册服务不可用(host 插件未加载或已卸载)");
|
|
275
|
+
}
|
|
356
276
|
return service;
|
|
357
277
|
}
|
|
358
278
|
|
|
359
279
|
/** 动态名册:完整专家列表 + 已启用 slug + 修订号。 */
|
|
360
280
|
async getCatalog() {
|
|
361
|
-
|
|
281
|
+
try {
|
|
282
|
+
return await this.catalog().snapshot();
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// 服务缺失已在 catalog() 里铸成 tTeam/host-unavailable;这里只兜业务失败。
|
|
285
|
+
throw this.businessError(error, "tTeam/catalog-unreadable");
|
|
286
|
+
}
|
|
362
287
|
}
|
|
363
288
|
|
|
364
289
|
/** 整体替换启用列表;过期修订号由 settings 服务拒绝。 */
|
|
@@ -369,27 +294,55 @@ class TTeamRemote extends TypertRemoteService {
|
|
|
369
294
|
const message = error instanceof Error ? error.message : String(error);
|
|
370
295
|
const code = typeof error === "object" && error !== null ? error.code : undefined;
|
|
371
296
|
// 裸 Error 过线会退化成 gateway/internal 并丢掉 code,所以业务错误统一转成 RemoteError。
|
|
372
|
-
|
|
297
|
+
//
|
|
298
|
+
// 冲突**只认稳定 code**,不再按显示文案兜底(复核 F-3)。依据:
|
|
299
|
+
// · 宿主 SettingsConflictError 自带 `code = "SETTINGS_CONFLICT"`,且 dsh-settings 的
|
|
300
|
+
// Service Definition 在 JSDoc 里承诺「namespace 移动后拒绝写入即抛 SettingsConflictError」
|
|
301
|
+
// —— 也就是说这个 code 是**契约的一部分**,不是实现细节;
|
|
302
|
+
// · 原先那条 `/changed since it was read|another window|其他窗口/` 与 D-3 被修掉的根因同类:
|
|
303
|
+
// 用随宿主版本/locale 变的文案做身份判断,宿主一改措辞冲突就会被误归成 settings-failed;
|
|
304
|
+
// · 「其他窗口」在本仓根本不存在于宿主侧,纯属臆测的变体。
|
|
305
|
+
// 因此无 code 的同类错误会落到下面的 settings-failed(可见且可诊断),而不是被文案猜成冲突。
|
|
306
|
+
if (code === "SETTINGS_CONFLICT") {
|
|
307
|
+
// @ts-expect-error 上游类型缺口:同上,tTeam/* 业务码不在闭合的 RemoteErrorDetailsMap 里。
|
|
373
308
|
throw new RemoteError("tTeam/conflict", message, { expectedRevision: String(expectedRevision) });
|
|
374
309
|
}
|
|
375
|
-
|
|
310
|
+
// 未知专家 slug 由 host 侧抛带稳定 code 的错误(lib/index.js 的 customError)。
|
|
311
|
+
// **不再**用 message.includes("未知专家") 分类 —— 显示文案随 locale 变,用它做身份会让
|
|
312
|
+
// 非中文 locale(或任何文案改写)下的错误直接退化成裸 Error,客户端只剩 gateway/internal(D-3)。
|
|
313
|
+
if (code === UNKNOWN_SLUG_CODE) {
|
|
314
|
+
// @ts-expect-error 上游类型缺口:同上,tTeam/* 业务码不在闭合的 RemoteErrorDetailsMap 里。
|
|
376
315
|
throw new RemoteError("tTeam/unknown-expert", message, {});
|
|
377
316
|
}
|
|
378
|
-
throw error;
|
|
317
|
+
throw this.businessError(error, "tTeam/settings-failed");
|
|
379
318
|
}
|
|
380
319
|
}
|
|
381
320
|
|
|
382
321
|
/** 小队定义服务(设置页「小队」标签)。 */
|
|
383
322
|
squads() {
|
|
384
323
|
const service = this.ctx.get(SQUAD_SERVICE);
|
|
385
|
-
if (service === undefined)
|
|
324
|
+
if (service === undefined) {
|
|
325
|
+
throw this.hostUnavailable(SQUAD_SERVICE, "T专家 小队服务不可用(host 插件未加载)");
|
|
326
|
+
}
|
|
386
327
|
return service;
|
|
387
328
|
}
|
|
388
329
|
|
|
389
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* 团队运行服务(设置页「团队」标签)。
|
|
332
|
+
*
|
|
333
|
+
* 这一处**刻意偏离**「缺服务一律 tTeam/host-unavailable」的统一口径,原因有两条,都记录在此:
|
|
334
|
+
* ① 语义上仍准确 —— 对 `startSquad` 来说「host 没挂团队服务」就是「这支小队起不来」,
|
|
335
|
+
* `tTeam/start-failed` 是可读的,而 `host-unavailable` 会丢掉「用户点的是启动」这层上下文;
|
|
336
|
+
* ② 向后兼容 —— 既有自检(verify.mjs 的 "startSquad 未知小队 → tTeam/start-failed")与客户端都把
|
|
337
|
+
* 这个码当作「启动失败」的分类键,改成新码会让唯一被断言的分类断裂。
|
|
338
|
+
* 缺的是 `catalog`/`squads` 那两个访问器(没有等价的历史承诺),它们统一走 hostUnavailable()。
|
|
339
|
+
*/
|
|
390
340
|
teams() {
|
|
391
341
|
const service = this.ctx.get(TEAM_SERVICE);
|
|
392
|
-
if (service === undefined)
|
|
342
|
+
if (service === undefined) {
|
|
343
|
+
// @ts-expect-error 上游类型缺口:同上,tTeam/* 业务码不在闭合的 RemoteErrorDetailsMap 里。
|
|
344
|
+
throw new RemoteError("tTeam/start-failed", "T专家 团队服务不可用(host 插件未加载)", { service: TEAM_SERVICE });
|
|
345
|
+
}
|
|
393
346
|
return service;
|
|
394
347
|
}
|
|
395
348
|
|
|
@@ -439,21 +392,31 @@ class TTeamRemote extends TypertRemoteService {
|
|
|
439
392
|
}
|
|
440
393
|
}
|
|
441
394
|
|
|
442
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* 业务错误统一转 RemoteError(裸 Error 过线会退化成 gateway/internal 并丢掉 code)。
|
|
397
|
+
*
|
|
398
|
+
* `error.code` 只有**形如 `<domain>/<reason>`** 才能当 wire code 用:系统 errno
|
|
399
|
+
* (`EACCES`/`ENOENT`/`EPERM`…)过线后被客户端按 code 分支时会误当领域码(D-17),
|
|
400
|
+
* 所以形态不符一律回落 `fallbackCode`。
|
|
401
|
+
* @param error - 被捕获的原错误。
|
|
402
|
+
* @param fallbackCode - 该访问器的领域码。
|
|
403
|
+
*/
|
|
443
404
|
businessError(error, fallbackCode) {
|
|
444
405
|
if (error instanceof RemoteError) return error;
|
|
445
406
|
const message = error instanceof Error ? error.message : String(error);
|
|
446
|
-
const
|
|
447
|
-
return new RemoteError(
|
|
407
|
+
const candidate = typeof error === "object" && error !== null && typeof error.code === "string" ? error.code : undefined;
|
|
408
|
+
return new RemoteError(DOMAIN_CODE_PATTERN.test(candidate ?? "") ? candidate : fallbackCode, message, {});
|
|
448
409
|
}
|
|
449
410
|
|
|
450
411
|
/** 按需读取一位专家的 persona 正文(面板预览用)。 */
|
|
451
412
|
async getPrompt(slug, division) {
|
|
413
|
+
// 先判服务:服务缺失要说「host 不可用」,不能说「专家不存在」——客户端把后者当
|
|
414
|
+
// 「这位专家不在名册里」,用户于是去一个根本没加载的名册里找人(D-4)。
|
|
415
|
+
const service = this.catalog();
|
|
452
416
|
try {
|
|
453
|
-
return await
|
|
417
|
+
return await service.prompt(slug, division);
|
|
454
418
|
} catch (error) {
|
|
455
|
-
|
|
456
|
-
throw new RemoteError("tTeam/missing-expert", message, { slug });
|
|
419
|
+
throw this.businessError(error, "tTeam/missing-expert");
|
|
457
420
|
}
|
|
458
421
|
}
|
|
459
422
|
|