SLOPSHOPPER

delegate

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

newguardcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /runs ⎿ delegate: 本会话没有 omd-lite run。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

delegate

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 调用后立即再扫。
  • 叫醒(GWT-15):run 结束 → 60 s 窗口内合成一条 $.prompt.submit(没有 run 还在跑就立即发):label · state · 轮数 · failureClass · diffstat · 越界 · 闸日志末 20 行; provider 失败不附闸日志,只说「换供应商或等窗口,不要回灌给执行方」(INV-8)。
  • launch 守卫(Bash 的 omd-lite run … 或仓配置的 launch 脚本):任务书缺「原始目标原文」/「允许改动:」/「## 成功判据」、允许改动里的路径不存在又没标(新建)、 与在跑 run 的允许改动重叠、距上次 launch 不足 launchSpacingSeconds、omdl-* 超 diskBudgetGB 或盘余 < minFreeGB → deny 并逐条说明。不设并发上限(D-16)。
  • 闸槽(C-5):放行时把每个 --gate <cmd> 改写成 --gate "<mod>/bin/gate-slot.sh '<cmd>'";槽数 = max(1, ⌊(MemAvailable − 8 GB) ÷ 2 GB⌋),flock 排队。
  • 合并后清理(D-17 ②):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 缺对应参数时补上。

配置(plugin userConfig)

选项默认说明
launchSpacingSeconds10两次 launch 之间最少间隔;0 = 不限
diskBudgetGB40仓库旁 omdl-* worktree 总量上限
minFreeGB50仓库所在盘最少余量
omdLitePathomd-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-harness
Source 3 files
hooks/register.ts 387 lines
1// 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}
387
hooks/logic.ts 407 lines
1// 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}
407
types/index.d.ts 13 lines
1// 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