dsh-plugin-t-expert 0.2.6 → 0.2.9
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 +21 -5
- package/THIRD-PARTY-NOTICES +17 -6
- package/data/experts/engineering/engineering-deepseek-harness-project-expert.md +256 -0
- package/data/experts/engineering/engineering-typescript-npm-stack-maintainer.md +277 -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 +16 -13
- package/data/zh/descriptions.json +3 -1
- package/data/zh/divisions.json +1 -1
- package/data/zh/names.json +3 -1
- package/lib/catalog.js +134 -31
- package/lib/client.js +1 -1
- package/lib/command.js +4 -1
- package/lib/i18n.js +75 -11
- package/lib/index.js +169 -58
- package/lib/plan-check.js +14 -3
- package/lib/remote.js +82 -15
- package/lib/skill.js +209 -0
- package/lib/squads.js +51 -3
- package/lib/teams/assignee-contract.js +47 -0
- package/lib/teams/quality-gates.js +24 -1
- package/lib/teams/state.js +14 -2
- package/lib/teams/tools.js +9 -3
- package/package.json +11 -24
- 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 +110 -0
- package/skills/t-expert-manager/references/ops-reference.md +100 -0
- package/vendor/third-party-licenses/README.md +1 -1
package/lib/catalog.js
CHANGED
|
@@ -79,19 +79,29 @@ export async function loadDivisionLabels(zhRoot) {
|
|
|
79
79
|
* 这样新增的分类不必改代码就能被扫描到。
|
|
80
80
|
*/
|
|
81
81
|
export async function discoverDivisions(root) {
|
|
82
|
+
const report = { found: [], unreadable: [], rootError: undefined };
|
|
82
83
|
let entries;
|
|
83
84
|
try {
|
|
84
85
|
entries = await readdir(root, { withFileTypes: true });
|
|
85
|
-
} catch {
|
|
86
|
-
|
|
86
|
+
} catch (error) {
|
|
87
|
+
report.rootError = error;
|
|
88
|
+
return report;
|
|
87
89
|
}
|
|
88
|
-
const found = [];
|
|
89
90
|
for (const entry of entries) {
|
|
90
91
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
let files;
|
|
93
|
+
try {
|
|
94
|
+
files = await readdir(join(root, entry.name));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// 分区目录读不动 —— 过去这里 `.catch(() => [])` 把它当成「没有 .md 的空分区」丢掉,
|
|
97
|
+
// 于是该分区整批专家静默缺席(单个分区不可读即可让名册 316→244,见 D-18)。
|
|
98
|
+
report.unreadable.push({ division: entry.name, error });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (files.some((name) => name.endsWith(".md"))) report.found.push(entry.name);
|
|
93
102
|
}
|
|
94
|
-
|
|
103
|
+
report.found.sort();
|
|
104
|
+
return report;
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
@@ -150,14 +160,17 @@ export function truncate(text, limit) {
|
|
|
150
160
|
return value.length > limit ? `${value.slice(0, Math.max(0, limit - 1))}…` : value;
|
|
151
161
|
}
|
|
152
162
|
|
|
153
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* 递归收集目录下的 .md 文件(跳过符号链接,与 DSH 的扫描口径一致)。
|
|
165
|
+
*
|
|
166
|
+
* 读目录失败**不再静默返回**:整个分区不可读时(EACCES 等)那批专家会**悄悄**从名册里消失
|
|
167
|
+
* ——实测单个分区不可读就能让 316 位掉到 244 位且零日志(D-18)。现在错误带路径抛给调用方,
|
|
168
|
+
* 由调用方逐分区记 warn,名册照旧返回其余可读的部分。
|
|
169
|
+
* @param dir - 要递归的目录。
|
|
170
|
+
* @param onFile - 每个 .md 文件的回调 `(fullPath, fileName)`。
|
|
171
|
+
*/
|
|
154
172
|
async function walkMarkdown(dir, onFile) {
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
158
|
-
} catch {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
173
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
161
174
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
162
175
|
for (const entry of entries) {
|
|
163
176
|
const full = join(dir, entry.name);
|
|
@@ -188,41 +201,90 @@ export async function loadCatalog(root, divisions, options = {}) {
|
|
|
188
201
|
await assertDirectory(root);
|
|
189
202
|
const zhRoot = typeof options.zhRoot === "string" && options.zhRoot !== "" ? options.zhRoot : undefined;
|
|
190
203
|
const customRoot = typeof options.customRoot === "string" && options.customRoot !== "" ? options.customRoot : undefined;
|
|
204
|
+
/** 宿主 logger(可选):所有「本该说话却过去没说」的地方都走它,不再 console(N-3)。 */
|
|
205
|
+
const logger = options.logger;
|
|
206
|
+
const warn = (message) => logger?.warn?.(message);
|
|
191
207
|
// 中文侧车目录:names.json / descriptions.json / <与 experts 同相对路径的 .md>。
|
|
192
208
|
// 译文永不写进 experts(那是随包发布的只读名册),所以名册更新不受影响。
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
209
|
+
// 侧车缺失是**自洽的错配**(它由 seedData 播种、路径来自 Config.zhRoot),必须可观察:
|
|
210
|
+
// 过去这里静默变成「全英文名册」,面板与工具都不报错(D-5)。
|
|
211
|
+
// 侧车「可用」= 目录存在**且**至少有一个内容锚点(names/descriptions/divisions/manual/manual-bodies
|
|
212
|
+
// 或任一分区目录)。只看目录存在是不够的:存在但空(或只剩 .DS_Store)时 seedData 会跳过播种,
|
|
213
|
+
// 于是中文名/简介全空却什么都不说 —— 正是 D-5 要消灭的静默降级。
|
|
214
|
+
const sidecarDirExists = zhRoot === undefined ? true : await isDirectory(zhRoot);
|
|
215
|
+
const sidecarAnchor = zhRoot === undefined || !sidecarDirExists
|
|
216
|
+
? undefined
|
|
217
|
+
: await firstExisting([
|
|
218
|
+
join(zhRoot, "names.json"),
|
|
219
|
+
join(zhRoot, "descriptions.json"),
|
|
220
|
+
join(zhRoot, "divisions.json"),
|
|
221
|
+
join(zhRoot, "manual.json"),
|
|
222
|
+
join(zhRoot, "manual-bodies"),
|
|
223
|
+
]);
|
|
224
|
+
const sidecarPresent = zhRoot === undefined || (sidecarDirExists && sidecarAnchor !== undefined);
|
|
225
|
+
if (zhRoot !== undefined && !sidecarDirExists) {
|
|
226
|
+
warn(`[t-team] 中文侧车目录不存在:${zhRoot}(中文名/简介/分类标签会全部回退英文;跑一次首启播种或把 zhRoot 指到正确位置)`);
|
|
227
|
+
} else if (zhRoot !== undefined && !sidecarPresent) {
|
|
228
|
+
warn(`[t-team] 中文侧车目录存在但没有任何内容(缺少 names.json/descriptions.json 等):${zhRoot}`
|
|
229
|
+
+ `(中文名/简介/分类标签会全部回退英文;删掉这个空目录让插件下次启动重新播种)`);
|
|
230
|
+
}
|
|
231
|
+
const zhNames = zhRoot === undefined || !sidecarPresent ? {} : await readJson(join(zhRoot, "names.json"));
|
|
232
|
+
const zhDescriptions = zhRoot === undefined || !sidecarPresent ? {} : await readJson(join(zhRoot, "descriptions.json"));
|
|
233
|
+
const divisionLabels = sidecarPresent ? await loadDivisionLabels(zhRoot) : {};
|
|
196
234
|
// 自建分类的显示名表(键 = 声明过的自建分类);官方标签优先级更高,见文件末尾的合并处。
|
|
197
235
|
const customLabels = await loadCustomLabels(customRoot);
|
|
198
236
|
// 未显式配置分类时自动发现:新增分类无需改代码。
|
|
199
237
|
// 发现结果一律按 isValidDivision 过一遍,**列表与扫描共用同一份过滤结果** ——
|
|
200
238
|
// 否则目录名不合规时会出现「分区名在列表里、专家却一个都没扫到」的空分区(已实测)。
|
|
201
|
-
|
|
239
|
+
// 显式配置了 divisions 就不自动发现;否则用发现结果,并把「读不动的分区」报出来(D-18)。
|
|
240
|
+
const discovery = divisions !== undefined && divisions.length > 0 ? undefined : await discoverDivisions(root);
|
|
241
|
+
if (discovery?.rootError !== undefined) {
|
|
242
|
+
warn(`[t-team] 无法列出专家根目录下的分区:${root}(${String(discovery.rootError)})`);
|
|
243
|
+
}
|
|
244
|
+
for (const item of discovery?.unreadable ?? []) {
|
|
245
|
+
warn(`[t-team] 分区目录无法读取,该分区专家已全部跳过:${join(root, item.division)}(${String(item.error)})`);
|
|
246
|
+
}
|
|
247
|
+
const scanned = (divisions !== undefined && divisions.length > 0 ? divisions : discovery.found)
|
|
202
248
|
.filter(isValidDivision);
|
|
249
|
+
/** 读不动的分区(供 host 侧 snapshot 暴露,诊断用)。 */
|
|
250
|
+
const unreadableDivisions = (discovery?.unreadable ?? []).map((item) => item.division);
|
|
203
251
|
const catalog = new Map();
|
|
204
252
|
|
|
205
253
|
/** 解析一个 persona 文件并放进名册;自定义根的文件额外带 custom 标记与写回路径。 */
|
|
206
254
|
async function ingest(fromRoot, division, filePath, fileName, isCustom) {
|
|
207
255
|
const slug = fileName.slice(0, -3);
|
|
208
|
-
if (!SLUG_PATTERN.test(slug))
|
|
256
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
257
|
+
// 文件名不合规 → 这位专家不会进名册。过去完全无声(D-18 同类);降到 debug 是刻意的:
|
|
258
|
+
// 它属于「文件命名不合规」,不该在正常安装里刷 warn。
|
|
259
|
+
logger?.debug?.(`[t-team] 跳过文件名不合规的专家文件:${filePath}`);
|
|
260
|
+
return "skipped";
|
|
261
|
+
}
|
|
209
262
|
let raw;
|
|
210
263
|
try {
|
|
211
264
|
raw = stripBom(await readFile(filePath, "utf8"));
|
|
212
|
-
} catch {
|
|
213
|
-
return;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
// 读不到就是这位专家静默缺席的直接原因,必须可见(过去 `catch { return; }` 什么都不说)。
|
|
267
|
+
warn(`[t-team] 读不到专家文件,已跳过:${filePath}(${String(error)})`);
|
|
268
|
+
return "skipped";
|
|
214
269
|
}
|
|
215
270
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
216
|
-
if (match === null)
|
|
271
|
+
if (match === null) {
|
|
272
|
+
logger?.debug?.(`[t-team] 跳过没有 frontmatter 的专家文件:${filePath}`);
|
|
273
|
+
return "skipped";
|
|
274
|
+
}
|
|
217
275
|
const meta = parseMetadata(match[1]);
|
|
218
|
-
if (!meta.name || !meta.description)
|
|
276
|
+
if (!meta.name || !meta.description) {
|
|
277
|
+
// 缺 name/description 的专家**不会进名册**,而这是作者最常犯的错 —— 过去零信号。
|
|
278
|
+
warn(`[t-team] 专家文件缺 name 或 description,已跳过:${filePath}`);
|
|
279
|
+
return "skipped";
|
|
280
|
+
}
|
|
219
281
|
if (catalog.has(slug)) {
|
|
220
282
|
if (isCustom) {
|
|
221
283
|
// 内置优先:自定义目录里出现同名 slug 时保留内置那位,不覆盖也不报冲突。
|
|
222
|
-
|
|
223
|
-
return;
|
|
284
|
+
warn(`[t-team] 自定义专家与内置专家同 slug,已忽略:${slug}(${filePath})`);
|
|
285
|
+
return "skipped";
|
|
224
286
|
}
|
|
225
|
-
|
|
287
|
+
warn(`[t-team] slug 冲突,后者覆盖前者:${slug}`);
|
|
226
288
|
}
|
|
227
289
|
const entry = {
|
|
228
290
|
slug,
|
|
@@ -246,19 +308,36 @@ export async function loadCatalog(root, divisions, options = {}) {
|
|
|
246
308
|
catalog.set(slug, entry);
|
|
247
309
|
}
|
|
248
310
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
311
|
+
/** 返回值用于把「跳过」计数汇总成一条 warn;逐条 debug 太吵,全静默又看不见。 */
|
|
312
|
+
let skipped = 0;
|
|
313
|
+
const walkInto = async (baseRoot, division, isCustom) => {
|
|
314
|
+
const dir = join(baseRoot, division);
|
|
315
|
+
if (!(await isDirectory(dir))) return;
|
|
316
|
+
try {
|
|
317
|
+
await walkMarkdown(dir, async (filePath, fileName) => {
|
|
318
|
+
if ((await ingest(baseRoot, division, filePath, fileName, isCustom)) === "skipped") skipped += 1;
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// 分区读不动 = 这个分区下的专家整批缺席。这是「无声掉专家」的根源,必须逐分区报出来。
|
|
322
|
+
// 不抛错:其余分区仍然可用,名册照旧返回(降级但可观察)。
|
|
323
|
+
warn(`[t-team] 分区目录无法读取,该分区专家已全部跳过:${dir}(${String(error)})`);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
for (const division of scanned) await walkInto(root, division, false);
|
|
252
328
|
|
|
253
329
|
// 自建专家根:目录可以不存在(还没建过任何自建专家),分区同样自动发现。
|
|
254
330
|
let customDivisions = [];
|
|
255
331
|
if (customRoot !== undefined) {
|
|
256
|
-
|
|
257
|
-
for (const
|
|
258
|
-
|
|
332
|
+
const customDiscovery = await discoverDivisions(customRoot);
|
|
333
|
+
for (const item of customDiscovery.unreadable) {
|
|
334
|
+
warn(`[t-team] 自建分区目录无法读取,该分区专家已全部跳过:${join(customRoot, item.division)}(${String(item.error)})`);
|
|
259
335
|
}
|
|
336
|
+
customDivisions = customDiscovery.found.filter(isValidDivision);
|
|
337
|
+
for (const division of customDivisions) await walkInto(customRoot, division, true);
|
|
260
338
|
}
|
|
261
339
|
|
|
340
|
+
if (skipped > 0) warn(`[t-team] 名册加载跳过了 ${skipped} 个专家文件(原因见上面的逐条日志)`);
|
|
262
341
|
if (catalog.size === 0) throw new Error(`专家目录为空或没有可解析的专家:${root}`);
|
|
263
342
|
markConflicts(catalog);
|
|
264
343
|
catalog.divisions = [...scanned, ...customDivisions.filter((item) => !scanned.includes(item))];
|
|
@@ -267,6 +346,10 @@ export async function loadCatalog(root, divisions, options = {}) {
|
|
|
267
346
|
catalog.customRoot = customRoot;
|
|
268
347
|
// 自建分类的显示名表;它的**键**同时就是"声明过的自建分类"——允许空分类(目录里还没有专家)。
|
|
269
348
|
catalog.customLabels = customLabels;
|
|
349
|
+
// 供 host 侧 snapshot() 暴露给面板:侧车缺了、有没有专家因此跳过。
|
|
350
|
+
catalog.sidecarPresent = sidecarPresent;
|
|
351
|
+
catalog.skippedFiles = skipped;
|
|
352
|
+
catalog.unreadableDivisions = unreadableDivisions;
|
|
270
353
|
// 官方标签优先:官方分类的显示名跟随中文侧车,不允许被本机覆盖(2026-09-12 用户选定)。
|
|
271
354
|
catalog.labels = { ...customLabels, ...divisionLabels };
|
|
272
355
|
if (zhRoot !== undefined) {
|
|
@@ -293,6 +376,26 @@ async function readJson(path) {
|
|
|
293
376
|
}
|
|
294
377
|
}
|
|
295
378
|
|
|
379
|
+
/** 返回第一个存在的路径(侧车「有没有内容」的锚点探测)。 */
|
|
380
|
+
async function firstExisting(paths) {
|
|
381
|
+
for (const path of paths) {
|
|
382
|
+
if (await isPath(path)) return path;
|
|
383
|
+
}
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 路径是否存在(文件或目录)。 */
|
|
388
|
+
async function isPath(path) {
|
|
389
|
+
const info = await stat(path).catch(() => undefined);
|
|
390
|
+
return info !== undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 是否为目录(用于「侧车/分区是否存在」的存在性探测)。 */
|
|
394
|
+
async function isDirectory(path) {
|
|
395
|
+
const info = await stat(path).catch(() => undefined);
|
|
396
|
+
return info !== undefined && info.isDirectory();
|
|
397
|
+
}
|
|
398
|
+
|
|
296
399
|
/** 是否为普通文件。 */
|
|
297
400
|
async function isFile(path) {
|
|
298
401
|
const info = await stat(path).catch(() => undefined);
|