lead 的外部 run 驾驶舱:扫描本会话的 omd-lite run($.delegate.runs)、守 launch(任务书 C-1b · 允许改动不重叠 · 起片间隔 · 磁盘预算)、把 --gate 包进按内存排队的 gate-slot.sh、run 结束合成一条叫醒、合并后清…

The lead's cockpit for external omd-lite runs: lists this session's runs, guards launches, queues gates by free memory, wakes the lead when runs end, and cleans merged worktrees.
lead 的外部 run 驾驶舱。确定性 harness:不切片、不写任务书、不选执行器(D-1)。文中 D-x / C-x / INV-x / GWT-x 是设计契约里的条目编号,代码注释沿用。
$.delegate.runs()(C-0,类型 types/index.d.ts):本会话起的 omd-lite run。bin/scan.mjs 读 ~/.omd-lite/runs/<label>/; 结束 = meta.json.wall_seconds 有值(D-8,result.json 会先出);没 result 且 driver 已死 = interrupted; failureClass 取自 result.json 与末个 pi agent_end / mcode exec.completed(429 / 529 / 2056 / 5xx / 断流 → provider)。 有 run 在跑每 2 s 扫一次,否则 10 s;提到 omd-lite / omdl- 的 Bash 调用后立即再扫。$.prompt.submit(没有 run 还在跑就立即发):label · state · 轮数 · failureClass · diffstat · 越界 · 闸日志末 20 行; provider 失败不附闸日志,只说「换供应商或等窗口,不要回灌给执行方」(INV-8)。omd-lite run … 或仓配置的 launch 脚本):任务书缺「原始目标原文」/「允许改动:」/「## 成功判据」、允许改动里的路径不存在又没标(新建)、 与在跑 run 的允许改动重叠、距上次 launch 不足 launchSpacingSeconds、omdl-* 超 diskBudgetGB 或盘余 < minFreeGB → deny 并逐条说明。不设并发上限(D-16)。--gate <cmd> 改写成 --gate "<mod>/bin/gate-slot.sh '<cmd>'";槽数 = max(1, ⌊(MemAvailable − 8 GB) ÷ 2 GB⌋),flock 排队。git merge … omdl/<label> 或仓配置的 merge 脚本成功后,确认分支已进 HEAD、worktree 干净且没有非缓存的被忽略文件,再跑 <omdLitePath> clean <label>(gc 不支持按 run 限定)。/runs:只读表。.claude/delegate.json(可选,放在会话 cwd){
"launch": "scripts/dev/omdl-launch.sh",
"launchArgs": ["name", "base", "spec", "gate..."],
"merge": "scripts/dev/omdl-merge.sh",
"gate": "scripts/dev/gate-slice.sh main",
"envFile": "/path/to/app.env",
"executor": "pi",
"model": "<provider>/<model>",
"fullGates": "final",
"sliceGate": "scripts/dev/gate-slice.sh",
"layerGates": [
{ "pattern": "^db/migrations/", "cmd": "bash db/ci/replay-local.sh" },
{ "pattern": "(^|/)package(-lock)?\\.json$", "cmd": "npm run build" },
{ "pattern": "\\.css$|^src/components/ui/", "cmd": "npm run check:design" }
],
"fullGatesSummary": "/tmp/full-gates-{sha}/summary.txt",
"measure": ["perf-gate\\.sh"]
}
没有配置 = 只认 omd-lite run。
D-18 三级闸门(四项都写才启用,缺哪项哪部分不生效):
merge 命令成功后,工具结果里追加一段提醒:<sliceGate> <批前 sha>(这批改动合集的组合片闸)+ 合集命中的 layerGates。同一批的后续合并沿用第一片合并前的 sha。<sliceGate> <批前 sha> 且命令没报错 → 这批清账,下一次合并重新记批前 sha。以日志判红绿,mod 只看命令有没有出错。measure 正则 → 读 fullGatesSummary({sha} = git rev-parse --short HEAD);文件不在或有任何一行 rc≠0 → 拦下并点名红的那道。executor / model / gate / envFile 只在 omd-lite run 缺对应参数时补上。| 选项 | 默认 | 说明 |
|---|---|---|
launchSpacingSeconds | 10 | 两次 launch 之间最少间隔;0 = 不限 |
diskBudgetGB | 40 | 仓库旁 omdl-* worktree 总量上限 |
minFreeGB | 50 | 仓库所在盘最少余量 |
omdLitePath | omd-lite | 合并后清理用的 CLI:命令名(走 PATH)、绝对路径或 ~/ 开头 |
闸槽可用环境变量调:DELEGATE_GATE_SLOTS(强制槽数)· DELEGATE_GATE_RESERVE_GB(默认 8)· DELEGATE_GATE_PER_GB(默认 2)· DELEGATE_GATE_SLOT_DIR(默认 /tmp/delegate-gate-slots)。
claude plugin validate delegate && claude plugin test delegate
node --test delegate/bin/scan.test.mjs
bash delegate/bin/gate-slot.test.sh
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install delegate@claude-lead-harnesshooks/register.ts 387 lines1// delegate: the lead's cockpit for omd-lite runs (docs/plan/2026-10-10-lead-harness.md, slice 4).
2// A deterministic harness: it scans runs, guards launches, wraps gates in gate-slot.sh, wakes the
3// lead when runs end and cleans a merged run's worktree. It never slices work, writes briefs or
4// picks executors (D-1).
5import type { EngineInterface, Register } from 'claude-code'
6
7import {
8 briefGaps,
9 allowedPaths,
10 combinedGateNote,
11 findLaunches,
12 findMerges,
13 fullGatesVerdict,
14 isMeasure,
15 runsSliceGate,
16 isTestPath,
17 mentionsRuns,
18 overlap,
19 rewriteLaunches,
20 runsTable,
21 toDelegateRun,
22 wakeText,
23 type Launch,
24 type RepoConfig,
25 type ScanRun,
26} from './logic'
27
28type $ = EngineInterface
29
30/** One wake per this window (D-21 ②). */
31const WAKE_WINDOW_MS = 60_000
32const DISK_CACHE_MS = 60_000
33const CONFIG_CACHE_MS = 10_000
34// On PATH by default; userConfig omdLitePath may be absolute or ~/-relative.
35const DEFAULT_OMD_LITE = 'omd-lite'
36
37const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
38const dirname = (p: string) => p.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
39
40// Module state, rebuilt by a scan after a reload; what must survive one is in $.store (WakeBook).
41let cache: ScanRun[] = []
42let sessionId = ''
43let scanning: Promise<ScanRun[]> | null = null
44let windowTimer: { cancel: () => void } | null = null
45const diskCache = new Map<string, { at: number; usedKB: number; freeKB: number }>()
46let configCache: { cwd: string; at: number; cfg: RepoConfig } | null = null
47
48let opts = {
49 launchSpacingSeconds: 10,
50 diskBudgetGB: 40,
51 minFreeGB: 50,
52 omdLitePath: DEFAULT_OMD_LITE,
53}
54
55// ── scanning and the wake ────────────────────────────────────────────────────────────
56
57async function scan($: $): Promise<ScanRun[]> {
58 if (!sessionId) return cache
59 const out = await $.process.run(['node', `${$.plugin.root}/bin/scan.mjs`, sessionId], { timeoutMs: 15_000 })
60 if (out.exitCode !== 0) return cache
61 try {
62 return JSON.parse(out.stdout) as ScanRun[]
63 } catch {
64 return cache
65 }
66}
67
68/** One scan at a time; every caller gets the scan in flight. */
69function refresh($: $): Promise<ScanRun[]> {
70 scanning ??= (async () => {
71 try {
72 const runs = await scan($)
73 cache = runs
74 await noteEnded($, runs)
75 return runs
76 } finally {
77 scanning = null
78 }
79 })()
80 return scanning
81}
82
83/** What the wake must remember across a reload, per session: `$.store` key `wake:<session id>`. */
84type WakeBook = { known: Record<string, string>; pending: string[]; at: number }
85const STALE_BOOK_MS = 2 * 24 * 3600 * 1000
86
87async function readBook($: $): Promise<WakeBook | undefined> {
88 return (await $.store.get(`wake:${sessionId}`)) as WakeBook | undefined
89}
90
91async function writeBook($: $, book: Omit<WakeBook, 'at'>) {
92 await $.store.set(`wake:${sessionId}`, { ...book, at: await $.clock.now() })
93}
94
95/** Other sessions' books untouched for two days. */
96async function pruneBooks($: $) {
97 const now = await $.clock.now()
98 for (const key of await $.store.keys()) {
99 if (!key.startsWith('wake:') || key === `wake:${sessionId}`) continue
100 const book = (await $.store.get(key)) as WakeBook | undefined
101 if (!book || now - book.at > STALE_BOOK_MS) await $.store.delete(key)
102 }
103}
104
105/** Runs seen running (or never seen) and now ended join the wake; the first scan only sets the baseline. */
106async function noteEnded($: $, runs: ScanRun[]) {
107 const book = await readBook($)
108 const before = book?.known ?? {}
109 const ended = book ? runs.filter(r => r.state !== 'running' && (before[r.label] === 'running' || before[r.label] === undefined)) : []
110 const waiting = [...new Set([...(book?.pending ?? []), ...ended.map(r => r.label)])]
111 await writeBook($, { known: Object.fromEntries(runs.map(r => [r.label, r.state])), pending: waiting })
112 if (!waiting.length) return
113 // Nothing left running: no reason to hold the window open.
114 if (!runs.some(r => r.state === 'running')) await wake($)
115 else windowTimer ??= $.clock.after(WAKE_WINDOW_MS, () => void wake($).catch(() => undefined))
116}
117
118/** One message for every run that ended in the window (GWT-15). */
119async function wake($: $) {
120 windowTimer?.cancel()
121 windowTimer = null
122 const book = await readBook($)
123 if (!book?.pending.length) return
124 await writeBook($, { known: book.known, pending: [] })
125 const runs = book.pending.flatMap(l => cache.filter(r => r.label === l))
126 if (runs.length) await $.prompt.submit({ text: wakeText(runs) })
127}
128
129// ── the launch guard ─────────────────────────────────────────────────────────────────
130
131async function repoConfig($: $): Promise<{ cwd: string; cfg: RepoConfig }> {
132 const cwd = await $.session.cwd()
133 const now = await $.clock.now()
134 if (configCache && configCache.cwd === cwd && now - configCache.at < CONFIG_CACHE_MS) return configCache
135 let cfg: RepoConfig = {}
136 const file = `${cwd}/.claude/delegate.json`
137 if (await $.fs.exists(file)) {
138 try {
139 cfg = JSON.parse(await $.fs.read(file)) as RepoConfig
140 } catch {
141 cfg = {}
142 }
143 }
144 configCache = { cwd, at: now, cfg }
145 return configCache
146}
147
148async function resolvePath($: $, p: string, cwd: string) {
149 if (p.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}/${p.slice(2)}`
150 return p.startsWith('/') ? p : `${cwd}/${p}`
151}
152
153async function diskUse($: $, parent: string) {
154 const now = await $.clock.now()
155 const hit = diskCache.get(parent)
156 if (hit && now - hit.at < DISK_CACHE_MS) return hit
157 const script = 'du -skc "$1"/omdl-* 2>/dev/null | tail -1 | cut -f1; df -Pk "$1" | tail -1 | awk \'{print $4}\''
158 const out = await $.process.run(['sh', '-c', script, 'sh', parent], { timeoutMs: 120_000 })
159 const [used = '0', free = '0'] = out.stdout.trim().split('\n')
160 const row = { at: now, usedKB: Number(used) || 0, freeKB: Number(free) || 0 }
161 diskCache.set(parent, row)
162 return row
163}
164
165/** Every reason a launch must not start; empty when it may. */
166async function judge($: $, launches: Launch[], cwd: string): Promise<string[]> {
167 const problems: string[] = []
168 const mine: { label: string; paths: string[] }[] = []
169 for (const l of launches) {
170 const label = l.name ?? '(无 --name)'
171 const repo = await resolvePath($, l.repo ?? '.', cwd)
172 if (!l.spec) {
173 problems.push(`${label}:没找到 --spec`)
174 continue
175 }
176 const specPath = await resolvePath($, l.spec, repo)
177 let text: string
178 try {
179 text = await $.fs.read(specPath)
180 } catch {
181 problems.push(`${label}:读不到任务书 ${specPath}`)
182 continue
183 }
184 for (const gap of briefGaps(text)) problems.push(`${label}:任务书${gap}(C-1b)`)
185 const paths = allowedPaths(text) ?? []
186 for (const p of paths) {
187 if (p.isNew || isTestPath(p.path)) continue
188 if (!(await $.fs.exists(await resolvePath($, p.path, repo)))) problems.push(`${label}:「允许改动」里的 ${p.path} 不存在,也没标(新建)`)
189 }
190 mine.push({ label, paths: paths.map(p => p.path) })
191 }
192
193 // GWT-8: no two live runs may share a path they are allowed to change.
194 const live = (await refresh($)).filter(r => r.state === 'running' && r.allowed?.length)
195 for (const [k, m] of mine.entries()) {
196 for (const r of live) {
197 const shared = overlap(m.paths, r.allowed ?? [])
198 if (shared.length) problems.push(`${m.label}:「允许改动」与在跑的 ${r.label} 重叠:${shared.join(' ')}`)
199 }
200 for (const other of mine.slice(k + 1)) {
201 const shared = overlap(m.paths, other.paths)
202 if (shared.length) problems.push(`${m.label}:「允许改动」与同一条命令里的 ${other.label} 重叠:${shared.join(' ')}`)
203 }
204 }
205
206 // GWT-9: launches spaced (vt IPC socket, provider bursts). No concurrency cap (D-16).
207 const spacing = opts.launchSpacingSeconds
208 if (spacing > 0) {
209 if (launches.length > 1) problems.push(`一条命令起了 ${launches.length} 个 run:分开起,间隔 ≥ ${spacing} s`)
210 const last = Number((await $.store.get('lastLaunchAt')) ?? 0)
211 const ago = ((await $.clock.now()) - last) / 1000
212 if (last && ago < spacing) problems.push(`距上次 launch 仅 ${Math.floor(ago)} s,间隔须 ≥ ${spacing} s(launchSpacingSeconds),${Math.ceil(spacing - ago)} s 后再起`)
213 }
214
215 // GWT-13 / D-17 ③: the disk budget.
216 const repos = new Set<string>()
217 for (const l of launches) repos.add(dirname(await resolvePath($, l.repo ?? '.', cwd)))
218 for (const parent of repos) {
219 const d = await diskUse($, parent)
220 const usedGB = d.usedKB / 1048576
221 const freeGB = d.freeKB / 1048576
222 if (usedGB > opts.diskBudgetGB) {
223 problems.push(`${parent}/omdl-* worktree 共 ${usedGB.toFixed(1)} GB,超预算 ${opts.diskBudgetGB} GB:先合并收尾,再 omd-lite gc --apply(或 omd-lite clean <label>)`)
224 }
225 if (freeGB < opts.minFreeGB) problems.push(`盘余 ${freeGB.toFixed(1)} GB < ${opts.minFreeGB} GB:先 omd-lite gc --apply 清 worktree`)
226 }
227 return problems
228}
229
230// ── after a merge (D-17 ②) ───────────────────────────────────────────────────────────
231
232const REGEN = /(^|\/)(node_modules|\.next|\.venv[^/]*|\.turbo|\.cache|coverage|__pycache__|\.expo|dist|\.vitest|\.pytest_cache)(\/|$)|\.tsbuildinfo$/
233
234async function cleanMerged($: $, label: string, cwd: string): Promise<string> {
235 const home = (await $.env.get('HOME')) ?? ''
236 let meta: { repo?: string; worktree?: string; branch?: string }
237 try {
238 meta = JSON.parse(await $.fs.read(`${home}/.omd-lite/runs/${label}/meta.json`)) as typeof meta
239 } catch {
240 return `${label}:没有 run 记录,worktree 未动`
241 }
242 const { repo, worktree, branch } = meta
243 if (!repo || !worktree || !branch) return `${label}:meta 缺 repo / worktree / branch,未动`
244 if (!(await $.fs.exists(worktree))) return `${label}:worktree 已不在`
245 const merged = await Promise.all(
246 [cwd, repo].map(async dir => (await $.process.run(['git', '-C', dir, 'merge-base', '--is-ancestor', branch, 'HEAD'])).exitCode === 0),
247 )
248 if (!merged.some(Boolean)) return `${label}:${branch} 还不在 HEAD 里,worktree 未动`
249 const status = await $.process.run(['git', '-C', worktree, 'status', '--porcelain', '--ignored'])
250 const lines = status.stdout.split('\n').filter(Boolean)
251 const dirty = lines.filter(l => !l.startsWith('!! '))
252 const kept = lines.filter(l => l.startsWith('!! ')).map(l => l.slice(3)).filter(p => !REGEN.test(p))
253 if (dirty.length) return `${label}:worktree 还有 ${dirty.length} 处未提交改动,未删(${worktree})`
254 if (kept.length) return `${label}:worktree 有被忽略的非缓存文件(${kept.slice(0, 3).join(' ')}),未删`
255 const bin = opts.omdLitePath.startsWith('~/') ? await resolvePath($, opts.omdLitePath, cwd) : opts.omdLitePath
256 const out = await $.process.run([bin, 'clean', label], { timeoutMs: 120_000 })
257 return out.exitCode === 0
258 ? `${label}:已合并,omd-lite clean 删了 worktree ${worktree} 与分支 ${branch}`
259 : `${label}:omd-lite clean 失败(exit ${out.exitCode}):${out.stderr.slice(0, 200)}`
260}
261
262// ── D-18:批合并后的组合片闸、独占量测前的全量 ─────────────────────────────────────
263
264/** 这批的账:批前 sha 与已合并的片;组合片闸绿了就删。按仓库目录分开。 */
265type Batch = { base: string; labels: string[]; at: number }
266
267async function git($: $, cwd: string, ...args: string[]): Promise<string> {
268 const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 15_000 })
269 return r.exitCode === 0 ? r.stdout.trim() : ''
270}
271
272/** 量测命令前:当前 HEAD 的 full-gates 汇总必须全绿,否则说明拦下的原因。 */
273async function measureBlock($: $, cwd: string, cfg: RepoConfig): Promise<string | null> {
274 if (!cfg.fullGatesSummary) return null
275 const sha = await git($, cwd, 'rev-parse', '--short', 'HEAD')
276 const file = cfg.fullGatesSummary.replace('{sha}', sha)
277 let summary: string | null = null
278 try {
279 summary = await $.fs.read(file)
280 } catch {
281 summary = null
282 }
283 const v = fullGatesVerdict(summary)
284 if (v.isGreen) return null
285 return summary === null
286 ? `delegate 拦下独占量测:当前 HEAD ${sha} 还没有全量结果(${file})。D-18:独占量测前先在 main 跑一次全量闸,全绿再量。`
287 : `delegate 拦下独占量测:HEAD ${sha} 的全量没有全绿(${v.red.join(' ')} 红,共 ${v.count} 道)。先修,红的那道重跑绿了再量。`
288}
289
290export const register: Register = (on, options) => {
291 opts = {
292 launchSpacingSeconds: num(options.launchSpacingSeconds, 10),
293 diskBudgetGB: num(options.diskBudgetGB, 40),
294 minFreeGB: num(options.minFreeGB, 50),
295 omdLitePath: typeof options.omdLitePath === 'string' && options.omdLitePath.trim() ? options.omdLitePath.trim() : DEFAULT_OMD_LITE,
296 }
297
298 // C-0: `$.delegate.runs()`, answered from the last scan (2 s old at most while a run is live).
299 on('engine.create', async (_, e, next) => {
300 const built = await next(e)
301 return { ...built, delegate: { runs: async () => cache.map(toDelegateRun) } }
302 })
303
304 on('session.start', async ($, e, next) => {
305 const started = await next(e)
306 await $.command.register({ name: 'runs', description: 'omd-lite runs launched from this session, with state (read-only)' })
307 sessionId = await $.session.id()
308 await pruneBooks($)
309 let ticks = 0
310 // Every 2 s while a run is live, every 10 s otherwise.
311 $.clock.every(2000, () => {
312 ticks += 1
313 if (cache.some(r => r.state === 'running') || ticks % 5 === 0) refresh($).catch(() => undefined)
314 })
315 await refresh($)
316 return started
317 })
318
319 on('command.run', { command: 'runs' }, async $ => {
320 const runs = await refresh($)
321 return { text: runsTable(runs, await $.clock.now()) }
322 })
323
324 // The launch guard (INV-7, INV-11, C-1b), the gate rewrite (C-5), the clean-up after a merge.
325 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
326 const cmd = String(e.command ?? '')
327 const { cwd, cfg } = await repoConfig($)
328 const launches = findLaunches(cmd, cfg)
329 const merges = findMerges(cmd, cfg)
330 const batchKey = `batch:${cwd}`
331 const batch = (await $.store.get(batchKey)) as Batch | undefined
332 const measuring = isMeasure(cmd, cfg.measure)
333 const combined = batch !== undefined && runsSliceGate(cmd, cfg.sliceGate ?? '', batch.base)
334 if (!launches.length && !merges.length && !mentionsRuns(cmd) && !measuring && !combined) return next(e)
335
336 if (measuring) {
337 const block = await measureBlock($, cwd, cfg)
338 if (block) return { deny: block }
339 if (!launches.length && !merges.length) return next(e)
340 }
341 if (combined && !launches.length && !merges.length) {
342 const done = await next(e)
343 if (done.deny === undefined && !done.isError) {
344 await $.store.delete(batchKey)
345 return { ...done, context: [...(done.context ?? []), `delegate · 组合片闸已跑(批前 ${batch.base.slice(0, 9)}),这批 ${batch.labels.length} 片清账。以日志为准判红绿。`] }
346 }
347 return done
348 }
349 // 第一片合并前记下批前 sha(同一批后续的合并沿用)。
350 const baseBefore = merges.length && cfg.sliceGate && !batch ? await git($, cwd, 'rev-parse', 'HEAD') : ''
351
352 let call = e
353 if (launches.length) {
354 const problems = await judge($, launches, cwd)
355 if (problems.length) return { deny: `delegate 拒绝启动 omd-lite run:\n- ${problems.join('\n- ')}` }
356 await $.store.set('lastLaunchAt', await $.clock.now())
357 call = { ...e, command: rewriteLaunches(cmd, launches, `${$.plugin.root}/bin/gate-slot.sh`, cfg) }
358 }
359
360 const ran = await next(call)
361 refresh($).catch(() => undefined)
362 if (launches.length) $.clock.after(3000, () => void refresh($).catch(() => undefined))
363
364 const result = ran.result as { backgroundTaskId?: string } | undefined
365 if (!merges.length || ran.deny !== undefined || ran.isError || result?.backgroundTaskId) return ran
366 const notes: string[] = []
367 for (const label of merges) notes.push(await cleanMerged($, label, cwd))
368 $.ui.toast(notes.join(' · '))
369 const context = [...(ran.context ?? []), `delegate 合并后清理:\n${notes.join('\n')}`]
370 if (cfg.sliceGate) {
371 const base = batch?.base ?? baseBefore
372 if (base) {
373 const nextBatch: Batch = { base, labels: [...new Set([...(batch?.labels ?? []), ...merges])], at: await $.clock.now() }
374 await $.store.set(batchKey, nextBatch)
375 const files = (await git($, cwd, 'diff', '--name-only', `${base}..HEAD`)).split('\n').filter(Boolean)
376 context.push(combinedGateNote(base, nextBatch.labels, files, cfg))
377 }
378 }
379 return { ...ran, context }
380 }).catch(($, e, next) => {
381 // A failure judging a launch refuses it; any other command goes on untouched.
382 const isLaunch = /omd-lite\s+run\b|omdl-launch/.test(String(e.command ?? ''))
383 if (next.error.kind === 're-entry') return isLaunch ? { deny: 'delegate: 守卫重入,拒绝启动' } : next(e)
384 return next.called ? next(e) : isLaunch ? { deny: `delegate: 守卫出错(${next.error.kind}),拒绝启动` } : next(e)
385 })
386}
387hooks/logic.ts 407 lines1// delegate's pure parts: reading a Bash command for launches and merges, the brief check (C-1b),
2// path overlap (GWT-8), the gate rewrite (C-5) and the wake text (GWT-15 / INV-8). No `$` here.
3import type { DelegateRun } from '../types/index'
4
5/** What bin/scan.mjs prints per run: the C-0 fields plus what the guard and the wake need. */
6export type ScanRun = DelegateRun & {
7 repo: string
8 worktree: string
9 branch: string
10 spec: string
11 allowed: string[] | null
12 diffstat: string
13 gateTail: string[]
14 failedGate: string
15 error: string
16}
17
18/** The repo's `.claude/delegate.json`; every field optional. */
19export type RepoConfig = {
20 launch?: string
21 launchArgs?: string[]
22 merge?: string
23 gate?: string
24 envFile?: string
25 executor?: string
26 model?: string
27 fullGates?: string
28 // D-18 三级闸门。sliceGate:片闸脚本(批合并后带批前 sha 跑一次 = 组合片闸);
29 // layerGates:改动路径命中正则就要加跑的层闸;fullGatesSummary:全量结果汇总文件,{sha} 换成 `git rev-parse --short HEAD`;
30 // measure:独占量测命令的正则,跑之前要求当前 HEAD 的全量全绿。四项都不写 = 这部分不启用。
31 sliceGate?: string
32 layerGates?: { pattern: string; cmd: string }[]
33 fullGatesSummary?: string
34 measure?: string[]
35}
36
37export const toDelegateRun = (r: ScanRun): DelegateRun => {
38 const out: DelegateRun = { label: r.label, runDir: r.runDir, executor: r.executor, state: r.state, startedAt: r.startedAt }
39 if (r.model !== undefined) out.model = r.model
40 if (r.round !== undefined) out.round = r.round
41 if (r.maxRounds !== undefined) out.maxRounds = r.maxRounds
42 if (r.endedAt !== undefined) out.endedAt = r.endedAt
43 if (r.wallSeconds !== undefined) out.wallSeconds = r.wallSeconds
44 if (r.stuck !== undefined) out.stuck = r.stuck
45 if (r.outside !== undefined) out.outside = r.outside
46 if (r.failureClass !== undefined) out.failureClass = r.failureClass
47 if (r.tool !== undefined) out.tool = r.tool
48 if (r.tokens !== undefined) out.tokens = r.tokens
49 return out
50}
51
52// ── shell words ────────────────────────────────────────────────────────────────────────
53
54export type Token = { kind: 'word' | 'op' | 'redir'; value: string; start: number; end: number; isRedirTarget?: boolean }
55
56const REDIR = /^(\d*)(>>|>&|&>|>|<)(&\d+|&-)?/
57
58/** Splits a command into words (quotes and backslashes resolved), operators and redirections, keeping each span. */
59export function tokenize(cmd: string): Token[] {
60 const out: Token[] = []
61 let i = 0
62 let expectTarget = false
63 while (i < cmd.length) {
64 const c = cmd[i] as string
65 if (c === ' ' || c === '\t') {
66 i++
67 continue
68 }
69 if (c === '#' && (i === 0 || /\s/.test(cmd[i - 1] as string))) {
70 while (i < cmd.length && cmd[i] !== '\n') i++
71 continue
72 }
73 const two = cmd.slice(i, i + 2)
74 const redir = REDIR.exec(cmd.slice(i))
75 if (redir) {
76 out.push({ kind: 'redir', value: redir[0], start: i, end: i + redir[0].length })
77 expectTarget = !redir[3]
78 i += redir[0].length
79 continue
80 }
81 if (two === '&&' || two === '||' || two === ';;') {
82 out.push({ kind: 'op', value: two, start: i, end: i + 2 })
83 i += 2
84 continue
85 }
86 if (';|&\n()'.includes(c)) {
87 out.push({ kind: 'op', value: c, start: i, end: i + 1 })
88 i++
89 continue
90 }
91 const start = i
92 let value = ''
93 while (i < cmd.length) {
94 const ch = cmd[i] as string
95 if (' \t\n;|&()<>'.includes(ch)) break
96 if (ch === "'") {
97 const close = cmd.indexOf("'", i + 1)
98 const stop = close < 0 ? cmd.length : close
99 value += cmd.slice(i + 1, stop)
100 i = stop + 1
101 } else if (ch === '"') {
102 i++
103 while (i < cmd.length && cmd[i] !== '"') {
104 if (cmd[i] === '\\' && '"\\$`\n'.includes(cmd[i + 1] ?? '')) i++
105 value += cmd[i] ?? ''
106 i++
107 }
108 i++
109 } else if (ch === '\\') {
110 value += cmd[i + 1] ?? ''
111 i += 2
112 } else {
113 value += ch
114 i++
115 }
116 }
117 out.push({ kind: 'word', value, start, end: Math.min(i, cmd.length), ...(expectTarget ? { isRedirTarget: true } : {}) })
118 expectTarget = false
119 }
120 return out
121}
122
123/** Quotes a string for bash, as plainly as it allows. */
124export function shellQuote(s: string): string {
125 if (/^[\w@%+=:,./-]+$/.test(s)) return s
126 if (!s.includes("'")) return `'${s}'`
127 if (!/["\\$`!]/.test(s)) return `"${s}"`
128 return `'${s.replace(/'/g, `'\\''`)}'`
129}
130
131/** The simple commands of a line: its words between operators, redirections and their targets left out. */
132function simpleCommands(tokens: Token[]): Token[][] {
133 const out: Token[][] = [[]]
134 for (const t of tokens) {
135 if (t.kind === 'op') out.push([])
136 else if (t.kind === 'word' && !t.isRedirTarget) out.at(-1)?.push(t)
137 }
138 return out.filter(c => c.length)
139}
140
141const basename = (p: string) => p.replace(/\/+$/, '').split('/').pop() ?? p
142
143// ── launches ───────────────────────────────────────────────────────────────────────────
144
145export type Launch = {
146 kind: 'omd-lite' | 'script'
147 /** The word that names omd-lite or the launch script. */
148 head: Token
149 /** `run` (omd-lite) or the head again (script): where flags are inserted after. */
150 anchor: Token
151 name?: string
152 spec?: string
153 repo?: string
154 base?: string
155 executor?: string
156 model?: string
157 hasEnvFile: boolean
158 gates: Token[]
159}
160
161const DEFAULT_LAUNCH_ARGS = ['name', 'base', 'spec', 'gate...']
162
163/** Every `omd-lite run` and every call of the repo's launch script in a command. */
164export function findLaunches(cmd: string, cfg: RepoConfig = {}): Launch[] {
165 const launches: Launch[] = []
166 const script = cfg.launch ? basename(cfg.launch) : ''
167 const hasEnvFile = /\bOMD_LITE_ENV_FILE=/.test(cmd)
168 for (const words of simpleCommands(tokenize(cmd))) {
169 for (let i = 0; i < words.length; i++) {
170 const w = words[i] as Token
171 const base = basename(w.value)
172 if (base === 'omd-lite' && words[i + 1]?.value === 'run') {
173 const l: Launch = { kind: 'omd-lite', head: w, anchor: words[i + 1] as Token, hasEnvFile, gates: [] }
174 for (let j = i + 2; j < words.length; j++) {
175 const a = (words[j] as Token).value
176 if (!a.startsWith('--')) continue
177 if (a === '--no-context') continue
178 const v = words[j + 1]
179 if (!v) break
180 j++
181 if (a === '--gate') l.gates.push(v)
182 else if (a === '--spec') l.spec = v.value
183 else if (a === '--name') l.name = v.value
184 else if (a === '--repo') l.repo = v.value
185 else if (a === '--base') l.base = v.value
186 else if (a === '--executor') l.executor = v.value
187 else if (a === '--model') l.model = v.value
188 }
189 launches.push(l)
190 break
191 }
192 if (script && base === script) {
193 const l: Launch = { kind: 'script', head: w, anchor: w, hasEnvFile, gates: [] }
194 const order = cfg.launchArgs ?? DEFAULT_LAUNCH_ARGS
195 const args = words.slice(i + 1)
196 order.forEach((slot, k) => {
197 if (slot === 'gate...') l.gates.push(...args.slice(k))
198 else if (args[k]) (l as Record<string, unknown>)[slot] = (args[k] as Token).value
199 })
200 launches.push(l)
201 break
202 }
203 }
204 }
205 return launches
206}
207
208/** True when a command mentions omd-lite or an omdl-* wrapper script: worth a rescan. */
209export const mentionsRuns = (cmd: string) => /omd-lite|omdl-/.test(cmd)
210
211/**
212 * The command with each launch's gates run through gate-slot.sh (C-5), and the repo's
213 * defaults (executor, model, gate, env file) filled in where an omd-lite run leaves them out.
214 */
215export function rewriteLaunches(cmd: string, launches: Launch[], slotPath: string, cfg: RepoConfig = {}): string {
216 const edits: { start: number; end: number; text: string }[] = []
217 const wrap = (gate: string) => `${slotPath} ${shellQuote(gate)}`
218 for (const l of launches) {
219 for (const g of l.gates) {
220 if (g.value.includes('gate-slot.sh')) continue
221 edits.push({ start: g.start, end: g.end, text: shellQuote(wrap(g.value)) })
222 }
223 if (l.kind !== 'omd-lite') continue
224 const extra: string[] = []
225 if (!l.executor && cfg.executor) extra.push(`--executor ${shellQuote(cfg.executor)}`)
226 if (!l.model && cfg.model) extra.push(`--model ${shellQuote(cfg.model)}`)
227 if (!l.gates.length && cfg.gate) extra.push(`--gate ${shellQuote(wrap(cfg.gate))}`)
228 if (extra.length) edits.push({ start: l.anchor.end, end: l.anchor.end, text: ` ${extra.join(' ')}` })
229 if (cfg.envFile && !l.hasEnvFile) edits.push({ start: l.head.start, end: l.head.start, text: `env OMD_LITE_ENV_FILE=${shellQuote(cfg.envFile)} ` })
230 }
231 let out = cmd
232 for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end)
233 return out
234}
235
236// ── merges ─────────────────────────────────────────────────────────────────────────────
237
238/** The run labels a command merges: the repo's merge script's first argument, or `git merge … omdl/<label>`. */
239export function findMerges(cmd: string, cfg: RepoConfig = {}): string[] {
240 const labels: string[] = []
241 const script = cfg.merge ? basename(cfg.merge) : ''
242 for (const words of simpleCommands(tokenize(cmd))) {
243 const values = words.map(w => w.value)
244 const at = values.findIndex(v => script !== '' && basename(v) === script)
245 if (at >= 0 && values[at + 1]) {
246 labels.push(values[at + 1] as string)
247 continue
248 }
249 const git = values.indexOf('git')
250 const merge = values.indexOf('merge', git)
251 if (git >= 0 && merge > git) for (const v of values.slice(merge + 1)) if (v.startsWith('omdl/')) labels.push(v.slice(5))
252 }
253 return [...new Set(labels)]
254}
255
256// ── the brief (C-1b) ───────────────────────────────────────────────────────────────────
257
258export type BriefPath = { path: string; isNew: boolean }
259
260/** The 允许改动 line → paths, each marked when the brief says it is new (`(新建)`). */
261export function allowedPaths(text: string): BriefPath[] | null {
262 const line = text
263 .split('\n')
264 .map(l => l.replace(/\*\*/g, '').trim())
265 .find(l => /^允许改动\s*[::]/.test(l))
266 if (line === undefined) return null
267 const out: BriefPath[] = []
268 for (const raw of line.replace(/^允许改动\s*[::]/, '').split(/[,,、\s]+/)) {
269 const s = raw.replace(/[`'"]/g, '').trim()
270 if (!s) continue
271 if (/^[((]?新建[))]?$/.test(s)) {
272 const last = out.at(-1)
273 if (last) last.isNew = true
274 continue
275 }
276 const isNew = /[((]新建[))]$/.test(s)
277 out.push({ path: s.replace(/[((]新建[))]$/, ''), isNew })
278 }
279 return out
280}
281
282export const isTestPath = (p: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p) || /(^|\/)test_[^/]*\.py$/.test(p)
283
284/** What a brief lacks of C-1b's three lines; paths not yet checked for existence. */
285export function briefGaps(text: string): string[] {
286 const gaps: string[] = []
287 const lines = text.split('\n').map(l => l.replace(/\*\*/g, '').trim())
288 if (!lines.some(l => /^原始目标原文/.test(l))) gaps.push('缺「原始目标原文」行(逐字引用户原话)')
289 const allowed = allowedPaths(text)
290 if (allowed === null) gaps.push('缺「允许改动:」行')
291 else if (!allowed.length) gaps.push('「允许改动:」行没有路径')
292 if (!lines.some(l => /^#{1,6}\s*成功判据/.test(l))) gaps.push('缺「## 成功判据」节')
293 return gaps
294}
295
296// ── overlap (GWT-8) ────────────────────────────────────────────────────────────────────
297
298const norm = (p: string) => p.replace(/^\.\//, '').replace(/\/+$/, '')
299
300/** Paths two allow-lists share: equal, or one a directory holding the other. */
301export function overlap(a: readonly string[], b: readonly string[]): string[] {
302 const out: string[] = []
303 for (const x of a.map(norm)) {
304 for (const y of b.map(norm)) {
305 if (x === y || x.startsWith(`${y}/`)) out.push(x)
306 else if (y.startsWith(`${x}/`)) out.push(y)
307 }
308 }
309 return [...new Set(out)]
310}
311
312// ── the wake (GWT-15, INV-8) ───────────────────────────────────────────────────────────
313
314export const PROVIDER_ADVICE = 'provider 失败,换供应商或等窗口,不要回灌给执行方(换 provider 重跑同一任务书即可)'
315
316/** One message listing every run that ended in the window. */
317export function wakeText(runs: readonly ScanRun[]): string {
318 const lines = [`delegate:${runs.length} 个 omd-lite run 结束。`]
319 for (const r of runs) {
320 const head = [r.label, r.state, r.round !== undefined ? `第 ${r.round}/${r.maxRounds ?? '?'} 轮` : '', r.failureClass ?? '']
321 lines.push('', `- ${head.filter(Boolean).join(' · ')}`)
322 if (r.diffstat) lines.push(` diff:${r.diffstat}`)
323 if (r.outside?.length) lines.push(` 越界:${r.outside.join(' ')}`)
324 if (r.failureClass === 'provider') {
325 lines.push(` ${PROVIDER_ADVICE}`)
326 if (r.error) lines.push(` 报错:${r.error.replace(/\s+/g, ' ').slice(0, 160)}`)
327 continue
328 }
329 if (r.stuck) lines.push(` stuck:${r.stuck}`)
330 if (r.state !== 'pass' && r.gateTail.length) {
331 lines.push(` 闸日志末 ${r.gateTail.length} 行${r.failedGate ? `(${r.failedGate})` : ''}:`, ' ```', ...r.gateTail.map(l => ` ${l}`), ' ```')
332 }
333 lines.push(` 产物:${r.runDir}`)
334 }
335 lines.push('', '读 diff 对任务书,再决定合并 / 重派。')
336 return lines.join('\n')
337}
338
339/** The /runs table. */
340export function runsTable(runs: readonly ScanRun[], now: number): string {
341 if (!runs.length) return '本会话没有 omd-lite run。'
342 const rows = [['label', 'state', 'round', 'class', 'min', 'diff']]
343 for (const r of runs) {
344 const mins = ((r.endedAt ?? now) - r.startedAt) / 60000
345 rows.push([r.label, r.state, r.round !== undefined ? `${r.round}/${r.maxRounds ?? '?'}` : '-', r.failureClass ?? r.stuck ?? '-', mins.toFixed(0), r.diffstat.replace(/ changed|\(\+\)|\(-\)/g, '') || '-'])
346 }
347 const widths = (rows[0] as string[]).map((_, k) => Math.max(...rows.map(row => (row[k] ?? '').length)))
348 return rows.map(row => row.map((c, k) => c.padEnd(widths[k] ?? 0)).join(' ').trimEnd()).join('\n')
349}
350
351// ── D-18 三级闸门:合并后的组合片闸提醒、独占量测前查全量 ─────────────────────────────
352
353// 改动文件命中的层闸命令(去重,按配置顺序)。
354export function layerGatesFor(files: readonly string[], layers: RepoConfig['layerGates'] = []): string[] {
355 const out: string[] = []
356 for (const l of layers) {
357 let re: RegExp
358 try {
359 re = new RegExp(l.pattern)
360 } catch {
361 continue
362 }
363 if (files.some(f => re.test(f)) && !out.includes(l.cmd)) out.push(l.cmd)
364 }
365 return out
366}
367
368// 这条命令跑的是片闸,且基线参数是 base(sha 前缀互相包含即算)。
369export function runsSliceGate(cmd: string, script: string, base: string): boolean {
370 if (!script || !base) return false
371 const name = basename(script)
372 for (const words of simpleCommands(tokenize(cmd))) {
373 const values = words.map(w => w.value)
374 const at = values.findIndex(v => basename(v) === name)
375 const arg = at >= 0 ? values[at + 1] : undefined
376 if (arg && arg.length >= 7 && (base.startsWith(arg) || arg.startsWith(base))) return true
377 }
378 return false
379}
380
381export function isMeasure(cmd: string, patterns: readonly string[] = []): boolean {
382 return patterns.some(p => {
383 try {
384 return new RegExp(p).test(cmd)
385 } catch {
386 return false
387 }
388 })
389}
390
391// full-gates.sh 的 summary.txt:每行 `NN-name rc=N Ns`。至少一行且全部 rc=0 才算绿;红的列出来。
392export function fullGatesVerdict(summary: string | null): { isGreen: boolean; red: string[]; count: number } {
393 const rows = (summary ?? '').split('\n').map(l => l.trim()).filter(l => /\brc=\d+/.test(l))
394 const red = rows.filter(l => !/\brc=0\b/.test(l)).map(l => l.split(/\s+/)[0] ?? l)
395 return { isGreen: rows.length > 0 && red.length === 0, red, count: rows.length }
396}
397
398export function combinedGateNote(base: string, labels: readonly string[], files: readonly string[], cfg: RepoConfig): string {
399 const layers = layerGatesFor(files, cfg.layerGates)
400 return [
401 `delegate · 这批已合并 ${labels.length} 片(${labels.join(' ')}),批前 ${base.slice(0, 9)},共改 ${files.length} 个文件。D-18:收这批前在 main 上跑组合片闸${layers.length ? '和这批碰到的层闸' : ''},全量闸留到独占量测前 / GOAL 收尾 / 发版:`,
402 `- ${cfg.sliceGate} ${base.slice(0, 9)}`,
403 ...layers.map(c => `- ${c}`),
404 '用日志判红绿;红了先修,不往上叠新片。组合片闸绿了这批就清账。',
405 ].join('\n')
406}
407types/index.d.ts 13 lines1// delegate's contract (C-0, docs/plan/2026-10-10-lead-harness.md): the `$.delegate` noun.
2// Consumers (savvy-progress, relay) list delegate under `dependencies`; never copy this file.
3export type DelegateExecutor = 'minimax' | 'mcode' | 'pi' | 'claude' | 'other'
4export type DelegateState = 'running' | 'pass' | 'fail' | 'not-run' | 'interrupted'
5export type DelegateRun = {
6 label: string; runDir: string; executor: DelegateExecutor; model?: string
7 state: DelegateState; round?: number; maxRounds?: number
8 startedAt: number; endedAt?: number; wallSeconds?: number
9 stuck?: string; outside?: string[]; failureClass?: string; tool?: string; tokens?: number
10}
11export type Delegate = { runs: () => Promise<DelegateRun[]> }
12declare module 'claude-code' { interface EngineInterface { delegate: Delegate } }
13