deepseek-foreman 0.2.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.
package/lib/index.js ADDED
@@ -0,0 +1,673 @@
1
+ /** Route adjudication for ticket dispatch: role -> provider/model, with hard constraints. */
2
+ import { constants, copyFileSync, cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, unlinkSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import z from '@deepseek-ai/schemastery';
7
+ import { defineTool } from '@deepseek-ai/dsh-tools';
8
+ import { parse as parseYaml } from 'yaml';
9
+ /** Cordis plugin name. */
10
+ export const name = 'foreman';
11
+ /** Services required by this plugin. */
12
+ export const inject = ['tools'];
13
+ /** Schema of one role entry, shared by the inline config and the external file. */
14
+ const ROLE_SCHEMA = z.object({
15
+ role: z.string(),
16
+ vendor: z.string(),
17
+ provider: z.string(),
18
+ model: z.string(),
19
+ reasoningEffort: z.string().default(''),
20
+ vision: z.boolean().default(false),
21
+ maxOutputTokens: z.number().default(0),
22
+ peakWindows: z.array(z.string()).default([]),
23
+ peakDays: z.array(z.number()).default([]),
24
+ holidays: z.array(z.string()).default([]),
25
+ fallback: z.string().default(''),
26
+ note: z.string().default(''),
27
+ });
28
+ /** Loader schema for the role table. */
29
+ export const Config = z.object({
30
+ roles: z.array(ROLE_SCHEMA).default([]),
31
+ rolesFile: z.string().default(''),
32
+ installSkill: z.boolean().default(true),
33
+ });
34
+ /** Schema of the external role file: the same role entries under a `roles:` key. */
35
+ const ROLES_FILE_SCHEMA = z.object({ roles: z.array(ROLE_SCHEMA).default([]) });
36
+ /** Role-file template shipped in this package, copied out when the user has none yet. */
37
+ const EXAMPLE_ROLES_FILE = fileURLToPath(new URL('../roles.example.yml', import.meta.url));
38
+ /** Packaged skill directory shipped in this package, installed into the user skill root on load. */
39
+ const SKILL_SOURCE = fileURLToPath(new URL('../skill/deepseek-foreman', import.meta.url));
40
+ /** Install target under the user skill root: `<home>/.dsh/skills/deepseek-foreman`. */
41
+ const SKILL_TARGET = () => join(homedir(), '.dsh', 'skills', 'deepseek-foreman');
42
+ const ROUTE_SCHEMA = {
43
+ type: 'object',
44
+ additionalProperties: false,
45
+ properties: {
46
+ role: { type: 'string', required: true },
47
+ provider: { type: 'string', required: true },
48
+ model: { type: 'string', required: true },
49
+ reasoning_effort: { type: 'string' },
50
+ note: { type: 'string' },
51
+ },
52
+ };
53
+ const ROUTE_PAIR_SCHEMA = {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ provider: { type: 'string', required: true },
58
+ model: { type: 'string', required: true },
59
+ },
60
+ };
61
+ /** Setup self-check (doctor) block: role table, skill install, allowlist reconciliation, hints. */
62
+ const SETUP_SCHEMA = {
63
+ type: 'object',
64
+ additionalProperties: false,
65
+ properties: {
66
+ rolesFile: {
67
+ type: 'object',
68
+ additionalProperties: false,
69
+ properties: {
70
+ path: { type: 'string', required: true },
71
+ status: { type: 'string', enum: ['ok', 'unconfigured', 'error'], required: true },
72
+ roles: { type: 'integer', required: true },
73
+ detail: { type: 'string' },
74
+ },
75
+ },
76
+ skill: { type: 'string', required: true },
77
+ allowlist: {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ properties: {
81
+ status: { type: 'string', enum: ['ok', 'unknown', 'not-configured'], required: true },
82
+ detail: { type: 'string' },
83
+ routes: { type: 'array', required: true, items: ROUTE_PAIR_SCHEMA },
84
+ profiles: {
85
+ type: 'array',
86
+ required: true,
87
+ items: {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ properties: {
91
+ file: { type: 'string', required: true },
92
+ status: { type: 'string', enum: ['ok', 'unknown'], required: true },
93
+ routes: { type: 'array', required: true, items: ROUTE_PAIR_SCHEMA },
94
+ detail: { type: 'string' },
95
+ },
96
+ },
97
+ },
98
+ unmatchedRoles: {
99
+ type: 'array',
100
+ required: true,
101
+ items: {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ properties: {
105
+ role: { type: 'string', required: true },
106
+ provider: { type: 'string', required: true },
107
+ model: { type: 'string', required: true },
108
+ },
109
+ },
110
+ },
111
+ roleMatches: {
112
+ type: 'array',
113
+ required: true,
114
+ items: {
115
+ type: 'object',
116
+ additionalProperties: false,
117
+ properties: {
118
+ role: { type: 'string', required: true },
119
+ matchedIn: { type: 'array', required: true, items: { type: 'string' } },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ },
125
+ hints: { type: 'array', required: true, items: { type: 'string' } },
126
+ },
127
+ };
128
+ const DECISION_SCHEMA = {
129
+ type: 'object',
130
+ additionalProperties: false,
131
+ properties: {
132
+ ok: { type: 'boolean', required: true },
133
+ reason: { type: 'string', required: true },
134
+ route: ROUTE_SCHEMA,
135
+ alternatives: { type: 'array', required: true, items: ROUTE_SCHEMA },
136
+ setup: SETUP_SCHEMA,
137
+ },
138
+ };
139
+ /** Long-output threshold below which a capped route is refused. */
140
+ const LONG_OUTPUT_MIN_TOKENS = 100_000;
141
+ function parseClock(value) {
142
+ const parts = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value);
143
+ if (parts === null)
144
+ return undefined;
145
+ return Number(parts[1]) * 60 + Number(parts[2]);
146
+ }
147
+ /** Whether `now` falls inside any of this route's peak windows, in local time. */
148
+ function inPeakWindow(route, now) {
149
+ if (route.peakWindows.length === 0)
150
+ return false;
151
+ if (route.holidays.includes(localDate(now)))
152
+ return false;
153
+ if (route.peakDays.length > 0 && !route.peakDays.includes(now.getDay() === 0 ? 7 : now.getDay()))
154
+ return false;
155
+ const minutes = now.getHours() * 60 + now.getMinutes();
156
+ return route.peakWindows.some(window => {
157
+ const parts = window.split('-');
158
+ const from = parts.length === 2 ? parseClock(parts[0].trim()) : undefined;
159
+ const to = parts.length === 2 ? parseClock(parts[1].trim()) : undefined;
160
+ if (from === undefined || to === undefined)
161
+ return false;
162
+ return from <= to ? minutes >= from && minutes < to : minutes >= from || minutes < to;
163
+ });
164
+ }
165
+ /** Local `YYYY-MM-DD` for a Date, so a holiday list is not timezone-sensitive. */
166
+ function localDate(now) {
167
+ const month = String(now.getMonth() + 1).padStart(2, '0');
168
+ const day = String(now.getDate()).padStart(2, '0');
169
+ return `${now.getFullYear()}-${month}-${day}`;
170
+ }
171
+ function toRoute(route) {
172
+ return {
173
+ role: route.role,
174
+ provider: route.provider,
175
+ model: route.model,
176
+ ...(route.reasoningEffort === '' ? {} : { reasoning_effort: route.reasoningEffort }),
177
+ ...(route.note === '' ? {} : { note: route.note }),
178
+ };
179
+ }
180
+ /** One-line, length-capped rendering of a thrown value, for a model-readable reason. */
181
+ function summarize(error) {
182
+ const text = error instanceof Error ? error.message : String(error);
183
+ return text.replace(/\s+/g, ' ').trim().slice(0, 300);
184
+ }
185
+ /** Validate a parsed role file into a usable table; throws a readable reason when it cannot be used. */
186
+ function validateTable(data) {
187
+ const table = ROLES_FILE_SCHEMA(data).roles;
188
+ const seen = new Set();
189
+ for (const entry of table) {
190
+ for (const field of ['role', 'vendor', 'provider', 'model']) {
191
+ if (typeof entry[field] !== 'string' || entry[field].trim() === '') {
192
+ throw new Error(`角色 ${entry.role === '' ? '(未命名)' : `"${entry.role}"`} 的 ${field} 为空`);
193
+ }
194
+ }
195
+ if (seen.has(entry.role))
196
+ throw new Error(`角色名重复:"${entry.role}"`);
197
+ seen.add(entry.role);
198
+ }
199
+ return table;
200
+ }
201
+ /** Register the role-to-route adjudication tool. */
202
+ export function apply(ctx, config) {
203
+ const inlineRoles = config.roles ?? [];
204
+ const inlineMode = inlineRoles.length > 0;
205
+ const configured = config.rolesFile ?? '';
206
+ const rolesFile = configured === ''
207
+ ? join(homedir(), '.dsh', 'foreman.roles.yml')
208
+ : configured.startsWith('~/') ? join(homedir(), configured.slice(2)) : configured;
209
+ /** Whether the packaged skill is installed on load; config `installSkill`, default true. */
210
+ const installSkill = config.installSkill ?? true;
211
+ /** Skill install result recorded at load time; a failure is reported to the doctor, never thrown. */
212
+ let skillInstall;
213
+ try {
214
+ skillInstall = ensureSkill();
215
+ }
216
+ catch (error) {
217
+ skillInstall = `failed: ${summarize(error)}`;
218
+ }
219
+ /** Active table: the inline config roles, or the last good read of `rolesFile`. */
220
+ let roles = inlineMode ? inlineRoles : [];
221
+ let byRole = new Map(roles.map(route => [route.role, route]));
222
+ /** Set while the file is missing, empty, unreadable or invalid; it refuses every call. */
223
+ let loadError;
224
+ /** Doctor classification of `loadError`: `unconfigured` for a fresh file, `error` for a broken one. */
225
+ let loadKind = 'ok';
226
+ /** mtime of the last file read already processed; NaN forces the next call to read. */
227
+ let loadedMtimeMs = Number.NaN;
228
+ /** Record one load outcome: `error` message for the caller, `kind` for the doctor report. */
229
+ function setLoad(kind, error) {
230
+ loadKind = kind;
231
+ loadError = error;
232
+ }
233
+ if (inlineMode && byRole.size !== roles.length)
234
+ throw new Error('foreman: duplicate role in config.roles');
235
+ /**
236
+ * Install the packaged skill into `<home>/.dsh/skills`: prefer a symlink (repo edits stay live),
237
+ * fall back to a recursive copy where symlinks are refused (Windows privileges etc.).
238
+ * Every failure is reported, not thrown — and a target that cannot serve as the install is never
239
+ * reported as `exists` (a dangling symlink used to read as a healthy install: doctor 假绿).
240
+ */
241
+ function ensureSkill() {
242
+ if (!installSkill)
243
+ return 'disabled';
244
+ const target = SKILL_TARGET();
245
+ const health = skillHealth(target);
246
+ if (health === 'ok')
247
+ return 'exists';
248
+ if (health === 'occupied')
249
+ return `failed: 目标路径被普通文件占用(${target}),未做任何改动`;
250
+ if (health === 'unreadable')
251
+ return `failed: 软链指向的位置读不到 SKILL.md(${target}),未改动该软链`;
252
+ let cleaned = false;
253
+ if (health === 'dangling') {
254
+ // 悬空软链必须清掉重装:留着它 entryExists 恒真,doctor 会把一个装不上的 skill 报成 exists。
255
+ // 这是工单明写的唯一授权删除动作;清不掉就如实报错,绝不假绿。
256
+ try {
257
+ unlinkSync(target);
258
+ cleaned = true;
259
+ }
260
+ catch (error) {
261
+ return `failed: 悬空软链清理失败(${summarize(error)}),未重装`;
262
+ }
263
+ }
264
+ try {
265
+ mkdirSync(dirname(target), { recursive: true });
266
+ symlinkSync(SKILL_SOURCE, target, 'dir');
267
+ return 'linked';
268
+ }
269
+ catch (linkError) {
270
+ if (entryExists(target))
271
+ return 'exists'; // someone else installed it between the check and the link
272
+ try {
273
+ // TOCTOU:cpSync 默认 force 覆盖,在这期间被建出来的目标会被静默冲掉;
274
+ // errorOnExist+force:false 把「不覆盖别人刚建的东西」交给内核判定,冲突即失败。
275
+ cpSync(SKILL_SOURCE, target, { recursive: true, force: false, errorOnExist: true });
276
+ return 'copied';
277
+ }
278
+ catch (copyError) {
279
+ if (cleaned)
280
+ return `failed: 悬空软链已清理,重装失败(软链:${summarize(linkError)};拷贝:${summarize(copyError)})`;
281
+ return `failed: 软链安装失败(${summarize(linkError)});递归拷贝也失败(${summarize(copyError)})`;
282
+ }
283
+ }
284
+ }
285
+ /** Copy the shipped template to `rolesFile`, creating its parent directory first. */
286
+ function writeTemplate() {
287
+ mkdirSync(dirname(rolesFile), { recursive: true });
288
+ // EXCL: never clobber a file that appeared between the existence check and this copy; the EEXIST
289
+ // lands in `restoreTemplate`'s catch and surfaces as a readable reason instead of silent data loss.
290
+ copyFileSync(EXAMPLE_ROLES_FILE, rolesFile, constants.COPYFILE_EXCL);
291
+ }
292
+ /** Lay the template back down after the file disappeared; report it when even that fails. */
293
+ function restoreTemplate() {
294
+ try {
295
+ writeTemplate();
296
+ // OK: the stale error is about the file that is gone now; let refresh() re-stat and reload
297
+ setLoad('ok', undefined);
298
+ }
299
+ catch (error) {
300
+ // The template never landed, so this is a real error dressed as "未配置" — keep the wording,
301
+ // but classify it as `error` for the doctor so it is not mistaken for a not-configured-yet state.
302
+ setLoad('error', `未配置:模板写入失败(${summarize(error)});目标:${rolesFile}。按注释填好 provider/model,保存即生效`);
303
+ }
304
+ }
305
+ /** Turn a file problem into a reason that names the file and the way out of it. */
306
+ function fileReason(head) {
307
+ const kept = roles.length === 0 ? '' : '已保留上一份可用角色表(见 alternatives),';
308
+ return `${head};文件:${rolesFile}。${kept}改好保存后下次调用自动生效`;
309
+ }
310
+ /** Read, parse and validate `rolesFile`; every failure keeps the previous good table. */
311
+ function loadFile() {
312
+ let text;
313
+ try {
314
+ // stat first, read second: if the file changes in between, the recorded mtime is the older one,
315
+ // so the next call reloads. The other order can record a newer mtime with older content and never retry.
316
+ loadedMtimeMs = statSync(rolesFile).mtimeMs;
317
+ text = readFileSync(rolesFile, 'utf8');
318
+ }
319
+ catch (error) {
320
+ setLoad('error', `配置文件读取失败:${summarize(error)};文件:${rolesFile}`);
321
+ return;
322
+ }
323
+ let data;
324
+ try {
325
+ data = parseYaml(text);
326
+ }
327
+ catch (error) {
328
+ setLoad('error', fileReason(`配置文件 YAML 解析失败:${summarize(error)}`));
329
+ return;
330
+ }
331
+ let table;
332
+ try {
333
+ table = validateTable(data ?? {});
334
+ }
335
+ catch (error) {
336
+ setLoad('error', fileReason(`配置文件校验失败:${summarize(error)}`));
337
+ return;
338
+ }
339
+ if (table.length === 0) {
340
+ if (roles.length > 0) {
341
+ // A legal but empty table is a half-saved edit far more often than a deliberate wipe: keep the last
342
+ // usable table for `alternatives` and refuse everything through `loadError`. Deleting the file does
343
+ // not wipe it either — the template gets laid back down and lands here as an empty table.
344
+ setLoad('error', fileReason('文件里是空表'));
345
+ return;
346
+ }
347
+ roles = [];
348
+ byRole = new Map();
349
+ setLoad('unconfigured', `未配置:${rolesFile} 里还没有启用中的角色。按注释填好 provider/model,保存即生效`);
350
+ return;
351
+ }
352
+ roles = table;
353
+ byRole = new Map(table.map(route => [route.role, route]));
354
+ setLoad('ok', undefined);
355
+ }
356
+ /** Re-read `rolesFile` when its mtime moved; inline config mode never touches the disk. */
357
+ function refresh() {
358
+ if (inlineMode)
359
+ return;
360
+ let mtimeMs = mtimeOf(rolesFile);
361
+ if (mtimeMs === undefined) {
362
+ restoreTemplate();
363
+ if (loadError !== undefined)
364
+ return;
365
+ mtimeMs = mtimeOf(rolesFile);
366
+ if (mtimeMs === undefined) {
367
+ setLoad('error', `配置文件读取失败:${rolesFile} 无法访问`);
368
+ return;
369
+ }
370
+ }
371
+ if (mtimeMs === loadedMtimeMs)
372
+ return;
373
+ loadFile();
374
+ }
375
+ if (!inlineMode) {
376
+ if (mtimeOf(rolesFile) === undefined)
377
+ restoreTemplate();
378
+ if (loadError === undefined)
379
+ loadFile();
380
+ }
381
+ /**
382
+ * Scan `<home>/.dsh/profiles/<name>/cordis.patch.yml` for `allowedModels` pairs. A file that cannot be
383
+ * read or parsed is reported as `unknown` — the doctor's job is to surface the gap, not to guess.
384
+ * Readable files with no `allowedModels` block anywhere are `not-configured` (nothing to enforce yet),
385
+ * which is a different state from "configured but not matching": the former must not alarm per role.
386
+ */
387
+ function scanAllowlist() {
388
+ const dir = join(homedir(), '.dsh', 'profiles');
389
+ let names;
390
+ try {
391
+ names = readdirSync(dir);
392
+ }
393
+ catch (error) {
394
+ return { profiles: [], routes: [], status: 'unknown', detail: `${dir} 读不到(${summarize(error)})` };
395
+ }
396
+ const profiles = [];
397
+ const routes = [];
398
+ const seen = new Set();
399
+ for (const name of names) {
400
+ const file = join(dir, name, 'cordis.patch.yml');
401
+ if (!entryExists(file))
402
+ continue;
403
+ try {
404
+ const found = collectAllowedModels(parseYaml(readFileSync(file, 'utf8')));
405
+ for (const pair of found) {
406
+ const key = JSON.stringify([pair.provider, pair.model]); // collision-free dedup
407
+ if (seen.has(key))
408
+ continue;
409
+ seen.add(key);
410
+ routes.push(pair);
411
+ }
412
+ profiles.push({ file, status: 'ok', routes: found });
413
+ }
414
+ catch (error) {
415
+ profiles.push({ file, status: 'unknown', routes: [], detail: summarize(error) });
416
+ }
417
+ }
418
+ const readable = profiles.filter((profile) => profile.status === 'ok');
419
+ if (readable.some((profile) => profile.routes.length > 0))
420
+ return { profiles, routes, status: 'ok' };
421
+ if (readable.length === profiles.length) {
422
+ // 全部读得到,却一个 allowedModels 段都没有 → 没启用白名单,不是「对不上」
423
+ return profiles.length === 0
424
+ ? { profiles, routes, status: 'unknown', detail: `${dir} 下没有 cordis.patch.yml` }
425
+ : { profiles, routes, status: 'not-configured', detail: `${dir} 下的 profile 都没有 allowedModels 段` };
426
+ }
427
+ return {
428
+ profiles,
429
+ routes,
430
+ status: 'unknown',
431
+ detail: readable.length === 0
432
+ ? `${dir} 下的 cordis.patch.yml 都读不到`
433
+ : `${dir} 下 ${profiles.length - readable.length} 个 cordis.patch.yml 读不到,其余没有 allowedModels 段`,
434
+ };
435
+ }
436
+ /** The `setup` block for a no-role call: role table, skill install, allowlist reconciliation, hints. */
437
+ function buildSetup() {
438
+ const allowlist = scanAllowlist();
439
+ // 对账按 profile 粒度:一个角色只要在任一 status=ok 的 profile 里对上,就不算失配;
440
+ // roleMatches 记下每个角色匹配到了哪些 profile(如 matchedIn: ['desktop']),供逐条核对。
441
+ const okProfiles = allowlist.profiles.filter((profile) => profile.status === 'ok');
442
+ const matchedIn = (route) => okProfiles
443
+ .filter((profile) => profile.routes.some((pair) => pair.provider === route.provider && pair.model === route.model))
444
+ .map((profile) => basename(dirname(profile.file)));
445
+ const roleMatches = allowlist.status === 'ok'
446
+ ? roles.map((route) => ({ role: route.role, matchedIn: matchedIn(route) }))
447
+ : [];
448
+ // 只标「在所有 status=ok 的 profile 里都不匹配」的角色。白名单没读到(unknown)或没启用
449
+ // (not-configured)时一个都不标:逐角色报警会把真正的问题埋掉。
450
+ const unmatchedRoles = allowlist.status === 'ok'
451
+ ? roles
452
+ .filter((route) => matchedIn(route).length === 0)
453
+ .map(route => ({ role: route.role, provider: route.provider, model: route.model }))
454
+ : [];
455
+ const rolesFileReport = inlineMode
456
+ ? { path: rolesFile, status: 'ok', roles: roles.length, detail: 'config.roles 直传,rolesFile 未使用' }
457
+ : { path: rolesFile, status: loadKind, roles: roles.length, ...(loadError === undefined ? {} : { detail: loadError }) };
458
+ const hints = [];
459
+ if (!inlineMode && loadKind === 'unconfigured') {
460
+ hints.push(`角色表还没配置:编辑 ${rolesFile},按注释填好 provider/model,保存即生效`);
461
+ }
462
+ else if (!inlineMode && loadKind === 'error') {
463
+ hints.push(`角色表有问题:${loadError}`);
464
+ }
465
+ if (skillInstall.startsWith('failed:')) {
466
+ const detail = skillInstall.slice('failed:'.length).trim();
467
+ hints.push(detail.startsWith('目标路径被普通文件占用')
468
+ ? `skill 安装目标被普通文件占用(${SKILL_TARGET()}),先移走或删除该文件,重启后插件会自动重装`
469
+ : `skill 自动安装失败(${detail}),可手动把包内 skill/deepseek-foreman 链进 ~/.dsh/skills/deepseek-foreman`);
470
+ }
471
+ for (const missing of unmatchedRoles) {
472
+ hints.push(`角色 ${missing.role} 的路由 ${missing.provider}/${missing.model} 不在 allowedModels,把它加进白名单后开新会话`);
473
+ }
474
+ if (allowlist.status === 'not-configured') {
475
+ hints.push('未启用白名单,当前不强制限制派单;建议启用');
476
+ }
477
+ else if (allowlist.status === 'unknown') {
478
+ hints.push(`没读到 allowedModels 白名单(${allowlist.detail ?? ''}),无法核对角色路由`);
479
+ }
480
+ return {
481
+ rolesFile: rolesFileReport,
482
+ skill: skillInstall,
483
+ allowlist: {
484
+ status: allowlist.status,
485
+ ...(allowlist.detail === undefined ? {} : { detail: allowlist.detail }),
486
+ routes: allowlist.routes,
487
+ profiles: allowlist.profiles,
488
+ unmatchedRoles,
489
+ roleMatches,
490
+ },
491
+ hints,
492
+ };
493
+ }
494
+ ctx.tools.register(defineTool({
495
+ name: 'pick_route',
496
+ description: 'Resolve a work role to the LLM route to dispatch with. Omit role to list every role. '
497
+ + 'Call before every ticket dispatch and pass the returned provider/model/reasoning_effort to the '
498
+ + 'subagent tool; do not choose a model yourself. A refused route must not be worked around.',
499
+ parameters: {
500
+ role: { type: 'string', description: 'Role key from the table, e.g. lead, daily-code, review, copywriting, chores.' },
501
+ needs_vision: { type: 'boolean', description: 'The work reads images or screenshots.' },
502
+ needs_long_output: { type: 'boolean', description: 'The work must produce a large single output (whole document, big file).' },
503
+ review_for: { type: 'string', description: 'When this dispatch is a code review, name the role that wrote the code. Same-vendor reviewers are refused: correlated models make correlated mistakes.' },
504
+ },
505
+ output: {
506
+ schema: DECISION_SCHEMA,
507
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
508
+ },
509
+ async execute(args) {
510
+ refresh();
511
+ const now = new Date();
512
+ const all = () => roles.map(toRoute);
513
+ // Omitting the role is the doctor call: the answer carries `setup` even when the table
514
+ // itself is broken — that is exactly the call where the user asks "what is still missing?".
515
+ if (loadError !== undefined) {
516
+ const wantsSetup = args.role === undefined || args.role === '';
517
+ return { ok: false, reason: loadError, alternatives: all(), ...(wantsSetup ? { setup: buildSetup() } : {}) };
518
+ }
519
+ if (args.role === undefined || args.role === '') {
520
+ return { ok: true, reason: `${roles.length} roles available`, alternatives: all(), setup: buildSetup() };
521
+ }
522
+ const route = byRole.get(args.role);
523
+ if (route === undefined) {
524
+ return { ok: false, reason: `unknown role "${args.role}"; call pick_route with no role to list roles`, alternatives: all() };
525
+ }
526
+ const author = args.review_for === undefined || args.review_for === ''
527
+ ? undefined : byRole.get(args.review_for);
528
+ if (author !== undefined && author.vendor === route.vendor) {
529
+ return {
530
+ ok: false,
531
+ reason: `reviewer and author are both vendor "${route.vendor}"; pick a route from another vendor`,
532
+ alternatives: roles
533
+ .filter(other => other.vendor !== route.vendor && usable(other, args, now))
534
+ .map(toRoute),
535
+ };
536
+ }
537
+ if (!usable(route, args, now)) {
538
+ const suggested = route.fallback === '' ? undefined : byRole.get(route.fallback);
539
+ return {
540
+ ok: false,
541
+ reason: blockers(route, args, now).join('; '),
542
+ ...(suggested === undefined || !usable(suggested, args, now) ? {} : { route: toRoute(suggested) }),
543
+ alternatives: roles.filter(other => other.role !== route.role && usable(other, args, now)).map(toRoute),
544
+ };
545
+ }
546
+ return { ok: true, reason: 'route accepted', route: toRoute(route), alternatives: [] };
547
+ },
548
+ }));
549
+ }
550
+ /** mtime of a file, or undefined when it cannot be stat'ed at all. */
551
+ function mtimeOf(file) {
552
+ try {
553
+ return statSync(file).mtimeMs;
554
+ }
555
+ catch {
556
+ return undefined;
557
+ }
558
+ }
559
+ /** Whether a path exists as an entry — `statSync` misses even a dangling symlink, `lstatSync` does not. */
560
+ function entryExists(file) {
561
+ try {
562
+ lstatSync(file);
563
+ return true;
564
+ }
565
+ catch {
566
+ return false;
567
+ }
568
+ }
569
+ /**
570
+ * Health of the skill install target, checked before `ensureSkill` may answer `exists`:
571
+ * - `ok`: a directory (pre-existing install, left untouched) or a symlink through which `SKILL.md` reads;
572
+ * - `dangling`: a symlink `statSync` cannot follow — the install is gone, but `lstatSync` sees an entry;
573
+ * - `unreadable`: a resolvable symlink whose `<target>/SKILL.md` does not read (only reported, not removed);
574
+ * - `occupied`: a plain file (or other non-directory) sitting on the target path;
575
+ * - `absent`: nothing there yet, safe to install.
576
+ * Without this check a dangling symlink reads as a healthy `exists` while the skill cannot load at all.
577
+ */
578
+ function skillHealth(target) {
579
+ let isLink = false;
580
+ let isDir = false;
581
+ try {
582
+ const stats = lstatSync(target);
583
+ isLink = stats.isSymbolicLink();
584
+ isDir = stats.isDirectory();
585
+ }
586
+ catch {
587
+ return 'absent';
588
+ }
589
+ if (isLink) {
590
+ try {
591
+ statSync(target); // follows the link; throws when it dangles
592
+ }
593
+ catch {
594
+ return 'dangling';
595
+ }
596
+ try {
597
+ readFileSync(join(target, 'SKILL.md'), 'utf8');
598
+ return 'ok';
599
+ }
600
+ catch {
601
+ return 'unreadable';
602
+ }
603
+ }
604
+ return isDir ? 'ok' : 'occupied';
605
+ }
606
+ /**
607
+ * `{provider, model}` pairs under any `allowedModels` key below a node — used only for subtrees that
608
+ * already belong to a `model-selection` entry (see `collectAllowedModels`).
609
+ */
610
+ function collectAllowedUnder(node, out = []) {
611
+ if (Array.isArray(node)) {
612
+ for (const item of node)
613
+ collectAllowedUnder(item, out);
614
+ return out;
615
+ }
616
+ if (node === null || typeof node !== 'object')
617
+ return out;
618
+ for (const [key, value] of Object.entries(node)) {
619
+ if (key !== 'allowedModels') {
620
+ collectAllowedUnder(value, out);
621
+ continue;
622
+ }
623
+ if (!Array.isArray(value))
624
+ continue;
625
+ for (const entry of value) {
626
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
627
+ continue;
628
+ const { provider, model } = entry;
629
+ if (typeof provider === 'string' && typeof model === 'string')
630
+ out.push({ provider, model });
631
+ }
632
+ }
633
+ return out;
634
+ }
635
+ /**
636
+ * `{provider, model}` pairs of a parsed patch file, collected ONLY from entries whose `name` carries
637
+ * `model-selection` (e.g. `@deepseek-ai/dsh-tool-subagent/model-selection-settings`). Other plugins may
638
+ * declare their own `allowedModels` key; counting those fakes a whitelisted route and lets the doctor
639
+ * stay green while the dispatch is really refused.
640
+ */
641
+ function collectAllowedModels(node, out = []) {
642
+ if (Array.isArray(node)) {
643
+ for (const item of node)
644
+ collectAllowedModels(item, out);
645
+ return out;
646
+ }
647
+ if (node === null || typeof node !== 'object')
648
+ return out;
649
+ const record = node;
650
+ if (typeof record.name === 'string' && record.name.includes('model-selection')) {
651
+ collectAllowedUnder(record, out);
652
+ return out;
653
+ }
654
+ for (const value of Object.values(record))
655
+ collectAllowedModels(value, out);
656
+ return out;
657
+ }
658
+ function blockers(route, args, now) {
659
+ const found = [];
660
+ if (args.needs_vision === true && route.vision !== true) {
661
+ found.push(`role "${route.role}" is not declared vision-capable`);
662
+ }
663
+ if (inPeakWindow(route, now)) {
664
+ found.push(`route ${route.provider}/${route.model} is locked during peak windows ${route.peakWindows.join(', ')}`);
665
+ }
666
+ if (args.needs_long_output === true && route.maxOutputTokens > 0 && route.maxOutputTokens < LONG_OUTPUT_MIN_TOKENS) {
667
+ found.push(`role "${route.role}" caps output at ${route.maxOutputTokens} tokens, too small for a long single output`);
668
+ }
669
+ return found;
670
+ }
671
+ function usable(route, args, now) {
672
+ return blockers(route, args, now).length === 0;
673
+ }