@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
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @yangdcm/dsh-expert-team bundle patch.
|
|
2
|
+
# Applied as a loader patch layer over the profile's composed tree. This bundle
|
|
3
|
+
# contributes only HOST-plane rows (no agent-plane tool rows — those stay behind
|
|
4
|
+
# the preset the session runs; the `standard` preset already exposes the
|
|
5
|
+
# subagent/workflow/ralph/skill tools the team drives through).
|
|
6
|
+
#
|
|
7
|
+
# The team's orchestration brain is NOT code: it is the `expert-team` SKILL,
|
|
8
|
+
# which the `/team` command copies into $DSH_HOME/skills (discovered by the
|
|
9
|
+
# preset's `skill-filesystem`) and which the model loads via the `skill` tool.
|
|
10
|
+
|
|
11
|
+
- insert:
|
|
12
|
+
# Host-plane command: `/team <task>` scaffolds the shared workspace run and
|
|
13
|
+
# enqueues one model-visible message that launches the team through the
|
|
14
|
+
# expert-team skill. Registered once, available to every session's `/` UI.
|
|
15
|
+
- id: expert-team-command
|
|
16
|
+
name: '@yangdcm/dsh-expert-team/command'
|
|
17
|
+
|
|
18
|
+
# Client-discovery marker row: dsh-client-modules scans Loader entries whose
|
|
19
|
+
# name is an EXACT package specifier for a `dsh.client` declaration. The
|
|
20
|
+
# command row above is a subpath (`.../command`), which the scanner skips, so
|
|
21
|
+
# we add a bundle-root row (exact `@yangdcm/dsh-expert-team`) that loads the
|
|
22
|
+
# inert `index` module purely so the live client overlay gets discovered.
|
|
23
|
+
- id: expert-team-bundle
|
|
24
|
+
name: '@yangdcm/dsh-expert-team'
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// 工件写入的**单一受控入口**(批 2-1 · L2-3 · 2-2 的共同载体)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(来自 ROI-REVIEW 的架构仲裁):
|
|
4
|
+
// · 宿主**早就有**文件级 CAS:`@deepseek-ai/dsh-fs` 的
|
|
5
|
+
// `FsWriteIntent = {kind:'createIfAbsent'} | {kind:'replaceIfVersion', version}` 与
|
|
6
|
+
// `writeText(target, content, expected?)`,provider 在版本失配时报 `FS_STALE_VERSION`;
|
|
7
|
+
// `dsh-fs-observation-policy` 已挂在 `dsh-base`。
|
|
8
|
+
// · 而我方 `lib/command.js` 用 `node:fs/promises` **直写 30+ 处**,**绕过了这把现成的栅栏**。
|
|
9
|
+
// · ⇒ 所以「跨会话写仲裁」不是宿主级大工程,而是**我方写入路径的改造**。
|
|
10
|
+
//
|
|
11
|
+
// 本模块把改造所需的四件事收进一个入口,避免各写点各写一套:
|
|
12
|
+
// ① 版本栅栏(expected version → replaceIfVersion;失配走重试而非静默覆盖)
|
|
13
|
+
// ② **陈旧重试**(重读 → 重新基线 → 重试一次 → 仍失败才上抛)
|
|
14
|
+
// —— 缺这条时,加栅栏的净效果只是"把静默丢更新换成用户可见的失败",属降级。
|
|
15
|
+
// ③ **范围硬排除**(L2-3):默认禁写区一律拒绝,且**不是告警而是拒绝**
|
|
16
|
+
// ④ revision 记账:写入即自增,读到回退/跳变即 loud(拦不住 lead 用 write 工具直写,
|
|
17
|
+
// 但能把"被别人改过"这件事变成可见事实)
|
|
18
|
+
//
|
|
19
|
+
// 设计约束:**不 import 任何宿主包**,只依赖注入的 `fsPort`,因此既能在宿主里跑(注入 `ctx.fs`),
|
|
20
|
+
// 也能在纯 Node 测试里跑(注入本文件提供的 `nodeFsPort()`)。
|
|
21
|
+
//
|
|
22
|
+
// ⚠️ 一个真实陷阱(架构师实测):`fs/write-intent` 是**工具层**(`dsh-tool-fs`)dispatch 的,
|
|
23
|
+
// **不是** `ctx.fs` 服务 dispatch 的。所以直连 `ctx.fs.writeText` 时**必须自己传 `expected`**,
|
|
24
|
+
// 不能指望 intent 监听器替你算。
|
|
25
|
+
|
|
26
|
+
import { readFile as nodeReadFile, writeFile as nodeWriteFile, stat as nodeStat, unlink as nodeUnlink } from 'node:fs/promises';
|
|
27
|
+
|
|
28
|
+
/** 默认禁写区(L2-3):密钥/凭据/版本库元数据/宿主配置 —— 命中即拒绝,不给"仅告警"的余地。 */
|
|
29
|
+
export const DEFAULT_EXCLUSIONS = [
|
|
30
|
+
/(^|\/)\.env(\.|$)/i,
|
|
31
|
+
/(^|\/)\.git(\/|$)/i,
|
|
32
|
+
/(^|\/)\.ssh(\/|$)/i,
|
|
33
|
+
/(^|\/)id_(rsa|ed25519|ecdsa)$/i,
|
|
34
|
+
/(^|\/)\.npmrc$/i,
|
|
35
|
+
/(^|\/)\.netrc$/i,
|
|
36
|
+
/(^|\/)credentials(\.json)?$/i,
|
|
37
|
+
/(^|\/)secrets?(\/|$)/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** 判断路径是否命中禁写区;返回命中的规则(无则 null)。 */
|
|
41
|
+
export function scopeViolation(target, exclusions = DEFAULT_EXCLUSIONS) {
|
|
42
|
+
const p = String(target || '');
|
|
43
|
+
for (const re of exclusions) if (re.test(p)) return String(re);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 从文本里取出 revision(JSON 对象且含数字 revision 时);取不到返回 null。 */
|
|
48
|
+
export function readRevision(text) {
|
|
49
|
+
try {
|
|
50
|
+
const o = JSON.parse(text);
|
|
51
|
+
if (o && typeof o === 'object' && Number.isSafeInteger(o.revision)) return o.revision;
|
|
52
|
+
} catch { /* 非 JSON */ }
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 纯 Node 的 fsPort(测试与"宿主不可用时的回退"用)。
|
|
58
|
+
* 它**自己实现**版本栅栏语义,以复现宿主 `replaceIfVersion` 的行为:
|
|
59
|
+
* `statVersion` 给出版本指纹,`writeText` 在 `intent.kind==='replaceIfVersion'` 且版本不符时抛 `FS_STALE_VERSION`。
|
|
60
|
+
*/
|
|
61
|
+
export function nodeFsPort() {
|
|
62
|
+
const versionOf = async (p) => {
|
|
63
|
+
try {
|
|
64
|
+
const st = await nodeStat(p);
|
|
65
|
+
return `${st.size}:${st.mtimeMs}`;
|
|
66
|
+
} catch {
|
|
67
|
+
return null; // 不存在
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
kind: 'node:fs',
|
|
72
|
+
async readText(p) {
|
|
73
|
+
try { return await nodeReadFile(p, 'utf8'); } catch { return null; }
|
|
74
|
+
},
|
|
75
|
+
async statVersion(p) { return versionOf(p); },
|
|
76
|
+
async writeText(p, content, intent) {
|
|
77
|
+
if (intent && intent.kind === 'replaceIfVersion') {
|
|
78
|
+
const cur = await versionOf(p);
|
|
79
|
+
if (cur !== intent.version) {
|
|
80
|
+
const err = new Error(`FS_STALE_VERSION: ${p}`);
|
|
81
|
+
err.code = 'FS_STALE_VERSION';
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await nodeWriteFile(p, content);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 把宿主的 `ctx.fs` 适配成 fsPort。
|
|
92
|
+
* 注意 `expected` 必须由调用方给出(见文件头陷阱说明)。
|
|
93
|
+
*/
|
|
94
|
+
export function cordisFsPort(ctxFs) {
|
|
95
|
+
return {
|
|
96
|
+
kind: 'ctx.fs',
|
|
97
|
+
async readText(p) {
|
|
98
|
+
const t = await ctxFs.resolve(p);
|
|
99
|
+
try { return await ctxFs.readText(t); } catch { return null; }
|
|
100
|
+
},
|
|
101
|
+
async statVersion(p) {
|
|
102
|
+
try {
|
|
103
|
+
const t = await ctxFs.resolve(p);
|
|
104
|
+
const st = await ctxFs.stat(t);
|
|
105
|
+
return st && st.version != null ? String(st.version) : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async writeText(p, content, intent) {
|
|
111
|
+
const t = await ctxFs.resolve(p);
|
|
112
|
+
return ctxFs.writeText(t, content, intent);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 内建 schema 告警(批 2-4 · L1-4′)。
|
|
119
|
+
*
|
|
120
|
+
* **只告警、不硬拒** —— 刻意如此:`listRuns` 对不合规的 STATE.json 是 catch 后静默跳过,
|
|
121
|
+
* 若写入侧硬拒,旧 run 会从 `/team status` 与浮层里**静默消失**,比"容忍脏数据"更糟。
|
|
122
|
+
*
|
|
123
|
+
* 它要修的是一类**已被实证的静默伤害**:本 run 自己的 `STATE.coverage` 曾被写成
|
|
124
|
+
* **角色名字符串数组**(`["pm","architect",…]`),而消费端用
|
|
125
|
+
* `Array.isArray(state.coverage) ? state.coverage : []` 兜底 ⇒ 渲染成一串空行,
|
|
126
|
+
* **生产端违约 + 消费端静默吞**,没有任何人被告知。
|
|
127
|
+
*
|
|
128
|
+
* 每个 guard 返回 `warnings: string[]`(空数组 = 合规),由 write() 经 `schema-warn` 事件上报。
|
|
129
|
+
*/
|
|
130
|
+
export const DEFAULT_SCHEMA_GUARDS = {
|
|
131
|
+
'STATE.json': (v) => {
|
|
132
|
+
const w = [];
|
|
133
|
+
if (v.coverage !== undefined) {
|
|
134
|
+
if (!Array.isArray(v.coverage)) w.push('coverage 应为数组(schema: [{constraint, tasks}])');
|
|
135
|
+
else if (v.coverage.some((c) => c === null || typeof c !== 'object' || Array.isArray(c))) {
|
|
136
|
+
w.push('coverage 的元素应为对象 {constraint, tasks};检测到非对象元素(下游会静默兜底并渲染空行)');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (v.members !== undefined && !Array.isArray(v.members)) w.push('members 应为数组');
|
|
140
|
+
if (v.phase !== undefined && typeof v.phase !== 'string') w.push('phase 应为字符串');
|
|
141
|
+
if (v.status !== undefined && typeof v.status !== 'string') w.push('status 应为字符串');
|
|
142
|
+
if (v.planDiscarded !== undefined && (v.planDiscarded === null || typeof v.planDiscarded !== 'object')) {
|
|
143
|
+
w.push('planDiscarded 应为对象 {at, reason, goalKey}');
|
|
144
|
+
}
|
|
145
|
+
return w;
|
|
146
|
+
},
|
|
147
|
+
'TASKS.json': (v) => {
|
|
148
|
+
const w = [];
|
|
149
|
+
if (!Array.isArray(v.tasks)) w.push('tasks 应为数组');
|
|
150
|
+
else if (v.tasks.some((t) => t === null || typeof t !== 'object' || Array.isArray(t) || typeof t.id !== 'string' || !t.id)) {
|
|
151
|
+
w.push('tasks 的元素应为对象且带非空字符串 id');
|
|
152
|
+
}
|
|
153
|
+
return w;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 创建受控写入器。
|
|
159
|
+
* @param fsPort 注入口(`nodeFsPort()` 或 `cordisFsPort(ctx.fs)`)
|
|
160
|
+
* @param exclusions 禁写区规则(默认 DEFAULT_EXCLUSIONS)
|
|
161
|
+
* @param onEvent 可选事件回调(用于把重试/拒绝/schema 告警写进 RUN.log)
|
|
162
|
+
* @param schemaGuards 按**文件名**匹配的 schema 告警器(默认 DEFAULT_SCHEMA_GUARDS);只告警不阻断
|
|
163
|
+
* @param identity 写入者身份(`() => ({sessionId, runId, role})` 或对象);用于**归因**(批 2-3 · L1-3′)
|
|
164
|
+
*/
|
|
165
|
+
export function createArtifactWriter({ fsPort, exclusions = DEFAULT_EXCLUSIONS, onEvent = () => {}, schemaGuards = DEFAULT_SCHEMA_GUARDS, identity = null } = {}) {
|
|
166
|
+
if (!fsPort) throw new Error('createArtifactWriter 需要 fsPort');
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 归因(批 2-3 · L1-3′):直接闭合 ROI 评审里的
|
|
170
|
+
* **R-3「漂移不可归因:只能证明文件被改写,不能证明写入进程身份」**。
|
|
171
|
+
*
|
|
172
|
+
* 做法:
|
|
173
|
+
* · 对象工件写入时附带 `_provenance = { writer, at }`(writer 来自 identity);
|
|
174
|
+
* · 每次写入前读回磁盘上的 `revision` 与 `_provenance.writer`:
|
|
175
|
+
* - **revision 比本进程上次写的更小** ⇒ `revision-regression`(有人把文件回退了)
|
|
176
|
+
* - **writer 与本进程身份不同** ⇒ `writer-changed`(别的写者动过)
|
|
177
|
+
* · 两者都**只上报、不阻断**(与 schema 告警同一取向:宁可响亮,不要静默,但不要因此让 run 失败)。
|
|
178
|
+
*
|
|
179
|
+
* 注意:本进程内记忆(`lastWritten`)只覆盖**本进程**写过的目标;跨进程回退无法可靠判定
|
|
180
|
+
* ——所以 writer 变化才是跨进程归因的主要信号。
|
|
181
|
+
*/
|
|
182
|
+
const lastWritten = new Map(); // target -> 本进程最后写入的 revision
|
|
183
|
+
|
|
184
|
+
// ── C1:跨进程互斥(advisory lock)─────────────────────────────────────
|
|
185
|
+
// 为什么需要:本进程内的 `revision`/`lastWritten` 只覆盖**本进程**;实测场景是**另一个 dsh
|
|
186
|
+
// 会话持续改写同一个工件**(两个进程各自有自己的 ARTIFACT 实例,互不感知)。
|
|
187
|
+
// 版本栅栏能拦住"别人已写完",但拦不住"别人**正在**写" ⇒ 两个进程可以同时读到 rev N、
|
|
188
|
+
// 各自写、后写覆盖先写。这把**写入点**用一个 `<target>.etv-lock` 文件序列化:
|
|
189
|
+
// · 拿不到锁 ⇒ 等待并重试(默认最多 ~5s),**不**静默覆盖;
|
|
190
|
+
// · 锁文件超过 staleMs(默认 30s)视为"写者已死"⇒ 接管(进程崩溃不会永久死锁);
|
|
191
|
+
// · 超时仍拿不到 ⇒ 抛 `WRITE_LOCK_TIMEOUT`,让调用方知道"有别的写者在写"。
|
|
192
|
+
// 只锁**写入**,不锁读 —— 读不加锁,CAS 在写侧兜底(重试时重新基线)。
|
|
193
|
+
const WRITE_LOCK_STALE_MS = 30000;
|
|
194
|
+
const WRITE_LOCK_TIMEOUT_MS = 5000;
|
|
195
|
+
async function acquireWriteLock(target, { timeoutMs = WRITE_LOCK_TIMEOUT_MS, staleMs = WRITE_LOCK_STALE_MS } = {}) {
|
|
196
|
+
const lockPath = `${target}.etv-lock`;
|
|
197
|
+
const deadline = Date.now() + timeoutMs;
|
|
198
|
+
for (;;) {
|
|
199
|
+
try {
|
|
200
|
+
await nodeWriteFile(lockPath, `${process.pid} ${Date.now()}`, { flag: 'wx' });
|
|
201
|
+
return { release: async () => { try { await nodeUnlink(lockPath); } catch { /* 已被别人接管 */ } } };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (!e || e.code !== 'EEXIST') throw e;
|
|
204
|
+
try {
|
|
205
|
+
const st = await nodeStat(lockPath);
|
|
206
|
+
if (Date.now() - st.mtimeMs > staleMs) { try { await nodeUnlink(lockPath); } catch { /* best-effort */ } continue; }
|
|
207
|
+
} catch { continue; } // 读完就没了,重试
|
|
208
|
+
if (Date.now() > deadline) {
|
|
209
|
+
const err = new Error(`WRITE_LOCK_TIMEOUT: ${target}(超过 ${timeoutMs}ms 拿不到写锁 ⇒ 有别的写者正在写)`);
|
|
210
|
+
err.code = 'WRITE_LOCK_TIMEOUT';
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const identityOf = () => {
|
|
218
|
+
try {
|
|
219
|
+
const id = typeof identity === 'function' ? identity() : identity;
|
|
220
|
+
return id && typeof id === 'object' ? id : null;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const identityKey = () => {
|
|
226
|
+
const id = identityOf();
|
|
227
|
+
if (!id) return null;
|
|
228
|
+
return [id.sessionId || '', id.runId || '', id.role || ''].join('|');
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 写入一个工件。
|
|
233
|
+
* @param target 目标路径
|
|
234
|
+
* @param content 字符串(原样写)或对象(会被 JSON 序列化,并自动维护 revision)
|
|
235
|
+
* @param expectedRevision 调用方认为的当前 revision;不符则走"重读→重基线→重试一次"
|
|
236
|
+
* @param maxAttempts 含首次的总尝试次数(默认 2 = 首次 + 重试一次)
|
|
237
|
+
* @returns {ok, revision, attempts, error?, code?, violation?, schemaWarnings?, provenance?}
|
|
238
|
+
*/
|
|
239
|
+
async function write(target, content, { expectedRevision = null, maxAttempts = 2 } = {}) {
|
|
240
|
+
// ① 范围硬排除:先拦,不进入任何 IO
|
|
241
|
+
const violation = scopeViolation(target, exclusions);
|
|
242
|
+
if (violation) {
|
|
243
|
+
onEvent({ type: 'scope-denied', target, rule: violation });
|
|
244
|
+
return { ok: false, code: 'SCOPE_DENIED', violation, error: `拒绝写入禁写区(命中规则 ${violation}):${target}` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// C1:写入前持**跨进程锁**。CAS 能拦住"别人已写完",拦不住"别人**正在**写";
|
|
248
|
+
// 这里把写入序列化,让两个进程不会在同一窗口里同时 read-modify-write。
|
|
249
|
+
// 锁是 advisory 的:死锁由 staleMs 兜底(进程崩了别人也能接管),不会永久卡死。
|
|
250
|
+
// ⚠️ 只有真实的**本地文件系统**才锁:cordisFsPort 的 `resolve` 会把目标解析成
|
|
251
|
+
// `host://` 前缀(宿主虚拟路径),而锁用 nodeWriteFile 写的是**真实磁盘文件** —
|
|
252
|
+
// 在 cordis 路径上锁路径根本不存在,会让写入直接 ENOENT(此时锁由宿主版本栅栏兜底)。
|
|
253
|
+
const useLock = fsPort && fsPort.kind === 'node:fs';
|
|
254
|
+
if (!useLock) return writeOnce(target, content, { expectedRevision, maxAttempts });
|
|
255
|
+
const lock = await acquireWriteLock(target).catch((e) => {
|
|
256
|
+
// 拿不到锁(包括超时)不应让整个进程抛错 —— 把它变成写失败的**返回**,
|
|
257
|
+
// 与 write() 的其余失败形态({ok:false, code})一致,让调用方能正常处理。
|
|
258
|
+
return { lockError: e };
|
|
259
|
+
});
|
|
260
|
+
if (lock.lockError) {
|
|
261
|
+
const code = lock.lockError && lock.lockError.code === 'WRITE_LOCK_TIMEOUT' ? 'WRITE_LOCK_TIMEOUT' : 'LOCK_FAILED';
|
|
262
|
+
return { ok: false, code, error: String((lock.lockError && lock.lockError.message) || lock.lockError) };
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
return await writeOnce(target, content, { expectedRevision, maxAttempts });
|
|
266
|
+
} finally {
|
|
267
|
+
await lock.release();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function writeOnce(target, content, { expectedRevision = null, maxAttempts = 2 } = {}) {
|
|
272
|
+
// ⚠️ 只有**纯对象**才走"JSON 序列化 + revision + 归因"路径。
|
|
273
|
+
// 曾经的写法是 `typeof content === 'object'` —— 但 **Buffer / Uint8Array / Array 也是 object**,
|
|
274
|
+
// 于是模板拷贝(`readFile(url)` 不带编码返回 Buffer)会被 `{...buffer}` 展开成
|
|
275
|
+
// `{"0":123,"1":10,…}` 的字节表,**把模板文件写坏**(第 7 轮迁移时引入,第 12 轮由 schema 告警抓出)。
|
|
276
|
+
const isBufferLike = (typeof Buffer !== 'undefined' && Buffer.isBuffer(content)) || content instanceof Uint8Array;
|
|
277
|
+
const isObject = content !== null && typeof content === 'object' && !Array.isArray(content) && !isBufferLike;
|
|
278
|
+
let attempts = 0;
|
|
279
|
+
let lastErr = null;
|
|
280
|
+
|
|
281
|
+
for (let i = 0; i < Math.max(1, maxAttempts); i += 1) {
|
|
282
|
+
attempts += 1;
|
|
283
|
+
try {
|
|
284
|
+
// ② 读当前状态:版本指纹 + revision
|
|
285
|
+
const version = await fsPort.statVersion(target);
|
|
286
|
+
const currentText = await fsPort.readText(target);
|
|
287
|
+
const currentRev = readRevision(currentText);
|
|
288
|
+
|
|
289
|
+
// ③ revision 前置条件:只在**调用方显式声明**时才校验(避免首次创建时误报)
|
|
290
|
+
if (expectedRevision !== null && currentRev !== null && currentRev !== expectedRevision) {
|
|
291
|
+
onEvent({ type: 'revision-stale', target, expected: expectedRevision, actual: currentRev, attempt: attempts });
|
|
292
|
+
lastErr = Object.assign(new Error(`REVISION_STALE: ${target} 期望 ${expectedRevision} 实际 ${currentRev}`), { code: 'REVISION_STALE' });
|
|
293
|
+
continue; // 重读重试(下一轮会拿到新的 currentRev)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ④ 组装内容:对象则自增 revision,并附带**归因**(批 2-3 · L1-3′)
|
|
297
|
+
let payload;
|
|
298
|
+
let nextRev = null;
|
|
299
|
+
let provenance = null;
|
|
300
|
+
if (isObject) {
|
|
301
|
+
nextRev = (currentRev === null ? 1 : currentRev + 1);
|
|
302
|
+
const me = identityKey();
|
|
303
|
+
// 归因检测(只上报、不阻断):
|
|
304
|
+
// · revision 回退:磁盘 revision 比本进程上次写的更小 ⇒ 有人把文件回退了
|
|
305
|
+
// · writer 变化:磁盘上的 writer 与本进程身份不同 ⇒ 别的写者动过
|
|
306
|
+
const prevWritten = lastWritten.get(target);
|
|
307
|
+
if (prevWritten !== undefined && currentRev !== null && currentRev < prevWritten) {
|
|
308
|
+
onEvent({ type: 'revision-regression', target, lastWritten: prevWritten, onDisk: currentRev });
|
|
309
|
+
}
|
|
310
|
+
let onDiskWriter = null;
|
|
311
|
+
if (currentText) {
|
|
312
|
+
try { onDiskWriter = (JSON.parse(currentText) || {})._provenance?.writer ?? null; } catch { onDiskWriter = null; }
|
|
313
|
+
}
|
|
314
|
+
if (me && onDiskWriter && onDiskWriter !== me) {
|
|
315
|
+
onEvent({ type: 'writer-changed', target, onDiskWriter, me });
|
|
316
|
+
}
|
|
317
|
+
provenance = (me || onDiskWriter) ? { writer: me || onDiskWriter, at: new Date().toISOString() } : null;
|
|
318
|
+
const base = provenance ? { ...content, _provenance: provenance, revision: nextRev } : { ...content, revision: nextRev };
|
|
319
|
+
payload = JSON.stringify(base, null, 2) + '\n';
|
|
320
|
+
} else {
|
|
321
|
+
payload = String(content);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ⑤ 版本栅栏写入
|
|
325
|
+
const intent = version === null ? { kind: 'createIfAbsent' } : { kind: 'replaceIfVersion', version };
|
|
326
|
+
await fsPort.writeText(target, payload, intent);
|
|
327
|
+
if (attempts > 1) onEvent({ type: 'write-retried', target, attempts });
|
|
328
|
+
// ⑤′ schema 告警(批 2-4 · L1-4′):**写完才报、绝不阻断** —— 保证旧 run 不会因不合规而消失
|
|
329
|
+
const guard = isObject ? schemaGuards[String(target).split('/').pop()] : null;
|
|
330
|
+
let schemaWarnings = [];
|
|
331
|
+
if (typeof guard === 'function') {
|
|
332
|
+
try {
|
|
333
|
+
schemaWarnings = guard(content) || [];
|
|
334
|
+
} catch { schemaWarnings = []; }
|
|
335
|
+
for (const msg of schemaWarnings) onEvent({ type: 'schema-warn', target, message: msg });
|
|
336
|
+
}
|
|
337
|
+
// 记住本进程写过的 revision —— 供后续写入检测"回退"(批 2-3 归因)
|
|
338
|
+
if (isObject && nextRev !== null) lastWritten.set(target, nextRev);
|
|
339
|
+
return { ok: true, revision: nextRev, attempts, schemaWarnings, provenance };
|
|
340
|
+
} catch (e) {
|
|
341
|
+
lastErr = e;
|
|
342
|
+
const stale = e && (e.code === 'FS_STALE_VERSION' || /FS_STALE_VERSION/.test(String(e.message || '')));
|
|
343
|
+
onEvent({ type: stale ? 'version-stale' : 'write-error', target, attempt: attempts, error: String(e && e.message) });
|
|
344
|
+
if (!stale) break; // 非"陈旧"类错误:重试无意义,直接上抛
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
code: (lastErr && lastErr.code) || 'WRITE_FAILED',
|
|
351
|
+
error: String(lastErr && lastErr.message ? lastErr.message : lastErr),
|
|
352
|
+
attempts,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 读取并返回 {text, revision};文件不存在返回 {text:null, revision:null}。 */
|
|
357
|
+
async function read(target) {
|
|
358
|
+
const text = await fsPort.readText(target);
|
|
359
|
+
return { text, revision: readRevision(text) };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 同 `write`,但**失败即抛**。
|
|
364
|
+
* 迁移既有写入点时用它:原 `writeFile` 失败会抛,若换成不检查返回值的 `write`,
|
|
365
|
+
* 就会把"写失败"静默成"写成功"。迁移必须保持 loud。
|
|
366
|
+
*/
|
|
367
|
+
async function must(target, content, opts = {}) {
|
|
368
|
+
const r = await write(target, content, opts);
|
|
369
|
+
if (!r.ok) {
|
|
370
|
+
const err = new Error(`工件写入失败[${r.code}] ${target}:${r.error}`);
|
|
371
|
+
err.code = r.code;
|
|
372
|
+
err.violation = r.violation;
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
return r;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { write, must, read, fsPort, exclusions, schemaGuards };
|
|
379
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// 命令解析(B 线第 8 项 · `command-parse.js`):`/team` 的**整个 CLI 面** —— 用法文本、
|
|
2
|
+
// 命名 profile、以及把一行输入解析成 `{kind, …}` 的 `parseTeamCommand`。
|
|
3
|
+
//
|
|
4
|
+
// 为什么单独成模块:这三样是**同一件事的三面**(有哪些命令 / 命令怎么拼 / 输入怎么解析),
|
|
5
|
+
// 以前散在 5000 行编排器的三处(相隔 150 行),改一条命令要跨好几段找三个地方。
|
|
6
|
+
//
|
|
7
|
+
// 边界:**这里只解析、不执行** —— 不碰 IO、不认识 `ctx`、不调用任何 handler;返回值只是
|
|
8
|
+
// `{kind, …}`,由 `command.js` 的 switch 分发。所以它可以被纯单测(`command-parse.test.mjs`
|
|
9
|
+
// 早就在用 `_live.parseTeamCommand` 做行为断言,这次搬家**一行都不用改**)。
|
|
10
|
+
|
|
11
|
+
import { DEFAULT_ROLES } from './vocab.js';
|
|
12
|
+
|
|
13
|
+
// Named multi-role profiles: preset roster + deliverable + a short note the lead reads.
|
|
14
|
+
export const PROFILE_DEFS = {
|
|
15
|
+
delivery: { roles: DEFAULT_ROLES, deliverable: 'code+artifacts', note: '完整交付:澄清→调研→设计→实现→评审→测试→交付' },
|
|
16
|
+
review: { roles: ['pm', 'architect', 'researcher', 'reviewer', 'qa'], deliverable: 'artifacts-only', note: '只做设计与代码评审:出 REVIEW/TEST 工件,不改代码' },
|
|
17
|
+
design: { roles: ['pm', 'architect', 'researcher'], deliverable: 'artifacts-only', note: '只出规格与架构设计:Ultra Spec + 接口契约,不改代码' },
|
|
18
|
+
refactor: { roles: ['pm', 'architect', 'backend', 'reviewer', 'qa'], deliverable: 'code+artifacts', note: '重构:契约冻结→实现→评审→测试' },
|
|
19
|
+
research: { roles: ['pm', 'researcher', 'architect'], deliverable: 'artifacts-only', note: '调研:代码定位/依赖/存量约束 + 结论' },
|
|
20
|
+
};
|
|
21
|
+
export function resolveProfile(name, fallback) {
|
|
22
|
+
const d = PROFILE_DEFS[name];
|
|
23
|
+
if (!d) return fallback;
|
|
24
|
+
return { ...fallback, roles: d.roles, deliverable: d.deliverable, profileNote: d.note, profile: name };
|
|
25
|
+
}
|
|
26
|
+
export const USAGE = [
|
|
27
|
+
'Usage:',
|
|
28
|
+
' /team <task> 一句话组队(一次性,自动组队并交付)',
|
|
29
|
+
' /team help 显示本用法(等价:? / -h / --help)',
|
|
30
|
+
' /team --persist <task> 启动持久化活团队(成员可反复指挥,跨会话恢复)',
|
|
31
|
+
' /team --no-code <task> 只产出计划/评审/测试工件,不改代码',
|
|
32
|
+
' /team --confirm <task> 大需求先确认:建 run 但**不自动派工**,面板点「执行」才开工',
|
|
33
|
+
' /team --tier <档位> <task> 指定流程档位:快速档 / 标准档 / 严格档(不写则在浮层三选一,每次都问)',
|
|
34
|
+
' /team tier <档位> [--run <run>] 运行中**升档**(快速档→标准档→严格档;不允许降档)',
|
|
35
|
+
' /team rule <内容> 立一条常驻规则(如「改完没问题就提交并推送」);/team rules 查看',
|
|
36
|
+
' /team --roles a,b,c <task> 指定/增补角色(覆盖默认编制)',
|
|
37
|
+
' /team --name <runId> <task> 指定 runId(默认由任务文本 + HHMMSS 生成)',
|
|
38
|
+
' /team --profile <name> <task> 套用命名模板(delivery|review|design|refactor|research)',
|
|
39
|
+
' /team --allow-rebuild <task> 显式解锁「该目标已被丢弃」的自动重建拦截(会记入 RUN.log)',
|
|
40
|
+
' ⚠ flags 一律写在 <task> **之前**(后置会被当成任务文本)',
|
|
41
|
+
' /team status 查看所有 run 的阶段、成员与模型计划(⚠违规实时可见)',
|
|
42
|
+
' /team models [<run>] 查看每个角色的成本/模型计划(轻=快模型,重=顶配)',
|
|
43
|
+
' /team canvas [<run>] 生成该 run 的可视化团队画布 (HTML)',
|
|
44
|
+
' /team canvas --watch [<run>] 生成画布并轮询 host 状态接口实时刷新(live 版)',
|
|
45
|
+
' /team learn 聚合所有 run 的日志 → METRICS.md + 蒸馏经验到 LEARNINGS.md',
|
|
46
|
+
' /team learn --deep 再写一份 DEEP-LEARN.md(逐 run 的卡点清单 + 映射到的规则)',
|
|
47
|
+
' /team index 扫描本工程 → team/REPOWIKI.md 项目知识库(供角色检索)',
|
|
48
|
+
' /team codeindex 构建代码索引(符号/词频 → team/CODEINDEX.json,Qoder 式)',
|
|
49
|
+
' /team search <关键词> 代码索引秒级搜索(路径 100 > 符号 60 > 高频词 20)',
|
|
50
|
+
' /team devcontainer [--write] 生成可复现校验环境配置(按语言选镜像;--write 写入 .devcontainer/)',
|
|
51
|
+
' /team migrate <run> 把旧 run 的 TASKS.json 升级到新 schema(启用质量门禁/自动调度)',
|
|
52
|
+
' /team check [<run>] 状态机/质量门禁一致性校验(host 强制)',
|
|
53
|
+
' /team detail [<run>] 单个 run 运行详情(目标/时间线/产物/首个失败点)',
|
|
54
|
+
' /team limit <run> [--max-runs N] [--deadline YYYY-MM-DD] [--clear] 配额:次数/截止(超限后拒绝恢复并提示)',
|
|
55
|
+
' /team task <id> <状态> [--run <run>] 快捷回写单任务状态(如 /team task B1 done --note 接口通过)',
|
|
56
|
+
' /team resume <run> 恢复/继续某个 run',
|
|
57
|
+
' /team settle [<run>] 清算冷启动后「没人做」的在飞任务(→ pending + attempt+1 + 留痕)',
|
|
58
|
+
' /team wait [<run>] 查看在飞任务进展(不阻塞;无进展超 5 分钟会提示)',
|
|
59
|
+
' /team clear [<run>] 清理一个或全部 run',
|
|
60
|
+
].join('\n');
|
|
61
|
+
|
|
62
|
+
// ── command parsing ────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function parseTeamCommand(rawInput) {
|
|
65
|
+
const input = (rawInput ?? '').trim();
|
|
66
|
+
if (input.length === 0) return { kind: 'help' };
|
|
67
|
+
const lower = input.toLowerCase();
|
|
68
|
+
// ⚠️ `help` 必须有独立分支:旧实现只在**空输入**时返回 help,`/team help` 会穿过所有
|
|
69
|
+
// 子命令判断、落到最后的 `create` 分支,把 "help" 当成任务文本 ⇒ **建出一个
|
|
70
|
+
// runId 形如 `help-HHMMSS` 的空 run**。用户一按就中,且几乎不会自己发现。
|
|
71
|
+
if (lower === 'help' || lower === '?' || lower === '-h' || lower === '--help' || lower === '帮助') return { kind: 'help' };
|
|
72
|
+
if (lower === 'status') return { kind: 'status' };
|
|
73
|
+
if (lower === 'members') return { kind: 'members' };
|
|
74
|
+
if (lower === 'models') return { kind: 'models' };
|
|
75
|
+
if (lower.startsWith('models ')) return { kind: 'models', run: input.slice(7).trim() };
|
|
76
|
+
if (lower === 'task') return { kind: 'error', text: 'Usage: /team task <id> <状态> [--run <run>] [--note <原因>]' };
|
|
77
|
+
if (lower.startsWith('task ')) {
|
|
78
|
+
const parts = input.slice(5).trim().split(/\s+/);
|
|
79
|
+
let run = null, note = '';
|
|
80
|
+
const rest = parts.filter((p) => !p.startsWith('--'));
|
|
81
|
+
let i = parts.indexOf('--run'); if (i >= 0 && parts[i + 1]) run = parts[i + 1];
|
|
82
|
+
i = parts.indexOf('--note'); if (i >= 0 && parts[i + 1]) { note = parts.slice(i + 1).join(' '); }
|
|
83
|
+
return { kind: 'task', id: rest[0] || '', status: (rest[1] || '').toLowerCase(), run, note };
|
|
84
|
+
}
|
|
85
|
+
if (lower === 'learn') return { kind: 'learn', deep: false };
|
|
86
|
+
if (lower.startsWith('learn ')) return { kind: 'learn', deep: /--deep/.test(input) };
|
|
87
|
+
// G 线:`/team tier <档位> [--run <run>]` —— 只允许**升档**(跑不顺就降级等于用档位掩盖问题)。
|
|
88
|
+
if (lower === 'tier') return { kind: 'error', text: 'Usage: /team tier <档位> [--run <run>](档位:快速档 / 标准档 / 严格档;只允许升档)' };
|
|
89
|
+
if (lower.startsWith('tier ')) {
|
|
90
|
+
const parts = input.slice(5).trim().split(/\s+/);
|
|
91
|
+
let run = null;
|
|
92
|
+
const ri = parts.indexOf('--run'); if (ri >= 0 && parts[ri + 1]) run = parts[ri + 1];
|
|
93
|
+
const rest = parts.filter((p) => !p.startsWith('--') && p !== run);
|
|
94
|
+
return { kind: 'tier', tier: rest[0] || '', run };
|
|
95
|
+
}
|
|
96
|
+
if (lower === 'board') return { kind: 'board' };
|
|
97
|
+
if (lower === 'detail') return { kind: 'detail' };
|
|
98
|
+
if (lower.startsWith('detail ')) return { kind: 'detail', run: input.slice(7).trim() };
|
|
99
|
+
if (lower === 'limit') return { kind: 'error', text: 'Usage: /team limit <run> --max-runs <N> | --deadline <YYYY-MM-DD> | --clear' };
|
|
100
|
+
if (lower.startsWith('limit ')) {
|
|
101
|
+
const parts = input.slice(6).trim().split(/\s+/);
|
|
102
|
+
let maxRuns = null, deadline = null, clear = false;
|
|
103
|
+
const rest = [];
|
|
104
|
+
for (let i = 0; i < parts.length; i++) {
|
|
105
|
+
if (parts[i] === '--max-runs' && parts[i + 1]) { maxRuns = Number(parts[i + 1]); i++; }
|
|
106
|
+
else if (parts[i] === '--deadline' && parts[i + 1]) { deadline = parts[i + 1]; i++; }
|
|
107
|
+
else if (parts[i] === '--clear') clear = true;
|
|
108
|
+
else rest.push(parts[i]);
|
|
109
|
+
}
|
|
110
|
+
return { kind: 'limit', run: rest[0] || '', maxRuns, deadline, clear };
|
|
111
|
+
}
|
|
112
|
+
if (lower === 'canvas') return { kind: 'canvas', watch: false };
|
|
113
|
+
if (lower.startsWith('canvas ')) {
|
|
114
|
+
const watch = /(^|\s)--watch(\s|$)/.test(input);
|
|
115
|
+
const run = input.slice(7).replace(/\s*--watch\s*/gi, '').trim();
|
|
116
|
+
return { kind: 'canvas', run, watch };
|
|
117
|
+
}
|
|
118
|
+
if (lower === 'clear') return { kind: 'clear' };
|
|
119
|
+
if (lower.startsWith('clear ')) return { kind: 'clear', run: input.slice(6).trim() };
|
|
120
|
+
// L3-2′ 可清算:显式清算冷启动后的搁浅任务(回 pending + attempt+1 + 留痕)
|
|
121
|
+
if (lower === 'settle') return { kind: 'settle', run: null };
|
|
122
|
+
if (lower.startsWith('settle ')) return { kind: 'settle', run: input.slice(7).trim() };
|
|
123
|
+
if (lower === 'index') return { kind: 'index' };
|
|
124
|
+
if (lower === 'codeindex') return { kind: 'codeindex' };
|
|
125
|
+
if (lower === 'search') return { kind: 'error', text: 'Usage: /team search <关键词>' };
|
|
126
|
+
if (lower.startsWith('search ')) return { kind: 'search', kw: input.slice(7).trim() };
|
|
127
|
+
if (lower === 'devcontainer') return { kind: 'devcontainer', write: false };
|
|
128
|
+
if (/^devcontainer\s+--write(\s+.*)?$/.test(input)) return { kind: 'devcontainer', write: true };
|
|
129
|
+
if (lower.startsWith('devcontainer ')) return { kind: 'devcontainer', write: /--write/.test(input) };
|
|
130
|
+
if (lower === 'check') return { kind: 'check' };
|
|
131
|
+
if (lower.startsWith('check ')) return { kind: 'check', run: input.slice(6).trim() };
|
|
132
|
+
if (lower === 'migrate') return { kind: 'error', text: 'Usage: /team migrate <run>' };
|
|
133
|
+
if (lower.startsWith('migrate ')) return { kind: 'migrate', run: input.slice(8).trim() };
|
|
134
|
+
if (lower === 'resume') return { kind: 'error', text: 'Usage: /team resume <run>' };
|
|
135
|
+
if (lower.startsWith('resume ')) return { kind: 'resume', run: input.slice(7).trim() };
|
|
136
|
+
if (lower === 'wait') return { kind: 'wait', run: '' };
|
|
137
|
+
if (lower.startsWith('wait ')) return { kind: 'wait', run: input.slice(5).trim() };
|
|
138
|
+
if (lower === 'rule' || lower === 'rules') return { kind: 'rules', action: 'list', rule: '' };
|
|
139
|
+
if (lower.startsWith('rule ')) return { kind: 'rules', action: 'add', rule: input.slice(5).trim() };
|
|
140
|
+
if (lower.startsWith('rules ')) return { kind: 'rules', action: 'add', rule: input.slice(6).trim() };
|
|
141
|
+
|
|
142
|
+
// Consume leading flags; everything after the last flag is the task text.
|
|
143
|
+
const tokens = input.split(/\s+/);
|
|
144
|
+
let i = 0;
|
|
145
|
+
let persist = false;
|
|
146
|
+
let noCode = false;
|
|
147
|
+
let roles = null;
|
|
148
|
+
let runName = null;
|
|
149
|
+
let profile = null;
|
|
150
|
+
let allowRebuild = false; // 批 0-3:显式解锁「该目标已被用户丢弃」的拦截
|
|
151
|
+
let confirm = false; // 大需求执行前询问:先建 run + 立待决,**不自动派工**,等用户拍板
|
|
152
|
+
let tierRaw = null; // G 线:显式 `--tier <快速档|标准档|严格档|quick|standard|strict>`
|
|
153
|
+
while (i < tokens.length && tokens[i].startsWith('--')) {
|
|
154
|
+
const flag = tokens[i];
|
|
155
|
+
if (flag === '--persist') persist = true;
|
|
156
|
+
else if (flag === '--no-code') noCode = true;
|
|
157
|
+
else if (flag === '--allow-rebuild') allowRebuild = true;
|
|
158
|
+
else if (flag.startsWith('--roles')) {
|
|
159
|
+
const eq = flag.indexOf('=');
|
|
160
|
+
if (eq >= 0) roles = flag.slice(eq + 1).split(',').map((s) => s.trim()).filter(Boolean);
|
|
161
|
+
else { i += 1; if (i < tokens.length) roles = tokens[i].split(',').map((s) => s.trim()).filter(Boolean); }
|
|
162
|
+
} else if (flag.startsWith('--name')) {
|
|
163
|
+
const eq = flag.indexOf('=');
|
|
164
|
+
if (eq >= 0) runName = flag.slice(eq + 1).trim();
|
|
165
|
+
else { i += 1; if (i < tokens.length) runName = tokens[i]; }
|
|
166
|
+
} else if (flag.startsWith('--profile')) {
|
|
167
|
+
const eq = flag.indexOf('=');
|
|
168
|
+
if (eq >= 0) profile = flag.slice(eq + 1).trim();
|
|
169
|
+
else { i += 1; if (i < tokens.length) profile = tokens[i]; }
|
|
170
|
+
} else if (flag.startsWith('--tier')) {
|
|
171
|
+
// 支持 `--tier=quick` 与 `--tier quick` 两种写法(与 --roles/--profile 一致)。
|
|
172
|
+
const eq = flag.indexOf('=');
|
|
173
|
+
if (eq >= 0) tierRaw = flag.slice(eq + 1).trim();
|
|
174
|
+
else { i += 1; if (i < tokens.length) tierRaw = tokens[i].trim(); }
|
|
175
|
+
} else if (flag === '--confirm') confirm = true;
|
|
176
|
+
i += 1;
|
|
177
|
+
}
|
|
178
|
+
const task = tokens.slice(i).join(' ').trim();
|
|
179
|
+
if (!task) return { kind: 'help' };
|
|
180
|
+
return { kind: 'create', task, persist, noCode, roles, runName, profile, allowRebuild, confirm, tierRaw };
|
|
181
|
+
}
|