@yangdcm/dsh-expert-team 1.1.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/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +135 -0
- package/client.js +2473 -0
- package/cordis.patch.yml +24 -0
- package/lib/artifact-writer.js +379 -0
- package/lib/command-parse.js +181 -0
- package/lib/command.js +5100 -0
- package/lib/dispatch-ledger.js +229 -0
- package/lib/index.js +15 -0
- package/lib/interception.js +266 -0
- package/lib/lead-toolface.js +179 -0
- package/lib/log-parse.js +181 -0
- package/lib/loop-guard.js +165 -0
- package/lib/metrics/collect.js +70 -0
- package/lib/metrics/render.js +100 -0
- package/lib/metrics/session-usage.js +319 -0
- package/lib/metrics/timing.js +188 -0
- package/lib/metrics/token-usage.js +352 -0
- package/lib/metrics/tokens.js +271 -0
- package/lib/routes/shared.js +83 -0
- package/lib/settings.js +289 -0
- package/lib/tier.js +190 -0
- package/lib/validate.js +681 -0
- package/lib/vocab.js +121 -0
- package/lib/write-tracer.js +58 -0
- package/package.json +119 -0
- package/presets/expert-team/agent.cordis.yml +542 -0
- package/presets/expert-team/preset.yml +3 -0
- package/skills/expert-team/SKILL.md +328 -0
- package/skills/expert-team/assets/templates/AUTHORITY.md +32 -0
- package/skills/expert-team/assets/templates/PLAN.md +27 -0
- package/skills/expert-team/assets/templates/RESEARCH.md +13 -0
- package/skills/expert-team/assets/templates/RETRO.md +24 -0
- package/skills/expert-team/assets/templates/REVIEW.md +10 -0
- package/skills/expert-team/assets/templates/ROSTER.json +6 -0
- package/skills/expert-team/assets/templates/SPEC.md +62 -0
- package/skills/expert-team/assets/templates/STATE.json +10 -0
- package/skills/expert-team/assets/templates/SUMMARY.md +25 -0
- package/skills/expert-team/assets/templates/TASK.md +23 -0
- package/skills/expert-team/assets/templates/TASKS.json +3 -0
- package/skills/expert-team/assets/templates/TEST.md +9 -0
- package/skills/expert-team/assets/templates//344/273/273/345/212/241/347/234/213/346/235/277.md +23 -0
- package/skills/expert-team/references/EFFICIENCY.md +79 -0
- package/skills/expert-team/references/LOGGING.md +82 -0
- package/skills/expert-team/references/PERSIST.md +57 -0
- package/skills/expert-team/references/PIPELINE.md +58 -0
- package/skills/expert-team/references/ROLES.md +297 -0
- package/skills/expert-team/references/WORKSPACE.md +123 -0
- package/skills/expert-team/references/workflow.team.js +97 -0
- package/skills/expert-team/scripts/scan-authority.mjs +114 -0
- package/skills/expert-team/scripts/scan-single-source.mjs +292 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 路由层共享件(B 线第 9 项 · 报告 04 的 R2)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:`apply()` 里 9 条 HTTP 路由**各自**抄了一份样板 —— 9 份 `json()` 助手、9 份 405
|
|
4
|
+
// 判断、5 份 body 解析、9 份 `catch → 500`。任何跨路由策略(鉴权、限流、审计、统一错误码)
|
|
5
|
+
// 都要改 9 处;而 A 线要加的 `/settings` 路由又得再抄一份。
|
|
6
|
+
//
|
|
7
|
+
// 设计约束(**本模块是叶子**):`shared.js` 不 import `command.js`,只依赖 node 内置 ——
|
|
8
|
+
// 这样它既能被 command.js 消费,将来也能被拆出去的子模块消费,不会形成环。
|
|
9
|
+
//
|
|
10
|
+
// 迁移策略:**一条一条搬**,先拿最独立的 `/artifact`(只读、无状态)试点;`/state`
|
|
11
|
+
// 最长最危险,放最后。搬一条就跑对应测试 + `gate:mutation`。
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 读请求体:dsh `webServer` 的 `req` 是**异步可迭代流**(没有 `.body`),
|
|
15
|
+
* 而测试用的假 req 直接给 `.body` —— 两种都支持。
|
|
16
|
+
* @param req - 请求对象。
|
|
17
|
+
* @returns 原始字符串(读不到或超 1MB 截断时返回已读部分)。
|
|
18
|
+
*/
|
|
19
|
+
export async function readRequestBody(req) {
|
|
20
|
+
try {
|
|
21
|
+
if (req && req.body != null) return String(req.body);
|
|
22
|
+
if (req && typeof req[Symbol.asyncIterator] === 'function') {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let size = 0;
|
|
25
|
+
for await (const raw of req) {
|
|
26
|
+
const b = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
27
|
+
size += b.byteLength;
|
|
28
|
+
if (size > 1024 * 1024) break;
|
|
29
|
+
chunks.push(b);
|
|
30
|
+
}
|
|
31
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
32
|
+
}
|
|
33
|
+
} catch { /* best-effort */ }
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 发一个 JSON 响应(统一 content-type,避免每条路由各写一遍)。
|
|
39
|
+
* @param res - 响应对象。
|
|
40
|
+
* @param code - HTTP 状态码。
|
|
41
|
+
* @param body - 可序列化对象。
|
|
42
|
+
*/
|
|
43
|
+
export function json(res, code, body) {
|
|
44
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
45
|
+
res.end(JSON.stringify(body));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 容错解析 JSON 请求体:非法 JSON 一律当 `{}`(与既有 9 条路由的写法一致 —— 参数缺失由
|
|
50
|
+
* 各自的校验返回 400,而不是让解析错误变成 500)。
|
|
51
|
+
* @param req - 请求对象。
|
|
52
|
+
* @returns 解析后的对象(永远不是 null)。
|
|
53
|
+
*/
|
|
54
|
+
export async function readJsonBody(req) {
|
|
55
|
+
let body = {};
|
|
56
|
+
try { body = JSON.parse((await readRequestBody(req)) || '{}'); } catch { body = {}; }
|
|
57
|
+
return body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 把一条路由包成统一的处理器:方法校验 → 解析 URL/body → 调用业务函数 → 统一 500 兜底。
|
|
62
|
+
*
|
|
63
|
+
* @param handler - `async ({ req, res, json, url, query, body }) => void`;**业务函数自己负责
|
|
64
|
+
* 写响应**(各路由的状态码语义差异太大,包起来反而更难读)。
|
|
65
|
+
* @param options.methods - 允许的方法(默认 `['GET']`)。不在名单里 ⇒ 返回 405 空体
|
|
66
|
+
* (与既有 9 条路由的行为逐字一致)。
|
|
67
|
+
* @returns `(req, res) => Promise<void>`
|
|
68
|
+
*/
|
|
69
|
+
export function withRoute(handler, { methods = ['GET'] } = {}) {
|
|
70
|
+
const allow = new Set(methods.map((m) => String(m).toUpperCase()));
|
|
71
|
+
return async function route(req, res) {
|
|
72
|
+
try {
|
|
73
|
+
const method = String((req && req.method) || 'GET').toUpperCase();
|
|
74
|
+
if (!allow.has(method)) { res.writeHead(405); res.end(); return; }
|
|
75
|
+
const url = new URL((req && req.url) || '/', 'http://127.0.0.1');
|
|
76
|
+
const body = method === 'GET' || method === 'HEAD' ? {} : await readJsonBody(req);
|
|
77
|
+
await handler({ req, res, json: (code, payload) => json(res, code, payload), url, query: url.searchParams, body });
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// 统一兜底:与既有 9 条路由的 500 文案逐字一致(`String(e && e.message ? e.message : e)`)
|
|
80
|
+
json(res, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// 设置控制台的真源(F 线第 1 项):`SETTINGS_SPEC` = 默认值 + 类型 + 值域 + 分组。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:设置同时被**三个消费者**读 —— ① 宿主路由(`GET/POST /settings`);
|
|
4
|
+
// ② 编排器(把 `roster.maxTasks` 等接进既有的上限解析);③ 浮层(照 spec 渲染表单)。
|
|
5
|
+
// 三者各写一份"默认值/合法值"必然分叉(本仓为此专门有 `vocab-consistency.test.mjs`)。
|
|
6
|
+
// 所以:**spec 只此一份**,UI 的表单结构也由它生成(新增一个设置项 = 只改这里)。
|
|
7
|
+
//
|
|
8
|
+
// 设计约束:
|
|
9
|
+
// · **零 IO、零依赖**(纯常量 + 纯函数)⇒ 可单测,也能被将来的 client 构建复用;
|
|
10
|
+
// · **未知键一律拒绝**(不静默吞)—— 打错字却"保存成功"是最糟糕的体验;
|
|
11
|
+
// · **非法值一律拒绝并说明原因**,**不**回落默认(回落会让 UI 显示的值与实际生效的值不一致);
|
|
12
|
+
// · 每个项都带 `label` 与 `hint`(中文)—— 设置页要给人看,不给模型看。
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 设置项的类型:`bool` / `int` / `enum` / `roles`(字符串数组)。
|
|
16
|
+
* `int` 带 `min`/`max`,`enum` 带 `values`,`roles` 只接受非空字符串数组。
|
|
17
|
+
*/
|
|
18
|
+
export const SETTINGS_GROUPS = ['identity', 'roster', 'display', 'gates'];
|
|
19
|
+
|
|
20
|
+
export const SETTINGS_SPEC = {
|
|
21
|
+
identity: {
|
|
22
|
+
label: '身份',
|
|
23
|
+
hint: '团队怎么跟你说话、问你什么',
|
|
24
|
+
items: {
|
|
25
|
+
profile: { type: 'enum', values: ['developer', 'non-technical', 'mixed'], default: 'developer', label: '你的身份', hint: '技术开发者 / 无技术经验 / 混合。影响提问口径与是否代你决策' },
|
|
26
|
+
askBudget: { type: 'int', min: 0, max: 20, default: 5, label: '一次澄清最多问几个问题', hint: '0 = 不主动问,按保守默认处理并写明' },
|
|
27
|
+
offerDecideForMe: { type: 'bool', default: false, label: '帮我把技术决策定下来', hint: '开启后由 pm 代决并逐条留痕(产品级/范围级决策仍必须你确认)' },
|
|
28
|
+
keepPlanGate: { type: 'bool', default: true, label: '保留「方案确认门」', hint: '关掉即起草完方案直接开工 —— 不推荐,方案错了后面全错' },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
roster: {
|
|
32
|
+
label: '编制',
|
|
33
|
+
hint: '默认班底与容量上限',
|
|
34
|
+
items: {
|
|
35
|
+
defaultRoles: { type: 'roles', default: null, label: '默认班底', hint: '留空 = 按档位默认;填了就固定用这套' },
|
|
36
|
+
deliverable: { type: 'enum', values: ['code+artifacts', 'artifacts-only'], default: 'code+artifacts', label: '交付口径', hint: 'artifacts-only = 只出工件,不改代码' },
|
|
37
|
+
persist: { type: 'bool', default: false, label: '默认持久化活团队', hint: '开 = 成员可反复指挥;关 = 一次性自动组队' },
|
|
38
|
+
maxMembers: { type: 'int', min: 0, max: 500, default: 32, label: '成员数上限', hint: '超过即拒绝落盘并显式报错' },
|
|
39
|
+
maxTasks: { type: 'int', min: 0, max: 5000, default: 200, label: '任务数上限', hint: '同上' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
display: {
|
|
43
|
+
label: '显示',
|
|
44
|
+
hint: '面板与浮层',
|
|
45
|
+
items: {
|
|
46
|
+
pollMs: { type: 'int', min: 1000, max: 60000, default: 3000, label: '面板轮询间隔(毫秒)', hint: '越小越实时、越费资源' },
|
|
47
|
+
capsuleMs: { type: 'int', min: 0, max: 60000, default: 4000, label: '胶囊提示停留(毫秒)', hint: '0 = 不自动消失' },
|
|
48
|
+
panelWidth: { type: 'int', min: 280, max: 900, default: 420, label: '浮层宽度(像素)', hint: '' },
|
|
49
|
+
defaultTab: { type: 'enum', values: ['team', 'tasks', 'board', 'info'], default: 'team', label: '默认页签', hint: '' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
gates: {
|
|
53
|
+
label: '门禁',
|
|
54
|
+
hint: '写侧门禁与档位选择门(关掉即退回只告警)',
|
|
55
|
+
items: {
|
|
56
|
+
boundaryTasks: { type: 'bool', default: true, label: '台账硬规则(TASKS.json)', hint: '重复 id / 环 / 自依赖 ⇒ 当场顶回' },
|
|
57
|
+
boundarySpec: { type: 'bool', default: true, label: '规格边界章节(SPEC.md)', hint: '边界没填就不让进 implement' },
|
|
58
|
+
loopGuard: { type: 'bool', default: true, label: '振荡检测(A→B→A→B)', hint: '宿主那份只管同工具重复,这份管来回振荡' },
|
|
59
|
+
tierGate: { type: 'enum', values: ['soft', 'hard'], default: 'soft', label: '档位选择门', hint: 'soft = 建议先生效;hard = 不选不开工' },
|
|
60
|
+
maxReviewRounds: { type: 'int', min: 1, max: 20, default: 3, label: '评审轮次上限', hint: '到顶的正确动作是升级用户,不是再来一轮' },
|
|
61
|
+
maxTestRounds: { type: 'int', min: 1, max: 20, default: 3, label: '测试轮次上限', hint: '同上' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** 全部设置项的扁平视图:`{ 'identity.profile': {…spec, group, key} }`。 */
|
|
67
|
+
export function flatSpec() {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const g of SETTINGS_GROUPS) {
|
|
70
|
+
for (const [k, item] of Object.entries(SETTINGS_SPEC[g].items)) out[`${g}.${k}`] = { ...item, group: g, key: k };
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 默认设置(每次新建对象,防调用方改到 spec)。 */
|
|
76
|
+
export function defaultSettings() {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const g of SETTINGS_GROUPS) {
|
|
79
|
+
out[g] = {};
|
|
80
|
+
for (const [k, item] of Object.entries(SETTINGS_SPEC[g].items)) out[g][k] = item.default;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 取一个设置项的值(含默认),`path` 形如 `roster.maxTasks`。 */
|
|
86
|
+
export function getSetting(settings, path) {
|
|
87
|
+
const spec = flatSpec()[String(path)];
|
|
88
|
+
if (!spec) return undefined;
|
|
89
|
+
const v = settings && settings[spec.group] ? settings[spec.group][spec.key] : undefined;
|
|
90
|
+
return v === undefined ? spec.default : v;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 单个值的校验:返回 `{ ok, value, error }`。**不回落默认** —— 非法就是非法,由调用方决定怎么办。 */
|
|
94
|
+
export function validateValue(path, raw) {
|
|
95
|
+
const spec = flatSpec()[String(path)];
|
|
96
|
+
if (!spec) return { ok: false, value: null, error: `未知设置项:${path}` };
|
|
97
|
+
switch (spec.type) {
|
|
98
|
+
case 'bool':
|
|
99
|
+
if (typeof raw !== 'boolean') return { ok: false, value: null, error: `${path} 必须是 true/false(收到 ${JSON.stringify(raw)})` };
|
|
100
|
+
return { ok: true, value: raw, error: '' };
|
|
101
|
+
case 'int': {
|
|
102
|
+
const n = typeof raw === 'number' ? raw : (typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN);
|
|
103
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) return { ok: false, value: null, error: `${path} 必须是整数(收到 ${JSON.stringify(raw)})` };
|
|
104
|
+
if (n < spec.min || n > spec.max) return { ok: false, value: null, error: `${path} 必须在 ${spec.min}..${spec.max}(收到 ${n})` };
|
|
105
|
+
return { ok: true, value: n, error: '' };
|
|
106
|
+
}
|
|
107
|
+
case 'enum':
|
|
108
|
+
if (!spec.values.includes(raw)) return { ok: false, value: null, error: `${path} 只能是 ${spec.values.join(' / ')}(收到 ${JSON.stringify(raw)})` };
|
|
109
|
+
return { ok: true, value: raw, error: '' };
|
|
110
|
+
case 'roles':
|
|
111
|
+
if (raw === null) return { ok: true, value: null, error: '' };
|
|
112
|
+
if (!Array.isArray(raw) || raw.length === 0 || !raw.every((r) => typeof r === 'string' && r.trim())) {
|
|
113
|
+
return { ok: false, value: null, error: `${path} 必须是非空字符串数组,或 null(留空 = 按档位默认)` };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, value: raw.map((r) => r.trim()), error: '' };
|
|
116
|
+
default:
|
|
117
|
+
return { ok: false, value: null, error: `${path} 的类型 ${spec.type} 不认识(spec 写错了)` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 把一份**补丁**(可以是 `{ roster: { maxTasks: 10 } }`,也可以是扁平的 `{ 'roster.maxTasks': 10 }`)
|
|
123
|
+
* 合并进当前设置。
|
|
124
|
+
*
|
|
125
|
+
* **全有或全无**:任何一项非法 ⇒ 整体拒绝并返回全部错误 —— 半套生效的设置比报错更难排查。
|
|
126
|
+
* 未知键同样报错(打错字却"保存成功"是最糟的体验)。
|
|
127
|
+
*
|
|
128
|
+
* @returns `{ ok, value, errors }`(`ok:false` 时 `value` 是原样返回的 `current`,调用方**不要**写盘)。
|
|
129
|
+
*/
|
|
130
|
+
export function mergeSettings(current, patch) {
|
|
131
|
+
const base = normalizeSettings(current).settings;
|
|
132
|
+
const errors = [];
|
|
133
|
+
const next = normalizeSettings(base).settings;
|
|
134
|
+
const assign = (path, raw) => {
|
|
135
|
+
const r = validateValue(path, raw);
|
|
136
|
+
if (!r.ok) { errors.push(r.error); return; }
|
|
137
|
+
const spec = flatSpec()[path];
|
|
138
|
+
next[spec.group][spec.key] = r.value;
|
|
139
|
+
};
|
|
140
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
141
|
+
return { ok: false, value: base, errors: ['补丁必须是对象'] };
|
|
142
|
+
}
|
|
143
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
144
|
+
if (k.includes('.')) { assign(k, v); continue; }
|
|
145
|
+
if (!SETTINGS_SPEC[k]) { errors.push(`未知设置分组:${k}`); continue; }
|
|
146
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) { errors.push(`${k} 必须是对象`); continue; }
|
|
147
|
+
for (const [k2, v2] of Object.entries(v)) {
|
|
148
|
+
if (!SETTINGS_SPEC[k].items[k2]) { errors.push(`未知设置项:${k}.${k2}`); continue; }
|
|
149
|
+
assign(`${k}.${k2}`, v2);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (errors.length) return { ok: false, value: base, errors };
|
|
153
|
+
return { ok: true, value: next, errors: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 规范化一份可能来自磁盘的设置:**只认 spec 里有的键**,缺失的补默认;
|
|
158
|
+
* 磁盘上的非法值(手改坏了)**按默认处理并如实返回**(不抛 —— 一份坏设置不该让插件挂掉)。
|
|
159
|
+
* @returns `{ settings, repaired: [{path, from, to}] }`
|
|
160
|
+
*/
|
|
161
|
+
export function normalizeSettings(raw) {
|
|
162
|
+
const out = defaultSettings();
|
|
163
|
+
const repaired = [];
|
|
164
|
+
if (!raw || typeof raw !== 'object') return { settings: out, repaired };
|
|
165
|
+
for (const g of SETTINGS_GROUPS) {
|
|
166
|
+
const src = raw[g];
|
|
167
|
+
if (!src || typeof src !== 'object') continue;
|
|
168
|
+
for (const [k, spec] of Object.entries(SETTINGS_SPEC[g].items)) {
|
|
169
|
+
if (!(k in src)) continue;
|
|
170
|
+
const r = validateValue(`${g}.${k}`, src[k]);
|
|
171
|
+
if (r.ok) out[g][k] = r.value;
|
|
172
|
+
else repaired.push({ path: `${g}.${k}`, from: src[k], to: spec.default, error: r.error });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { settings: out, repaired };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 给设置页用的渲染元数据(分组 + 每项的类型/值域/中文标签)—— UI 由它生成,不另写一份表单结构。 */
|
|
179
|
+
export function settingsSchema() {
|
|
180
|
+
return SETTINGS_GROUPS.map((g) => ({
|
|
181
|
+
group: g,
|
|
182
|
+
label: SETTINGS_SPEC[g].label,
|
|
183
|
+
hint: SETTINGS_SPEC[g].hint,
|
|
184
|
+
items: Object.entries(SETTINGS_SPEC[g].items).map(([k, item]) => ({
|
|
185
|
+
path: `${g}.${k}`, key: k, type: item.type, values: item.values ?? null,
|
|
186
|
+
min: item.min ?? null, max: item.max ?? null, default: item.default,
|
|
187
|
+
label: item.label, hint: item.hint,
|
|
188
|
+
})),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 身份策略(F 线第 4 项):把「你的身份」编译成**可审计的一页 POLICY.md** + 启动消息里的口径块 ──
|
|
193
|
+
//
|
|
194
|
+
// 需求原文:「目前对话框询问的问题有时候偏技术性或产品性,如果懂技术的就很好明白,如果不懂技术
|
|
195
|
+
// 的人就可能不会选……很多问题可以另外加一个身份专门帮用户做决策」。
|
|
196
|
+
//
|
|
197
|
+
// 三层落地(合并清单里定的),本模块负责前两层(**不依赖重新同步、对新 run 立即生效**):
|
|
198
|
+
// ① 启动消息里的【用户画像与提问口径】块(lead 第一眼就看到);
|
|
199
|
+
// ② run 目录的 `POLICY.md`(可审计、子代理可读、用户可点开)。
|
|
200
|
+
// 第三层(SKILL/ROLES 规则)由技能文本承载。
|
|
201
|
+
//
|
|
202
|
+
// ⚠️ `keepPlanGate=false` 是**用户显式授权**:此时 POLICY.md 会写明"本 run 已获准跳过方案确认门"。
|
|
203
|
+
// 它是**有意的例外**,不是默认行为 —— 默认 true 时 SKILL §3 的门禁一字不改。
|
|
204
|
+
|
|
205
|
+
/** 三种身份各自的提问/决策口径(面向人写,直接进 POLICY.md)。 */
|
|
206
|
+
export const POLICY_PROFILES = {
|
|
207
|
+
developer: {
|
|
208
|
+
label: '技术开发者',
|
|
209
|
+
ask: '可以直接使用技术术语(接口 / 契约 / 幂等 / 迁移 / DDL…),但仍要给出**选项 + 影响**,不要只抛一个开放问题。',
|
|
210
|
+
decide: '**不代你决策**:技术选择给出 2–3 个选项与权衡,由你拍板。',
|
|
211
|
+
},
|
|
212
|
+
'non-technical': {
|
|
213
|
+
label: '无技术经验',
|
|
214
|
+
ask: '**禁止术语**。每个问题必须写成三段:① 一句话人话版(这在做什么、影响什么);② 每个选项会导致什么可感知的差别;③ **我建议选哪个、为什么**。',
|
|
215
|
+
decide: '**技术决策由 pm 代你拍板并逐条留痕**(框架 / 库 / 字段格式 / 目录结构 / 命名这类)。**产品级与范围级决策必须问你**(要做什么、不要做什么、给谁用、验收标准)。',
|
|
216
|
+
},
|
|
217
|
+
mixed: {
|
|
218
|
+
label: '混合',
|
|
219
|
+
ask: '两段式:先一句话人话版,再给技术细节;技术性追问可以直接用术语。',
|
|
220
|
+
decide: '技术决策给出**建议默认值**并说明可覆盖;你没回应时按建议走,并逐条留痕。',
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** POLICY.md 里的口径块标记(launchMessage 靠它抽取,避免把整篇文档塞进启动消息)。 */
|
|
225
|
+
export const POLICY_BLOCK_START = '<!-- POLICY-BLOCK:START -->';
|
|
226
|
+
export const POLICY_BLOCK_END = '<!-- POLICY-BLOCK:END -->';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 把设置编译成身份策略。
|
|
230
|
+
* @param settings - `currentSettings()` 的形状(缺项按默认)。
|
|
231
|
+
* @returns `{ profile, label, ask, decide, askBudget, offerDecideForMe, keepPlanGate, md, block }`
|
|
232
|
+
*/
|
|
233
|
+
export function compilePolicy(settings) {
|
|
234
|
+
const s = normalizeSettings(settings).settings;
|
|
235
|
+
const id = s.identity;
|
|
236
|
+
const prof = POLICY_PROFILES[id.profile] || POLICY_PROFILES.developer;
|
|
237
|
+
const decide = id.offerDecideForMe && id.profile === 'developer'
|
|
238
|
+
? `${prof.decide}\n- ⚠️ 你已在设置里勾选「帮我把技术决策定下来」:技术选择可直接采用最稳妥方案,但**必须逐条留痕**(选了什么 / 为什么不选另外两个)。`
|
|
239
|
+
: prof.decide;
|
|
240
|
+
const block = [
|
|
241
|
+
POLICY_BLOCK_START,
|
|
242
|
+
`【用户画像与提问口径】身份=**${prof.label}**(profile=${id.profile})`,
|
|
243
|
+
`- 怎么问:${prof.ask}`,
|
|
244
|
+
`- 一次澄清最多问 **${id.askBudget}** 个问题(0 = 先按保守默认处理并写明,不要空等)。`,
|
|
245
|
+
`- 谁决策:${decide}`,
|
|
246
|
+
`- 方案确认门:${id.keepPlanGate
|
|
247
|
+
? '**保留**(spec-review 后、implement 前必须让用户确认;SKILL §3 原样执行)。'
|
|
248
|
+
: '⚠️ **用户已显式关闭**(settings.identity.keepPlanGate=false)⇒ 本 run **已获准**在 spec-review 通过后直接进入 implement;但**产品级/范围级变更仍必须问**,且跳过一事要写进 `SUMMARY.md` 与 `RUN.log.md`。'}`,
|
|
249
|
+
POLICY_BLOCK_END,
|
|
250
|
+
].join('\n');
|
|
251
|
+
const md = [
|
|
252
|
+
'# 身份策略(POLICY)',
|
|
253
|
+
'',
|
|
254
|
+
'> 由设置控制台编译而来,**随 run 冻结**(改设置只影响之后新建的 run)。',
|
|
255
|
+
`> 生成于:${new Date().toISOString()} | profile=${id.profile}`,
|
|
256
|
+
'',
|
|
257
|
+
block,
|
|
258
|
+
'',
|
|
259
|
+
'## 三层落地',
|
|
260
|
+
'',
|
|
261
|
+
'1. **启动消息**里带上面这个口径块(lead 第一眼就看到);',
|
|
262
|
+
'2. 本文件(可审计、子代理可读);',
|
|
263
|
+
'3. 技能规则(`SKILL.md` §1.2 / `references/ROLES.md` 的提问与决策口径)。',
|
|
264
|
+
'',
|
|
265
|
+
'## 口径明细',
|
|
266
|
+
'',
|
|
267
|
+
`- 提问:${prof.ask}`,
|
|
268
|
+
`- 提问预算:一次澄清最多 ${id.askBudget} 个问题。`,
|
|
269
|
+
`- 决策:${decide}`,
|
|
270
|
+
`- 方案确认门:${id.keepPlanGate ? '保留' : '用户显式关闭(见上)'}。`,
|
|
271
|
+
'',
|
|
272
|
+
'## 留痕要求',
|
|
273
|
+
'',
|
|
274
|
+
'- 每一次"代你决策"都要在 `DECISIONS.md` 留一行:选了什么 / 备选是什么 / 为什么不选。',
|
|
275
|
+
'- 用术语解释事情时,必须同时给一句人话版(这条对三种身份都成立)。',
|
|
276
|
+
'',
|
|
277
|
+
].join('\n');
|
|
278
|
+
return { profile: id.profile, label: prof.label, ask: prof.ask, decide, askBudget: id.askBudget,
|
|
279
|
+
offerDecideForMe: id.offerDecideForMe, keepPlanGate: id.keepPlanGate, md, block };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 从 POLICY.md 全文里抽出启动消息要用的口径块;没有标记返回 `''`(旧 run 不受影响)。 */
|
|
283
|
+
export function policyBlockFrom(mdText) {
|
|
284
|
+
const t = String(mdText || '');
|
|
285
|
+
const a = t.indexOf(POLICY_BLOCK_START);
|
|
286
|
+
const b = t.indexOf(POLICY_BLOCK_END);
|
|
287
|
+
if (a < 0 || b < 0 || b <= a) return '';
|
|
288
|
+
return t.slice(a, b + POLICY_BLOCK_END.length);
|
|
289
|
+
}
|
package/lib/tier.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// 流程档位(E 线之后的 G 线)· **档位词表的唯一真源**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要档位:拿一个真实 run 做的定量体检显示,**一个小项目也跑满了八阶段十二角色**
|
|
4
|
+
// (58 任务 / 74 次派工 / 9h54m)。问题不是"环节太多",而是**没有选择** —— 无论项目大小
|
|
5
|
+
// 都是同一套流程。「流程太繁琐」的真正含义是"没有档位"。
|
|
6
|
+
//
|
|
7
|
+
// 三条设计红线(写死在代码里,不靠文档自觉):
|
|
8
|
+
// ① **档位只裁"角色与独立环节",不裁验收面**:所有档位都必须有 `clarify`(含边界十问)
|
|
9
|
+
// 与交付前的真实校验 —— 这两条是本仓数据支持的资产,砍掉它们省下的不是时间而是质量。
|
|
10
|
+
// ② **档位不改变验收标准**:`acceptanceCap` 限制的是**验收项条数**(小项目本来就没那么多),
|
|
11
|
+
// 不是"通过率";通过率红线永远是 100%。
|
|
12
|
+
// ③ **建议 ≠ 选择**(用户拍板 #5):`suggestTier` 只出建议,每次新建 team 仍然要让用户
|
|
13
|
+
// 看到并确认一次;本模块**不**保存"上次的选择",也不做默认沿用。
|
|
14
|
+
|
|
15
|
+
/** 规范档位 id(机器可读处一律用这三个值)。 */
|
|
16
|
+
export const TIERS = ['quick', 'standard', 'strict'];
|
|
17
|
+
|
|
18
|
+
/** 默认档位:没有任何信号时的兜底(也是 `suggestTier` 的兜底)。 */
|
|
19
|
+
export const DEFAULT_TIER = 'standard';
|
|
20
|
+
|
|
21
|
+
/** 中文标签(面向用户;`normalizeTier` 也认这几个词)。 */
|
|
22
|
+
export const TIER_LABELS_ZH = { quick: '快速档', standard: '标准档', strict: '严格档' };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 各档的裁剪表(**唯一真源**:SKILL.md 的裁剪表、`/team status` 的输出、浮层徽标都从这里来)。
|
|
26
|
+
*
|
|
27
|
+
* 字段含义:
|
|
28
|
+
* - `phases` —— 该档要走的阶段(其余阶段跳过;`clarify` 与 `deliver` 任何档位都在)。
|
|
29
|
+
* - `roleCap` —— 角色数上限。
|
|
30
|
+
* - `defaultRoles`—— 该档的默认班底(用户可用 `--roles` 覆盖)。
|
|
31
|
+
* - `acceptanceCap` —— 验收项条数上限(`null` = 不设上限)。
|
|
32
|
+
* - `dispatchCap` —— 子代理派工次数上限(`null` = 不设)。
|
|
33
|
+
* - `independentReview` —— 是否有**独立**审查角色(false = 由 lead 自检 + qa 测试)。
|
|
34
|
+
* - `closing` —— 交付前收尾强度。
|
|
35
|
+
* - `when` —— 适用场景(写给人看,也是 `/team status` 的一行)。
|
|
36
|
+
*/
|
|
37
|
+
export const TIER_SPEC = {
|
|
38
|
+
quick: {
|
|
39
|
+
label: '快速档',
|
|
40
|
+
when: '单页工具 / 单模块 / 原型 / 一次性脚本',
|
|
41
|
+
phases: ['clarify', 'implement', 'test', 'deliver'],
|
|
42
|
+
roleCap: 3,
|
|
43
|
+
defaultRoles: ['pm', 'backend', 'qa'],
|
|
44
|
+
acceptanceCap: 10,
|
|
45
|
+
dispatchCap: 15,
|
|
46
|
+
independentReview: false,
|
|
47
|
+
closing: '回归 + 证据',
|
|
48
|
+
},
|
|
49
|
+
standard: {
|
|
50
|
+
label: '标准档',
|
|
51
|
+
when: '常规功能开发、中等重构',
|
|
52
|
+
phases: ['clarify', 'research', 'design', 'spec-review', 'implement', 'review', 'test', 'deliver'],
|
|
53
|
+
roleCap: 6,
|
|
54
|
+
defaultRoles: ['pm', 'architect', 'backend', 'frontend', 'reviewer', 'qa'],
|
|
55
|
+
acceptanceCap: 30,
|
|
56
|
+
dispatchCap: 40,
|
|
57
|
+
independentReview: true,
|
|
58
|
+
closing: '回归 + 证据 + 冲突检查',
|
|
59
|
+
},
|
|
60
|
+
strict: {
|
|
61
|
+
label: '严格档',
|
|
62
|
+
when: '安全 / 支付 / 权限 / 数据迁移 / 跨模块重构',
|
|
63
|
+
phases: ['clarify', 'research', 'design', 'spec-review', 'implement', 'review', 'test', 'deliver'],
|
|
64
|
+
roleCap: 12,
|
|
65
|
+
defaultRoles: ['pm', 'architect', 'researcher', 'ui', 'backend', 'frontend', 'dba', 'sec', 'reviewer', 'qa', 'devops', 'docs'],
|
|
66
|
+
acceptanceCap: null,
|
|
67
|
+
dispatchCap: null,
|
|
68
|
+
independentReview: true,
|
|
69
|
+
closing: '全量 + 真机 + 反向对照',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 把用户输入(英文 id / 中文标签 / 带"档"字的写法)归一成规范档位 id。
|
|
75
|
+
* 认不出来返回 `null`(**不猜、不静默退回默认** —— 静默退回会让用户以为自己的选择生效了)。
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeTier(v) {
|
|
78
|
+
const s = String(v ?? '').trim().toLowerCase();
|
|
79
|
+
if (!s) return null;
|
|
80
|
+
if (TIERS.includes(s)) return s;
|
|
81
|
+
for (const t of TIERS) {
|
|
82
|
+
const zh = TIER_LABELS_ZH[t];
|
|
83
|
+
if (s === zh.toLowerCase() || s === zh.replace('档', '').toLowerCase()) return t;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 档位的人读一行:`快速档(quick)· 角色 ≤3 · 验收项 ≤10 · 无独立审查`。 */
|
|
89
|
+
export function tierSummaryLine(tier) {
|
|
90
|
+
const t = normalizeTier(tier) || DEFAULT_TIER;
|
|
91
|
+
const s = TIER_SPEC[t];
|
|
92
|
+
const acc = s.acceptanceCap === null ? '验收项不限' : `验收项 ≤${s.acceptanceCap}`;
|
|
93
|
+
return `${TIER_LABELS_ZH[t]}(${t})· 角色 ≤${s.roleCap} · ${acc} · ${s.independentReview ? '有独立审查' : '无独立审查(lead 自检 + qa 测试)'}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 高风险词:命中即意味着"用小档跑会出事"(安全/钱/权限/数据/并发)。 */
|
|
97
|
+
const RISKY_RE = /支付|付款|结算|退款|权限|鉴权|认证|登录|token|密钥|加密|隐私|审计|迁移|schema|数据库|建表|并发|死锁|事务|安全|注入|越权|删除|清库|上线|发布|跨模块|重构/i;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 按项目规模 + 目标关键词**建议**档位(不是替你选)。
|
|
101
|
+
*
|
|
102
|
+
* 判定顺序(先安全后规模,任一"高风险"信号都优先):
|
|
103
|
+
* ① 高风险词 ≥2 处,或高风险 × 大体量 ⇒ `strict`
|
|
104
|
+
* ② 高风险 ≥1 处 ⇒ `standard`(绝不 `quick`)
|
|
105
|
+
* ③ 无明显风险 + 小体量(文件 ≤80 且代码 ≤400KB)⇒ `quick`
|
|
106
|
+
* ④ 其余 ⇒ `standard`
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ 规模用 **文件数 + 代码字节数**(都只要 `stat`,不读文件内容)—— 不用"行数"是因为
|
|
109
|
+
* 数行要读每个文件的全文,在建 run 的关键路径上不值得(本仓已在别处实测过串行读盘的代价)。
|
|
110
|
+
* 阈值按经验换算:400KB 源码 ≈ 1 万行量级。
|
|
111
|
+
*
|
|
112
|
+
* @param args.goal - 目标文本(可为空)。
|
|
113
|
+
* @param args.fileCount - 代码文件数(缺省 = 规模未知)。
|
|
114
|
+
* @param args.totalBytes - 代码总字节数(缺省 = 规模未知)。
|
|
115
|
+
* @param args.capped - 文件枚举是否被上限截断(截断 ⇒ 一定是大项目)。
|
|
116
|
+
* @returns `{ tier, reasons, signals }` —— `reasons` 是**给人看的依据**(建议必须可解释,否则用户无从判断要不要改)。
|
|
117
|
+
*/
|
|
118
|
+
export function suggestTier({ goal = '', fileCount = null, totalBytes = null, capped = false } = {}) {
|
|
119
|
+
const safeGoal = String(goal || '');
|
|
120
|
+
const riskyHits = safeGoal.match(new RegExp(RISKY_RE.source, 'gi')) || [];
|
|
121
|
+
const uniqRisky = [...new Set(riskyHits.map((x) => x.toLowerCase()))];
|
|
122
|
+
// 规模未知(null/非数字)时**不当作小**:误判成"小项目"会让安全敏感的活儿跑快速档。
|
|
123
|
+
const sizeKnown = Number.isFinite(fileCount) && Number.isFinite(totalBytes);
|
|
124
|
+
const heavy = capped || (Number.isFinite(fileCount) && fileCount > 500) || (Number.isFinite(totalBytes) && totalBytes > 5_000_000);
|
|
125
|
+
const tiny = sizeKnown && !capped && fileCount <= 80 && totalBytes <= 400_000;
|
|
126
|
+
const signals = { risky: uniqRisky, fileCount, totalBytes, sizeKnown, capped, heavy, tiny };
|
|
127
|
+
const reasons = [];
|
|
128
|
+
|
|
129
|
+
if (uniqRisky.length >= 2 || (uniqRisky.length >= 1 && heavy)) {
|
|
130
|
+
reasons.push(`高风险词命中 ${uniqRisky.length} 处(${uniqRisky.slice(0, 4).join('、')})${heavy ? ' + 大体量' : ''} ⇒ 一律按严格档,不省评审与安全角色`);
|
|
131
|
+
return { tier: 'strict', reasons, signals };
|
|
132
|
+
}
|
|
133
|
+
if (uniqRisky.length === 1) {
|
|
134
|
+
reasons.push(`高风险词命中「${uniqRisky[0]}」⇒ 至少标准档(快速档不跑独立审查,这里不能省)`);
|
|
135
|
+
return { tier: 'standard', reasons, signals };
|
|
136
|
+
}
|
|
137
|
+
if (tiny) {
|
|
138
|
+
reasons.push(`无明显高风险,且体量小(${fileCount} 个代码文件 / ${Math.round(totalBytes / 1024)}KB ≤ 80 / 400KB)⇒ 快速档:裁掉独立调研/设计/审查角色,保留 clarify 与交付前真实校验`);
|
|
139
|
+
return { tier: 'quick', reasons, signals };
|
|
140
|
+
}
|
|
141
|
+
reasons.push(sizeKnown
|
|
142
|
+
? `无明显高风险,体量中等或偏大(${fileCount} 个代码文件 / ${Math.round(totalBytes / 1024)}KB)⇒ 标准档`
|
|
143
|
+
: '规模未知(未统计到代码文件)⇒ **不按小项目处理**,取标准档');
|
|
144
|
+
return { tier: 'standard', reasons, signals };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 档位切换时**该不该按档改写班底**(纯函数 ⇒ 可单测)。
|
|
149
|
+
*
|
|
150
|
+
* 规则(一条,别加例外):**只有当前班底仍是"未定制的基线班底"时才自动改写**。
|
|
151
|
+
* 用户自己点过名的角色(`--roles`,或在面板里改过编制)**一律不动** ——
|
|
152
|
+
* 档位是"流程强度"的档,**不是"替我删人"的档**;把人删掉比多跑几个角色贵得多。
|
|
153
|
+
*
|
|
154
|
+
* 何时调用:**派工开始前**(建 run 时显式 `--tier`、浮层选择档位的那一刻)。
|
|
155
|
+
* **不**在 run 进行中改编制(那时成员已经领了活,改编制只会造成"谁负责这块"的真空)。
|
|
156
|
+
*
|
|
157
|
+
* @param tier - 目标档位。
|
|
158
|
+
* @param currentRoles - 当前班底。
|
|
159
|
+
* @param baselineRoles - "未定制"的基线班底(本包即 `DEFAULT_ROLES`)。
|
|
160
|
+
* @returns 改写后的角色数组;**不该动时返回 `null`**(调用方据此决定要不要写盘与留痕)。
|
|
161
|
+
*/
|
|
162
|
+
export function narrowedRoles(tier, currentRoles, baselineRoles) {
|
|
163
|
+
const t = normalizeTier(tier);
|
|
164
|
+
if (!t) return null;
|
|
165
|
+
const cur = (Array.isArray(currentRoles) ? currentRoles : []).join(',');
|
|
166
|
+
const base = (Array.isArray(baselineRoles) ? baselineRoles : []).join(',');
|
|
167
|
+
if (!cur || cur !== base) return null; // 用户定制过(或没有班底)⇒ 不动
|
|
168
|
+
const next = TIER_SPEC[t].defaultRoles;
|
|
169
|
+
if (next.join(',') === cur) return null; // 已经一致 ⇒ 无需改写
|
|
170
|
+
return [...next];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `/team status` 用的多行说明(含适用场景与裁剪结果)。 */
|
|
174
|
+
export function tierDetailLines(tier) {
|
|
175
|
+
const t = normalizeTier(tier) || DEFAULT_TIER;
|
|
176
|
+
const s = TIER_SPEC[t];
|
|
177
|
+
return [
|
|
178
|
+
`- 档位:${tierSummaryLine(t)}`,
|
|
179
|
+
` · 适用:${s.when}`,
|
|
180
|
+
` · 阶段:${s.phases.join(' → ')}`,
|
|
181
|
+
` · 默认班底:${s.defaultRoles.join(', ')}(可用 \`--roles\` 覆盖,但**不得超过角色上限 ${s.roleCap}**)`,
|
|
182
|
+
` · 收尾强度:${s.closing}`,
|
|
183
|
+
` · 派工上限:${s.dispatchCap === null ? '不设' : `≤${s.dispatchCap}`}`,
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 档位三选一的候选项(浮层卡片用;顺序固定为 quick → standard → strict)。 */
|
|
188
|
+
export function tierChoices() {
|
|
189
|
+
return TIERS.map((t) => ({ tier: t, label: TIER_LABELS_ZH[t], when: TIER_SPEC[t].when }));
|
|
190
|
+
}
|