@stofone/dsh-agent-roster 0.5.0 → 0.6.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.
@@ -13,11 +13,85 @@
13
13
  "repo": "github:STofone/dsh-agent-roster",
14
14
  "ref": "main",
15
15
  "path": "examples",
16
- "rosters": ["core-review", "research"],
17
- "tags": ["starter", "review", "research"],
16
+ "rosters": [
17
+ "core-review",
18
+ "research"
19
+ ],
20
+ "tags": [
21
+ "starter",
22
+ "review",
23
+ "research"
24
+ ],
18
25
  "license": "MIT",
19
- "maintainers": ["STofone"],
26
+ "maintainers": [
27
+ "STofone"
28
+ ],
20
29
  "homepage": "https://github.com/STofone/dsh-agent-roster#examples"
30
+ },
31
+ {
32
+ "id": "agent-teams",
33
+ "title": "Agent Teams(wshobson/agents 迁移)",
34
+ "description": "把 wshobson/agents 的 agent-teams 插件(4.2 万星,MIT)迁成档案:队长、实现者、审查者、调试者,保留上游工具边界。",
35
+ "repo": "github:STofone/dsh-roster-agent-teams",
36
+ "ref": "main",
37
+ "path": "rosters",
38
+ "rosters": [
39
+ "agent-teams"
40
+ ],
41
+ "tags": [
42
+ "team",
43
+ "orchestration",
44
+ "claude-code",
45
+ "wshobson"
46
+ ],
47
+ "license": "MIT",
48
+ "maintainers": [
49
+ "STofone"
50
+ ],
51
+ "homepage": "https://github.com/STofone/dsh-roster-agent-teams"
52
+ },
53
+ {
54
+ "id": "metagpt-company",
55
+ "title": "MetaGPT 软件公司(迁移)",
56
+ "description": "MetaGPT(5 万星+,MIT)的软件公司角色:产品经理、架构师、项目经理、工程师,字段取自上游 Role 的 profile/goal/constraints。",
57
+ "repo": "github:STofone/dsh-roster-metagpt-company",
58
+ "ref": "main",
59
+ "path": "rosters",
60
+ "rosters": [
61
+ "metagpt-company"
62
+ ],
63
+ "tags": [
64
+ "software-company",
65
+ "sdlc",
66
+ "metagpt"
67
+ ],
68
+ "license": "MIT",
69
+ "maintainers": [
70
+ "STofone"
71
+ ],
72
+ "homepage": "https://github.com/STofone/dsh-roster-metagpt-company"
73
+ },
74
+ {
75
+ "id": "openmanus",
76
+ "title": "OpenManus 三件套(迁移)",
77
+ "description": "OpenManus(5.8 万星,MIT)的三个代理提示词:规划、编码、浏览器,systemPrompt 保留上游原文。",
78
+ "repo": "github:STofone/dsh-roster-openmanus",
79
+ "ref": "main",
80
+ "path": "rosters",
81
+ "rosters": [
82
+ "openmanus"
83
+ ],
84
+ "tags": [
85
+ "agent",
86
+ "browser",
87
+ "coding",
88
+ "openmanus"
89
+ ],
90
+ "license": "MIT",
91
+ "maintainers": [
92
+ "STofone"
93
+ ],
94
+ "homepage": "https://github.com/STofone/dsh-roster-openmanus"
21
95
  }
22
96
  ]
23
97
  }
package/index.js CHANGED
@@ -17,6 +17,7 @@ import { readFileSync } from "node:fs";
17
17
  import z from "@deepseek-ai/schemastery";
18
18
 
19
19
  import { configView } from "./lib/config-view.js";
20
+ import { applyCaptain, listSessions } from "./lib/apply.js";
20
21
  import { collectLibrary } from "./lib/library.js";
21
22
  import { parseDocumentText } from "./lib/parse.js";
22
23
  import { validateRoster } from "./lib/validate.js";
@@ -111,7 +112,9 @@ export function apply(ctx, config) {
111
112
  const query = new URL(req.url ?? "/", "http://localhost").searchParams;
112
113
  // 远程源是“附属信息”:拉不到也不能让本地档案库的响应失败,所以失败只体现在 remote.diagnostics 里。
113
114
  const remote = await loadRemotePanel(settings, { refresh: query.get("refresh") === "1" });
114
- sendJson(res, 200, { ...snapshot, remote });
115
+ // 会话列表也是“附属信息”:没有 sessionController(或它报错)时面板仍要能用。
116
+ const sessions = await listSessions(ctx.get?.("sessionController"));
117
+ sendJson(res, 200, { ...snapshot, remote, sessions });
115
118
  },
116
119
  "library route",
117
120
  );
@@ -238,6 +241,43 @@ export function apply(ctx, config) {
238
241
  "install route",
239
242
  );
240
243
 
244
+ register(
245
+ "/plugins/dsh-agent-roster/apply",
246
+ async (req, res) => {
247
+ if (guard(req, res)) return;
248
+ if (!requireMethod(req, res, "POST")) return;
249
+ const body = await readJsonBody(req);
250
+ if (!body.ok) {
251
+ sendError(res, body.status, body.code);
252
+ return;
253
+ }
254
+ const input = body.value !== null && typeof body.value === "object" ? body.value : {};
255
+ const rosterId = typeof input.rosterId === "string" ? input.rosterId : "";
256
+ if (rosterId === "") {
257
+ sendError(res, 400, "missing-roster");
258
+ return;
259
+ }
260
+ // 只允许对“档案库里真实存在且校验通过”的档案动手:输入不构造路径,也不走缓存。
261
+ const library = collectLibrary(ctx, settings, { name: pkg.name, version: pkg.version });
262
+ const rosters = library.workspaces.flatMap((workspace) => workspace.rosters);
263
+ const roster = rosters.find((item) => item.id === rosterId);
264
+ if (roster === undefined) {
265
+ sendJson(res, 404, { ok: false, error: { code: "roster-not-found" }, known: rosters.map((item) => item.id) });
266
+ return;
267
+ }
268
+ const result = await applyCaptain({
269
+ sessionController: ctx.get?.("sessionController"),
270
+ roster,
271
+ mode: input.mode === "new" ? "new" : "existing",
272
+ sessionId: typeof input.sessionId === "string" ? input.sessionId : "",
273
+ cwd: typeof input.cwd === "string" ? input.cwd : "",
274
+ });
275
+ const status = result.ok ? 200 : result.code === "session-controller-unavailable" ? 503 : 400;
276
+ sendJson(res, status, result);
277
+ },
278
+ "apply route",
279
+ );
280
+
241
281
  register(
242
282
  "/plugins/dsh-agent-roster/rosters",
243
283
  async (req, res) => {
package/lib/apply.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * “应用队长档案”:把一份档案搬到某个会话里,让那个会话按档案去建团队。
3
+ *
4
+ * 为什么是这个形态:队长**不是**能被 `agent_teams_add_member` 创建出来的对象,
5
+ * 它就是"创建团队的那个会话"。所以要"应用队长档案",能做的只有三件事:
6
+ *
7
+ * 1. 选一个会话(已有的,或新建一个);
8
+ * 2. 把档案声明的模型选到那个会话上(`sessionController.selectModel`);
9
+ * 3. 往那个会话里排一条提示,让它按档案建团队(`sessionController.prompt`)。
10
+ *
11
+ * 第 2 步是**真的写进运行时**(模型选择是会话级持久投影),第 3 步只是让会话开始干活;
12
+ * 人格(persona)仍然来自部署的 agent preset,档案里只能声明,改不了。
13
+ *
14
+ * 这一层不 import 任何宿主包:`sessionController` 由调用方传进来,方便测试。
15
+ *
16
+ * @module dsh-agent-roster/apply
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+
20
+ import { MEMBER_FIELDS } from "./format.js";
21
+
22
+ /**
23
+ * 会话头上的模型投影:`next` 是下一次请求要用的,`lastUsed` 是上一次真正用过的。
24
+ *
25
+ * @param {object | null | undefined} summary `SessionSummary`
26
+ * @returns {{ provider: string, model: string, reasoningEffort: string } | null}
27
+ */
28
+ export function pickSessionModel(summary) {
29
+ const values = summary?.projections?.values;
30
+ const projection = values?.modelSelection;
31
+ const selection = projection?.next ?? projection?.lastUsed ?? null;
32
+ if (selection === null || typeof selection !== "object") return null;
33
+ return {
34
+ provider: typeof selection.provider === "string" ? selection.provider : "",
35
+ model: typeof selection.model === "string" ? selection.model : "",
36
+ reasoningEffort: typeof selection.reasoningEffort === "string" ? selection.reasoningEffort : "",
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 列可选会话(剔除子代理会话,它们不是队长候选)。
42
+ *
43
+ * @param {object | null | undefined} sessionController 宿主 `sessionController` 服务
44
+ * @returns {Promise<{ ok: boolean, code?: string, sessions: object[] }>}
45
+ */
46
+ export async function listSessions(sessionController) {
47
+ if (sessionController === null || typeof sessionController !== "object" || typeof sessionController.list !== "function") {
48
+ return { ok: false, code: "session-controller-unavailable", sessions: [] };
49
+ }
50
+ try {
51
+ const page = await sessionController.list({});
52
+ const items = Array.isArray(page?.items) ? page.items : [];
53
+ const sessions = items
54
+ .filter((item) => item?.origin !== "subagent")
55
+ .map((item) => ({
56
+ sessionId: String(item.sessionId ?? ""),
57
+ cwd: typeof item.cwd === "string" ? item.cwd : "",
58
+ updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : 0,
59
+ running: item.running === true,
60
+ blank: item.blank === true,
61
+ model: pickSessionModel(item),
62
+ }))
63
+ .filter((item) => item.sessionId !== "");
64
+ return { ok: true, sessions };
65
+ } catch (error) {
66
+ return { ok: false, code: "session-list-failed", reason: String(error?.message ?? error), sessions: [] };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 一段能给会话照着执行的提示词。
72
+ *
73
+ * 只写"要它做什么"和"从哪拿参数",不复制成员人设原文:人设可能很长,
74
+ * 而 `roster_show` 已经会把它和建员参数一起给出,重复一遍只会浪费上下文。
75
+ *
76
+ * @param {{ roster: object, teamName?: string }} input
77
+ * @returns {string}
78
+ */
79
+ export function buildCaptainPrompt(input) {
80
+ const roster = input.roster;
81
+ const id = String(roster?.id ?? "");
82
+ const title = typeof roster?.title === "string" && roster.title !== "" ? roster.title : id;
83
+ const teamName = typeof input.teamName === "string" && input.teamName !== "" ? input.teamName : id;
84
+ const members = Array.isArray(roster?.members) ? roster.members : [];
85
+ const table = members
86
+ .map((member) => {
87
+ const bits = [`name: ${member.name}`, `role: ${member.role}`];
88
+ for (const field of MEMBER_FIELDS) {
89
+ if (field === "name" || field === "role" || field === "systemPrompt") continue;
90
+ const value = member[field];
91
+ if (value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) continue;
92
+ bits.push(`${field}: ${Array.isArray(value) ? value.join(",") : value}`);
93
+ }
94
+ return `- ${bits.join(" | ")}`;
95
+ })
96
+ .join("\n");
97
+
98
+ return [
99
+ `请按档案 \`${id}\`(${title})创建一个多 Agent 团队,队长就是你(当前会话)。`,
100
+ "",
101
+ "步骤:",
102
+ `1. 调用 \`roster_show\`,参数 id: "${id}"。它会给出每个成员的“建员参数”(含人设原文),请以它为准。`,
103
+ `2. 调用 \`agent_teams_create\`,参数 name: "${teamName}",description 用档案的 description 或你自己概括,approval: "automatic"。`,
104
+ `3. 对每个成员调用 \`agent_teams_add_member\`:name / role / executionPrompt 用人设原文,provider / model / reasoningEffort / toolPolicy 按建员参数填;provenance 写 "roster:${id}"。`,
105
+ `4. 成员都加上后调用 \`roster_verify\`(teamId 用上一步返回的团队 id),确认每个成员的字段是否如实进了运行时。`,
106
+ "5. 把核对结果摘要给我(哪几个成员、哪些字段 not-carried),不要粘贴成员人设原文。",
107
+ "",
108
+ "本次要建的成员:",
109
+ table === "" ? "- (档案里没有成员)" : table,
110
+ "",
111
+ "注意:你的模型/人格不来自这份档案——档案的 captain 段只是声明;要改队长本身,得改部署配置里的 agent-default-model 与 preset persona。档案里的成员模型/provider 若与事实不符,以档案为准去 add_member,不要臆造。",
112
+ ].join("\n");
113
+ }
114
+
115
+ /**
116
+ * 把档案“应用”到一个会话:新建或复用会话 → 选模型 → 排提示。
117
+ *
118
+ * @param {object} input
119
+ * @param {object | null | undefined} input.sessionController 宿主 `sessionController` 服务
120
+ * @param {object} input.roster 已校验的档案(含 captain 段)
121
+ * @param {"new" | "existing"} input.mode
122
+ * @param {string} [input.sessionId] `existing` 时必填
123
+ * @param {string} [input.cwd] `new` 时可指定会话工作目录,缺省用当前工作区
124
+ * @param {string} [input.requestId] 便于测试固定 requestId
125
+ * @returns {Promise<object>}
126
+ */
127
+ export async function applyCaptain(input) {
128
+ const sessionController = input.sessionController;
129
+ if (
130
+ sessionController === null ||
131
+ typeof sessionController !== "object" ||
132
+ typeof sessionController.create !== "function" ||
133
+ typeof sessionController.prompt !== "function"
134
+ ) {
135
+ return { ok: false, code: "session-controller-unavailable" };
136
+ }
137
+
138
+ const roster = input.roster;
139
+ const captain = roster?.captain ?? {};
140
+ const mode = input.mode === "new" ? "new" : "existing";
141
+
142
+ let sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
143
+ if (mode === "new") {
144
+ try {
145
+ const created = await sessionController.create(input.cwd ? { cwd: input.cwd } : {});
146
+ sessionId = String(created?.sessionId ?? "");
147
+ } catch (error) {
148
+ return { ok: false, code: "session-create-failed", reason: String(error?.message ?? error) };
149
+ }
150
+ if (sessionId === "") return { ok: false, code: "session-create-failed" };
151
+ } else if (sessionId === "") {
152
+ return { ok: false, code: "missing-session" };
153
+ }
154
+
155
+ let model = null;
156
+ const provider = typeof captain.provider === "string" ? captain.provider : "";
157
+ const modelId = typeof captain.model === "string" ? captain.model : "";
158
+ if (provider !== "" && modelId !== "") {
159
+ if (typeof sessionController.selectModel !== "function") {
160
+ return { ok: false, code: "model-selection-unavailable", sessionId };
161
+ }
162
+ const selection = { sessionId, provider, model: modelId };
163
+ const effort = typeof captain.reasoningEffort === "string" ? captain.reasoningEffort : "";
164
+ if (effort !== "" && effort !== "default") selection.reasoningEffort = effort;
165
+ try {
166
+ const applied = await sessionController.selectModel(selection);
167
+ model = applied?.selected ?? { provider, model: modelId, reasoningEffort: selection.reasoningEffort ?? "" };
168
+ } catch (error) {
169
+ // 选不上就**不要**继续排提示:那样会建出一个模型与档案不符的团队,比直接失败更难查。
170
+ return { ok: false, code: "model-not-applied", sessionId, reason: String(error?.message ?? error) };
171
+ }
172
+ }
173
+
174
+ try {
175
+ await sessionController.prompt({
176
+ requestId: typeof input.requestId === "string" && input.requestId !== "" ? input.requestId : randomUUID(),
177
+ sessionId,
178
+ mode: "queue",
179
+ content: [{ type: "text", text: buildCaptainPrompt({ roster, teamName: input.teamName }) }],
180
+ });
181
+ } catch (error) {
182
+ return { ok: false, code: "prompt-not-queued", sessionId, reason: String(error?.message ?? error) };
183
+ }
184
+
185
+ return { ok: true, sessionId, model, queued: true, mode };
186
+ }
package/lib/captain.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 队长(captain)侧:把部署里真实写着的队长配置读出来,和档案里的声明逐字段核对。
3
+ *
4
+ * 为什么需要单独一层:队长**不是一个成员**。agent-teams 的 state 里只有
5
+ * `captainSessionId` 与 `captainInbox`,没有 captain 成员行,也没有任何工具参数能改写
6
+ * 队长的路由——队长就是"创建团队的那个会话",它的模型来自部署配置
7
+ * (profile 的 `agent-default-model`),它的人格来自 agent preset 的 persona。
8
+ *
9
+ * 所以档案里的 `captain` 段只能声明,落地要动部署配置。这一层负责两件事:
10
+ * 1. 从 profile 的 `cordis.patch.yml` 里读出**实际值**(provider / model / persona 前缀);
11
+ * 2. 把实际值和声明逐字段比对,给出 `match / differ / undeclared / unknown` 四态。
12
+ *
13
+ * 解析只用行扫描、不引 YAML 解析器:这些 patch 文件里含 `!!js` 标签,
14
+ * 通用 YAML 解析会直接抛错;而我们要的字段全是顶格的行结构。
15
+ *
16
+ * @module dsh-agent-roster/captain
17
+ */
18
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
19
+ import nodePath from "node:path";
20
+
21
+ import { displayDshHome, resolveDshHome } from "./home.js";
22
+
23
+ /** 部署配置里承载队长模型的插件行 id。 */
24
+ export const MODEL_ROW_ID = "agent-default-model";
25
+
26
+ /** agent preset 行的 id 前缀。 */
27
+ export const PRESET_ID_PREFIX = "preset-";
28
+
29
+ const PATCH_FILE_NAME = "cordis.patch.yml";
30
+
31
+ /**
32
+ * 去掉包裹用的引号(只处理配对的首尾引号,不碰内部字符)。
33
+ *
34
+ * @param {string} text
35
+ * @returns {string}
36
+ */
37
+ function unquote(text) {
38
+ const value = text.trim();
39
+ if (value.length >= 2) {
40
+ const first = value[0];
41
+ const last = value[value.length - 1];
42
+ if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
43
+ return value.slice(1, -1);
44
+ }
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * 解析 patch 文本,返回**所有** preset 的 persona 前缀(上面的单行扫描已按 preset 分组,
51
+ * 这里补一份完整列表,便于核对任意 preset)。
52
+ *
53
+ * @param {string} text
54
+ * @param {string} profile
55
+ * @returns {{ profile: string, provider: string, model: string, reasoningEffort: string, personas: object[] }}
56
+ */
57
+ export function parseDeployedProfile(text, profile) {
58
+ const lines = String(text ?? "").split(/\r?\n/);
59
+ const result = { profile, provider: "", model: "", reasoningEffort: "", personas: [] };
60
+
61
+ let row = null;
62
+ let inPersona = false;
63
+ let blockIndent = -1;
64
+ let blockLines = [];
65
+
66
+ const flushBlock = () => {
67
+ if (blockLines.length > 0 && row?.kind === "preset") {
68
+ row.personaPrefixes.push(blockLines.join("\n").trim());
69
+ }
70
+ blockLines = [];
71
+ blockIndent = -1;
72
+ };
73
+
74
+ const closePreset = () => {
75
+ if (row?.kind === "preset") {
76
+ for (const prefix of row.personaPrefixes) {
77
+ result.personas.push({ preset: row.id, prefix });
78
+ }
79
+ }
80
+ };
81
+
82
+ for (const line of lines) {
83
+ if (blockIndent >= 0) {
84
+ const indent = line.length - line.trimStart().length;
85
+ if (line.trim() !== "" && indent >= blockIndent) {
86
+ blockLines.push(line.trim());
87
+ continue;
88
+ }
89
+ flushBlock();
90
+ }
91
+
92
+ const top = /^-\s+id:\s*(\S+)\s*$/.exec(line);
93
+ if (top !== null) {
94
+ closePreset();
95
+ const id = top[1];
96
+ if (id === MODEL_ROW_ID) row = { kind: "model" };
97
+ else if (id.startsWith(PRESET_ID_PREFIX)) row = { kind: "preset", id, personaPrefixes: [] };
98
+ else row = { kind: "other" };
99
+ inPersona = false;
100
+ continue;
101
+ }
102
+ if (row === null) continue;
103
+
104
+ if (row.kind === "model") {
105
+ const field = /^\s+(provider|model|reasoningEffort):\s*(.+?)\s*$/.exec(line);
106
+ if (field !== null) {
107
+ const value = unquote(field[2]);
108
+ if (field[1] === "provider") result.provider = value;
109
+ else if (field[1] === "model") result.model = value;
110
+ else result.reasoningEffort = value;
111
+ }
112
+ continue;
113
+ }
114
+
115
+ if (row.kind === "preset") {
116
+ const sub = /^\s+-\s+id:\s*(\S+)\s*$/.exec(line);
117
+ if (sub !== null) {
118
+ inPersona = sub[1] === "persona";
119
+ continue;
120
+ }
121
+ if (!inPersona) continue;
122
+ const prefix = /^(\s+)prefix:\s*(.*)$/.exec(line);
123
+ if (prefix === null) continue;
124
+ const value = prefix[2].trim();
125
+ if (value === "|" || value === ">" || value === "|-" || value === ">-") {
126
+ blockIndent = prefix[1].length + 1;
127
+ blockLines = [];
128
+ continue;
129
+ }
130
+ if (value !== "") row.personaPrefixes.push(unquote(value));
131
+ }
132
+ }
133
+ flushBlock();
134
+ closePreset();
135
+
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * 读本机所有 profile 的队长配置。
141
+ *
142
+ * @param {{ dshHome?: string, env?: Record<string, string | undefined> }} [options]
143
+ * @returns {{ home: string, displayed: string, profiles: object[], diagnostics: object[] }}
144
+ */
145
+ export function readDeployedCaptains(options = {}) {
146
+ const env = options.env ?? process.env;
147
+ const home = resolveDshHome(options.dshHome, env);
148
+ const profilesDir = nodePath.join(home, "profiles");
149
+ const diagnostics = [];
150
+ const profiles = [];
151
+
152
+ if (!existsSync(profilesDir)) {
153
+ return { home, displayed: displayDshHome(home, env), profiles, diagnostics };
154
+ }
155
+
156
+ let names = [];
157
+ try {
158
+ names = readdirSync(profilesDir, { withFileTypes: true })
159
+ .filter((entry) => entry.isDirectory())
160
+ .map((entry) => entry.name)
161
+ .sort();
162
+ } catch (error) {
163
+ diagnostics.push({
164
+ severity: "warn",
165
+ code: "profiles-unreadable",
166
+ reason: String(error?.message ?? error),
167
+ });
168
+ return { home, displayed: displayDshHome(home, env), profiles, diagnostics };
169
+ }
170
+
171
+ for (const name of names) {
172
+ const file = nodePath.join(profilesDir, name, PATCH_FILE_NAME);
173
+ if (!existsSync(file)) continue;
174
+ try {
175
+ profiles.push(parseDeployedProfile(readFileSync(file, "utf8"), name));
176
+ } catch (error) {
177
+ diagnostics.push({
178
+ severity: "warn",
179
+ code: "profile-unreadable",
180
+ params: { profile: name },
181
+ reason: String(error?.message ?? error),
182
+ });
183
+ }
184
+ }
185
+
186
+ return { home, displayed: displayDshHome(home, env), profiles, diagnostics };
187
+ }
188
+
189
+ /**
190
+ * 逐个字段比对"档案声明"与"部署实际值"。
191
+ *
192
+ * 四态含义:
193
+ * - `match`:声明了且部署一致;
194
+ * - `differ`:声明了但部署不一致(要改部署配置,改档案没用);
195
+ * - `undeclared`:档案没声明,运行时用部署默认(不是错误,只是没有约束);
196
+ * - `unknown`:读不到部署配置,无法判断(也不能假装通过)。
197
+ *
198
+ * @param {object | null | undefined} captain 档案的 captain 段
199
+ * @param {{ profiles?: object[], displayed?: string }} deployed `readDeployedCaptains` 的结果
200
+ * @returns {{ checks: object[], ok: boolean, target: object | null, profiles: object[] }}
201
+ */
202
+ export function verifyCaptain(captain, deployed = {}) {
203
+ const declared = captain !== null && typeof captain === "object" ? captain : {};
204
+ const profiles = Array.isArray(deployed.profiles) ? deployed.profiles : [];
205
+ // 多 profile 时无法确定"这个会话到底跑哪一个",所以只给出每个字段的"任一匹配"结论,
206
+ // 并把候选 profile 名单一起返回,让界面说清楚"这是哪个 profile 的值"。
207
+ const target = profiles.length === 1 ? profiles[0] : null;
208
+ const compare = (field, declaredValue, actualValues) => {
209
+ const values = actualValues.filter((value) => typeof value === "string" && value !== "");
210
+ if (typeof declaredValue !== "string" || declaredValue === "") {
211
+ const fallback = values.length > 0 ? values[0] : "";
212
+ return {
213
+ field,
214
+ status: fallback === "" ? "undeclared" : "undeclared",
215
+ declared: "",
216
+ actual: fallback,
217
+ };
218
+ }
219
+ if (values.length === 0) {
220
+ return { field, status: "unknown", declared: declaredValue, actual: "" };
221
+ }
222
+ const hit = values.includes(declaredValue);
223
+ return {
224
+ field,
225
+ status: hit ? "match" : "differ",
226
+ declared: declaredValue,
227
+ actual: values.join(", "),
228
+ };
229
+ };
230
+
231
+ const checks = [
232
+ compare("provider", declared.provider, profiles.map((profile) => profile.provider)),
233
+ compare("model", declared.model, profiles.map((profile) => profile.model)),
234
+ compare(
235
+ "reasoningEffort",
236
+ declared.reasoningEffort === "default" ? "" : declared.reasoningEffort,
237
+ profiles.map((profile) => profile.reasoningEffort),
238
+ ),
239
+ ];
240
+
241
+ const personaPrefix = typeof declared.personaPrefix === "string" ? declared.personaPrefix.trim() : "";
242
+ const prefixes = profiles.flatMap((profile) => profile.personas.map((persona) => persona.prefix));
243
+ if (personaPrefix === "") {
244
+ checks.push({ field: "personaPrefix", status: prefixes.length > 0 ? "undeclared" : "unknown", declared: "", actual: prefixes[0] ?? "" });
245
+ } else if (prefixes.length === 0) {
246
+ checks.push({ field: "personaPrefix", status: "unknown", declared: personaPrefix, actual: "" });
247
+ } else {
248
+ const hit = prefixes.some((prefix) => prefix.trim() === personaPrefix);
249
+ checks.push({
250
+ field: "personaPrefix",
251
+ status: hit ? "match" : "differ",
252
+ declared: personaPrefix,
253
+ actual: prefixes[0],
254
+ });
255
+ }
256
+
257
+ const ok = checks.every((check) => check.status === "match" || check.status === "undeclared");
258
+ return { checks, ok, target, profiles };
259
+ }
@@ -31,21 +31,24 @@ function read(cell, fallback) {
31
31
  /**
32
32
  * 把 Config 解析结果包成带 getter 的视图。
33
33
  *
34
- * 只暴露插件真正使用的三项;其余字段一律不转发,避免下游误把引用对象当数据用。
34
+ * 只转发插件真正使用的字段;其余一律不透传,避免下游误把引用对象当数据用。
35
35
  *
36
36
  * @param {object} config `apply(ctx, config)` 收到的配置
37
37
  * @returns {{
38
38
  * rosterDirs: string[],
39
39
  * knownToolPolicies: string[],
40
40
  * agentTeamsStateDir: string,
41
+ * indexes: unknown,
42
+ * region: string,
41
43
  * }}
42
44
  */
43
45
  export function configView(config) {
44
46
  const source = config ?? {};
45
47
  return {
46
48
  get rosterDirs() {
47
- const dirs = read(source.rosterDirs, ["rosters"]);
48
- return Array.isArray(dirs) ? dirs : ["rosters"];
49
+ // 缺省 = 空串 = 机器级全局库($DSH_HOME/agent-roster),与 0.3.0 起的默认一致。
50
+ const dirs = read(source.rosterDirs, [""]);
51
+ return Array.isArray(dirs) ? dirs : [""];
49
52
  },
50
53
  get knownToolPolicies() {
51
54
  const policies = read(source.knownToolPolicies, []);
@@ -55,5 +58,19 @@ export function configView(config) {
55
58
  const dir = read(source.agentTeamsStateDir, ".agent-teams");
56
59
  return typeof dir === "string" && dir !== "" ? dir : ".agent-teams";
57
60
  },
61
+ /**
62
+ * 索引源。三种取值都必须原样保留:
63
+ * `undefined`/空数组 = 官方源;显式 `false` = 完全关掉远端;数组 = 自定义源。
64
+ */
65
+ get indexes() {
66
+ if (typeof source.indexes === "object" && source.indexes !== null && typeof source.indexes.get === "function") {
67
+ return source.indexes.get();
68
+ }
69
+ return source.indexes;
70
+ },
71
+ get region() {
72
+ const region = read(source.region, "auto");
73
+ return typeof region === "string" && region !== "" ? region : "auto";
74
+ },
58
75
  };
59
76
  }
package/lib/render.js CHANGED
@@ -309,6 +309,31 @@ export function renderCaptainSection(captain) {
309
309
  }
310
310
 
311
311
  /** 状态标签,供 UI 与报告共用。 */
312
+ /**
313
+ * 队长核对结果:逐字段给「一致 / 不一致 / 未声明 / 无法核对」。
314
+ *
315
+ * 四态是刻意的:读不到部署配置时不能写成「通过」,也不能写成「不一致」。
316
+ *
317
+ * @param {{ checks?: object[] }} verified
318
+ * @param {{ displayed?: string }} [options]
319
+ * @returns {string}
320
+ */
321
+ export function renderCaptainVerification(verified, options = {}) {
322
+ const lines = [];
323
+ const where = options.displayed === undefined || options.displayed === "" ? "" : `(部署:${options.displayed})`;
324
+ lines.push(`队长档案核对${where}`);
325
+ for (const check of verified.checks ?? []) {
326
+ if (check.status === "match") lines.push(` ✓ 一致 ${check.field} ${check.declared}`);
327
+ else if (check.status === "differ") {
328
+ lines.push(` ✗ 不一致 ${check.field} 声明 ${check.declared || "(空)"} / 部署 ${check.actual || "(空)"}`);
329
+ } else if (check.status === "undeclared") {
330
+ lines.push(` · 未声明 ${check.field} 未声明,运行时用部署默认${check.actual === "" ? "" : "(" + check.actual + ")"}`);
331
+ } else lines.push(` ? 无法核对 ${check.field} 读不到部署配置`);
332
+ }
333
+ lines.push(" 说明:队长=创建团队的那个会话,没有成员行;模型来自部署 agent-default-model,人格来自 preset 的 persona。");
334
+ return lines.join("\n");
335
+ }
336
+
312
337
  export function statusLabel(status) {
313
338
  return BINDING_STATUS[status] ?? status;
314
339
  }