issue-sweep-batch — 把 epic 的形成做成一步
Repo profile — 先读
.claude/repo-profile.md。 本 skill 与仓库无关; arc 是参考实现。repo_slug、label 集合、树的划分都从 profile 取,别硬编码。
它在哪一层
issue-sweep 逐条处理「人回复了的」issue per-issue triage
issue-sweep-batch 把一堆无人认领的 issue 聚成可派 epic ← 本 skill
epic-conductor 把一个已定义的 epic 推到合并 per-epic execution
/agentloop:issue-sweep 的候选集主动排除 epic-managed / epic:<n>——它明确不管
已归 epic 的东西。/agentloop:epic-conductor 的 Step 1 是 Decompose——它假设 epic 已存在。
中间这层此前没有 skill 负责。
只产出 epic,不碰任何既有机制
- 不派工、不合并、不修改既有 epic 的成员、不动任何 issue 的状态标签
- 唯一的写操作是:建新 epic issue + 给候选挂
epic:<新号>+ 写 ledger - 因此它是纯增量的:不跑它,工厂的行为和今天完全一样
它其实是全局分类器,epic 只是一种输出
真正在做的事是:给存量里每一条工作项确定它属于哪一类、在那类的哪一簇, 并知道这个结论什么时候失效。 epic 是「一簇 bug 且路径面不相交」时的产物,不是全部。
分类轴按类型不同(不可混用)
| 类型 | 轴 | 聚簇判据 |
|---|---|---|
bug |
defectLayer |
这几条能不能被同一个修复方向覆盖? |
feature / idea |
capabilityArea |
这几条会不会被同一次设计决定一起决定掉? |
research |
openQuestion |
这几条会不会被同一次调查一起回答? |
symptom |
openQuestion |
这几条会不会被同一次诊断一起回答? |
untyped |
无轴 | 必须先定类型,不能硬分 |
symptom —— 未诊断的观察既不是缺陷也不是报告
走查 / 夜测报出的单条失败(test-sweep-failure / nightly-test-failure)说的是
「出现了预期外的东西」,而不是「这里有一个缺陷」。它有自己的类型,因为两个现成的桶
都会制造同色:
- 塞进
bug:bug 的轴是defectLayer(能否被同一个修复方向覆盖),而未诊断的失败 方向未知,赋层就是编。并且「已确认缺陷」与「待定项」在计数上同色。 - 塞进
report:report 无轴、无跟进通道,于是**「诊断完发现不是缺陷」与「根本没人看」 同色**(oversight-discipline 的静默≠健康)。
symptom 与 research 同轴不同 disposition:research 的产物是知识,可以长期开着;
symptom 的产物是一个判决,必须终结成闭合词表里的一个值(classify.ts 的
SYMPTOM_VERDICTS):
| 判决 | 处置 |
|---|---|
bug |
确认缺陷 —— 改类型,此时才赋 defectLayer 并参与 epic 聚簇(唯一继续 open 的) |
test-defect |
走查机具 / fixture 自己错了 —— 关闭 |
env |
环境或部署态,不是产品缺陷 —— 关闭 |
stale |
已被别的改动修掉,复现不了 —— 关闭 |
normal |
是正常态(对应 cost-gate 的 normal-state 一问)—— 关闭 |
闭合词表不是手续:undiagnosed-symptom detector 把「仍然 open 且超期」读成
「还没有人判决」,这个推断只有在其余判决一律关闭时才成立。这条前提钉在
classify.test.ts 的 VERDICT_KEEPS_OPEN 那条测试上。
优先序:report > bug > symptom。挂上 bug 就是判决已做出,它不再是待诊断观察;
挂上 report label 说明它是一次运行的汇总,不是单条症状。
⚠ 两种形状不要混在一个 label 下:「检测到 N 处失败(timestamp)」是运行汇总(report), 里面的 N 处应各自 spin off 成 symptom。混在一起时「1 条 open」与「3 个未诊断的失败」同色 ——计数本身就是错的。
把 bug 的判据套到 feature 上,会把「同一个产品面的两个不同主张」当成一簇。
untyped 单列不是洁癖:arc 实测近 14 天新建的 770 条里 268 条(34%)没有任何类型标签,
默默当 bug 处理会污染缺陷层的聚簇。
三种模式
--types bug,untyped # work-object 默认(导入无 keywords → untyped);`--types bug` 仍只扫 bug
--mode new # 只处理**从未分类**的 —— 反复归类没动的东西是纯浪费
--mode revalidate # 只重验**已分类**的 —— 世界变了之后旧结论还成立吗
--mode all # 两者(默认)
--mode revalidate 的用处:一批人的建议进来了、或一批改动合了,
需要看的是过去的分类是否还成立,而不是重新扫一遍全量。
失效有三个来源,不只是「自己变了」
| 来源 | 信号 | GitHub 能给吗 |
|---|---|---|
| 自身变了 | fingerprint(正文 + label) | ✅ |
| 邻域变了 | 邻居关闭 / 被解锁 | ❌ 需 issue-graph 的 graph-scan 补算 |
| 从未分类 | 记录里没有 layer |
✅ |
第二条是 label 给不出的:一条 issue 可以一个字没改,而它依赖的那条已经合了——
过去的分类可能已经不成立。这正是 graph(将来是 work object 的关系边)
相对 label 的不可比优势。源若 capabilities.neighborhood === false,
脚本会明确警告这一类失效会整类漏掉,不装作看得见。
增量实测(accept-path,不是设计意图)
全部已分类 · 无变化 → 选中 0 / 跳过 42 (三种模式都是)
改一条的指纹 → 选中 1 / 跳过 41 (且正是改动的那条)
工厂健康 —— 让人 5 秒钟知道需不需要管
页面顶部与 CLI 首行都是一个状态 + 至多三条解释:
🔴 ACTION REQUIRED 需要人介入
🟡 DEGRADED 有信号但不需要人介入
🟢 HEALTHY 工厂产出正常,不需要人介入
分两层,第一层不是 LLM
- 硬 detector(
health.ts):确定性、便宜、可测。每条给出证据,不给结论。 - 健康判读(
assess):把信号合成一个状态 + ≤3 条解释。
agent 读 --json,不读截图。 渲染 → 视觉理解 → 推理会再加一层不必要的噪声,
而我们已经在验证信号上吃过噪声的亏。
每个 detector 必须有 accept 臂
一个从不触发的 detector 与一个健康的工厂完全同色。
所以 detectors() 在健康基线上必须返回空数组,assess() 必须能说
healthy 且 humanAttention=false——只会说黄/红的系统等于没有系统,
它会退化成另一个骚扰人的 micro-manager。这两条在测试里钉死了。
单信号不足以判定
backlog-expansion 要求进出比高且存量在涨同时成立。只看比值会在
「正在恢复」时误报——arc 实况正是比值仍高但净值开始转负。
斜率必须算自无偏输入
不要把 stockSeries 直接喂进 detector:它由「当前 open + 窗口内已关闭」推出,
更早关闭的项不在窗口内,曲线左端系统性偏低。用 netToCumulative(每日净值)——
开与关同源同窗口,作差后偏差抵消。
当前门槛(集中在 health.ts 的 T)
| detector | 条件 |
|---|---|
backlog-expansion |
7d 进出比 > 1.25 且 存量斜率 > 1/天 |
classification-debt |
untyped ≥ 15%(warn)/ ≥ 25%(bad) |
stale-work |
>7d 的占 open ≥ 40% |
undiagnosed-symptom |
超过 TTL(7d)仍无判决的 symptom ≥ 2 条(warn)/ 且占 open symptom ≥ 50%(bad) |
undiagnosed-symptom 的 TTL 标定自实测:arc 上已判决关闭的 test-sweep-failure
全部在 0–5 天内关闭(抽样 20 条,最长 5 天,中位约 2 天),7 天落在观测分布之外。
它测的是相对这个工厂自己的基线的偏离,不是一个从外面拍下来的数字
(oversight-discipline 的「基线偏离优于固定阈值」)。它也不是 stale-work 的重复:
后者看笼统的年龄,一条 20 天的 feature 和一条 8 天未判决的 symptom 在它眼里一样。
正控:健康基线 fixture 里必须含一条 TTL 内的 open symptom,否则这个 detector 的 accept 臂是空的——「仪器看过了、没事」与「压根没东西可看」同色。
可视化:--html
四个视图。概览是首页——整个仓库的 work object 一目了然:
| 视图 | 回答 |
|---|---|
| 概览 | 按类型的存量分布 + 流量图(开 vs 关)+ 存量线,粒度可切小时/天/周/30天 |
| 全局 | 按类型筛(点概览的类型卡,或顶部类型 chip),每种类型展示它自己的分类轴与聚簇判据 |
bun .../sweep-batch.ts --dry-run --html sweep.html && open sweep.html
产出一份自包含的 HTML —— 无 CDN、无构建、断网可用、从 file:// 双击就开。
是 HTML 不是 SVG,因为要能操作:切视图、点开详情、顺着关系走;只能看的图看不出判断。
三个视图对应三种真实问题:
| 视图 | 回答 |
|---|---|
| 按 epic | 在飞 epic 各占哪些文件?谁和谁撞?——逐条点名撞的文件 |
| 单条追溯 | 这一条和谁同车道、属于哪个 epic、和谁共享文件?——从一个点顺关系走 |
顶部常驻能力自述(pushdown / incremental / writableClassification / neighborhood),
以及源不支持邻域时的警告——页面不装作看得见它看不见的东西。
类型筛选带全部类型,不只是本轮扫的那批
页面模型带所有开着的工作项,不只是 --types 选中的——否则点「feature」卡片
会得到一张空页(实测的 UI bug:原来的处理器只切视图不过滤,六个类型给出同一张页)。
未进本轮分类流程的项仍然展示,但标淡,并在顶部写明:
本轮
--types bug没有扫 feature,下面只是存量展示:路径面已算, 但没有进本轮分类流程。要分类它:--types feature重跑。
别让透明度独自承担这个语义——只调淡而不写明,看起来像页面坏了。
年龄柱是可点的(按年龄挑一批出来处理)
点 >14d 就把下面的列表筛成那一批;在全局页里柱子跟随当前类型重算,所以
「选 bug → 看 bug 自己的年龄分布 → 点 >14d」直接得到最该动的那 10 条。再点一次取消。
档位是 <1d · 1-3d · 3-7d · 7-14d · 14-30d · 30-90d · >90d,颜色沿档位单调变热
(--a0…--a6,最老的是红)。为什么切到 30 / 90:>14d 曾经是一个桶,而它一个人
就占 arc open 的 40%——一个装了 40% 的桶不区分「两周」和「半年」,等于没有分档。
空档不给柱子、不可点、压到最淡,但数字 0 仍然写出来——「数出来是 0」与 「这里没有这一档」必须不同色(度量正控)。一根 4px 的残柱会让人以为那里还有东西。
三条纪律:
- 桶的归属只算一次。
health.ts的ageBucketOf算好写进每条 item,前端只做 字符串相等。柱子的计数与过滤器各写一遍边界的话,两边会悄悄漂移—— 「柱子说 101」与「点开给出 97」在页面上完全同色,而没有人会去数。 - 柱子的数只能从列表里数,不能读服务端的
aging:后者统计全部 open(含 18 条 epic 自身),而列表里没有 epic。 - 档位清单只有一份:
health.ts的AGE_SCALE(key + 下界),页面顺序、桶归属、 陈货门槛全部从它派生。加一档时最容易漏的是陈货判据——detector 里手写ages["7-14d"] + ages[">14d"]的话,新加的30-90d/>90d会悄悄不算进陈货, 于是「陈货变少了」与「新档没接线」完全同色。所以STALE_BUCKETS也从表里算。
⚠ 全局页是车道图:一条 issue 碰几个路径面就画几张卡,所以卡片数 > 条数。
页面上有一行对账(101 条 · 铺成 172 张卡片,分布在 N 个路径面)——实测跑出来的问题,
不写出来的话点进去只会以为筛选器坏了。
accept-path 检查:bun scripts/ui-verify.mjs(默认跑五份手写 Model:
full / empty / single-bin / all-zero / uncollected)。断言打在 renderHtml() 产物上,
不编码「这个仓库这一轮长什么样」。可选 bun scripts/ui-verify.mjs <生成的 html>
对活数据只做结构性检查(能渲染、能点开、不崩),不含任何数字/库存断言。
它执行页面自己产出的那段脚本,逐桶断言「柱子上的数 == 点开后的条数」。
单测看不出这条——三处任何一处漂移,页面照样渲染得好好的,只是数对不上。
判别力实测:删掉筛选那一行 → 全红;让柱子不跟随类型 → 只红 type×age 那一臂。
颜色只表示一件事
| 通道 | 表示 | 取值 |
|---|---|---|
| hue | 类型 | bug 红 · symptom 橙 · feature 蓝 · research 紫 · idea 绿 · report 青 · untyped 灰 |
热度 --a0..--a6 |
年龄严重度 | 新鲜 → 陈旧单调变热,最老是红 |
两套互不冒充:年龄柱一律用严重度色(不用类型色),类型卡 / chip / 堆叠图一律用类型色。
净值数字走第三条通道(柱顶,不在柱体上):净增红 · 净减绿 · 平为墨色(--fg)。
柱体看类型,柱顶看涨跌,位置不同所以不会混。
概览页的两张图回答两个不同问题
两张图都按类型堆叠:一根「全部」的柱子回答不了真正的问题——进的是什么、出的是什么。
实测 arc 的存量主体是 feature(90)而不是 bug(45),聚合柱把这件事完全藏住。
堆叠高度恒等于总量:每个出现过的类型都自成一层,含未知类型(stats.ts 保证,
stats.test.ts 逐桶断言「各层之和 == 总量」)。少一层就是「堆起来比总数矮一截」,
而没有人会去加。
- 流量(开 vs 关,按桶) —— 进货和出货哪个快?这是「修了这么多为什么总数不降」的 直接答案。画成上下对称的棒槌:往 +y 开、往 −y 关,一眼看出哪头更长。 实测某 14 天窗口:开 770 / 关 612,净 +158——关闭吞吐并不低,是进货更快。
- 存量(每桶末还开着的数) —— 常说的 burn-down 那条线。它是流量的积分, 好看但滞后:净值转负好几天后这条线才明显下弯。先看流量再看存量。 与流量图共用同一把 X 尺(每个点落在对应棒槌的中心)和同一份类型 legend; 点 legend 只筛这两张图(有「全部」),不跳到全局页。两张图是一个整体。
画这两张图必须拿到已关闭项
list({state:"open"}) 没有 closedAt。所以源要实现 timeline(sinceDays):
- GitHub:额外一次昂贵拉取(open 全量 + closed 按
closed:>=<date>收窄) - work object:一次带时间范围的查询 —— 又一处具体优势
页面底部常驻窗口说明(「已关闭项只取最近 N 天,更早的关闭不在窗口内,
存量线左端会因此偏低」)——不装作那条线是完整历史。
源不提供 timeline 时概览显示「未采集」,而不是画一张空图。
数据契约是 html.ts 的 Model。将来做成 web component 集成进 factory / work object 时
契约不变,只换渲染宿主。
契约:一个 epic 可派,当且仅当五条同时成立
- 单一主题 —— 每个成员是同一个缺陷形状,不是同一个症状
- 路径面与所有在飞 epic 不相交 —— 文件级,且三态判定为
disjoint - 纯 bug —— 无 feature 混入
- 无成员卡在人身上
- 逐成员写明验收 —— mutation pair:弄坏必须红,恢复必须绿
任何一条不成立就不是 epic,是一袋 issue。宁可少形成一个 epic,也不要形成一个假 epic—— 假 epic 的代价是两个 agent 撞在同一个文件上,比不派更贵。
机械 / 判断的分工(不可混淆)
scripts/sweep-batch.ts 只做可判定的部分,其余显式交回给你:
| 机械(脚本做) | 判断(你做) |
|---|---|
| 存量拉取、候选过滤 | 给每条候选赋 layer |
| ledger 增量 | 读代码定位 unproven 的落点 |
| 路径面抽取 | 按 layer 聚簇 |
| 三态不相交判定 | 写 epic 正文、成员取舍 |
| 在飞 PR 排除 |
脚本刻意不猜 layer。 用关键词猜会重演这个真实错误:#5487「共享 worker 槽位」与 #4749「独占 heavy lease」症状同为并发争用,修复方向相反,捆一起产出的是「既共享又独占」。
同层判据一句话:两条能不能被同一个修复方向覆盖? 不能就不是同一层。
三态不相交(本 skill 的核心)
| 态 | 含义 | 动作 |
|---|---|---|
disjoint |
两边文件集都已知,交集为空 | ✅ 可并行 |
overlap |
已知且交集非空 | 串行化,或重切;脚本会点名撞哪个文件 |
unproven |
一边或两边正文里没有任何文件路径 | ❌ 不是 disjoint。必须读代码定位后再判 |
为什么必须是三态:这是 accept-path 铁律作用在测量本身上——
「没测到冲突」与「测过了没冲突」完全同色。 arc 实测:一轮里 #5554 / #5417 / #5617
的正文都抽出 0 个路径;把 unproven 当 disjoint,它们会被当作安全并行派出,
而 #5554 要扩的能力声明面正是在飞 epic 的另一条成员在动的面。
抽取器认哪些根目录 —— source_roots(#5723)
路径面从 issue 正文里抽,靠的是一份根目录白名单。这份清单曾经写死为 arc 的布局
(providers/ runtimes/ blocklets/ …),于是别的仓库整类抽不到落点:
unproven的语义应当是「正文里没有路径」。 白名单没覆盖时它实际表示 「正文里有路径,但我不认识这些根目录」——两件事被折叠成同一个值, 量具自己制造了它被设计来消灭的那种同色。
实测 ArcBlock/blockchain(根目录是 core/ did/ statedb/ …):100 条 unproven 里
44 条是量具产物;那一轮因此得出「形不成任何 epic」,而那是假的。
一个坏掉的量具让工厂静默停摆,且停摆看起来像「没有工作可派」。
所以清单住消费仓库的 .claude/repo-profile.md:
| `source_roots` | `core did statedb indexdb ledger rollup apps examples` |
缺键回退到 arc 的缺省列表(零行为变化)。缺键、且存量里出现「unproven 但正文有
路径样 token」时,机械层会直接报警并提示去 profile 里声明——不让「没配」和
「配了但真的没路径」同色(lib.ts 的 looksLikeMissingRoots)。
⚠ 别往缺省列表里加 .github:lib.test.ts 的 MIXED fixture 正是靠它落在白名单外
来验证 partial 臂,收进来会让那条 accept 臂恒真。
步骤
Step 0 — 同步 + 读 profile
沿用 /agentloop:issue-sweep 的 Step 0。
Step 1 — 跑机械层
bun <plugin_root>/skills/issue-sweep-batch/scripts/sweep-batch.ts --dry-run \
[--types bug|feature|idea|research|untyped] [--mode new|revalidate|all]
读它的输出:候选集、排除理由分布、已测路径面按车道分组、unproven 清单、
以及每个在飞 epic 的 disjoint / overlap / unproven 计数与撞点。
Step 1.5 — 邻域信号(GitHub 源必需)
GitHub 源自述 neighborhood=false。跑一次 issue-graph 补上,否则
「邻居合了导致旧分类不成立」这一类失效整类看不见:
bun <plugin_root>/skills/issue-graph/scripts/graph-scan.ts --window-hours 24
把它的 kicks / blocked 喂给重验判定。work object 源不需要这一步——
关系是边,邻域变化是一次图查询。
Step 2 — 处理 unproven(不可省)
对每条 unproven 的候选,读代码定位落点:grep 它描述的机制、找到会被改的文件。
定位不出来就不要纳入本轮 epic——落点未知的成员会让整个 epic 的不相交声明失效。
Step 3 — 赋 layer,按 layer 聚簇
一个簇 = 一个 epic 候选。簇内成员必须能被同一个修复方向覆盖。
Step 4 — 簇内与簇间再验一次不相交
簇形成后,用同一个判定重算:簇 × 每个在飞 epic、以及簇 × 簇。任何 overlap 或
unproven 都要在 epic 正文里显式声明合并序,或把该成员移出。
Step 5 — 写 epic 正文
必须包含(缺一不可):
- 主题一句话 —— 说清这是哪个缺陷形状,不是列举症状
- 成员表 —— 每条一句话 + 落点
- 只碰 / 不碰 —— 逐文件写死;点名其他在飞 epic 占着哪些文件
- 逐条验收 —— mutation pair 的两臂都写出来(弄坏 → 必须红;恢复 → 必须绿)
- 误拦一侧 —— 若本 epic 在修「假红」,必须要求配一条证明真红仍红的测试
- round 上限 3(第二轮警告,第三轮未收敛即停机挂起并 @ 人)
- flake 处置 —— 看到红先查机器负载,别盲目重跑整条闸
- scrum 派工 —— 成员由 agent 自认领(
claimed_by),不是assigned_to - 成本闸四问 —— 见下。epic 是本 skill 唯一的写出物,也是工厂里最贵的一种工作项,
所以开 epic 这一步在
scripts/lint-issue-cost-gate.ts的ISSUE_OPENING_ROUTINES里申报为gated(不是豁免:它没有 env / 窄标签凭据,也不该有)。
正文里必须带这一段并如实填写:
<!-- cost-gate -->
- substrate: no — <换个地基为什么不会自动消失:给一个与地基无关的凭据(文件路径 / 复现命令 / #issue / SHA)>
- duty-log: no — <为什么这是一个工作项,而不是「本轮跑了什么、看到什么」的叙事>
- normal-state: no — <为什么这个状态是故障而不是正常态:干净机器上、清理之后也这样吗>
- cheaper-rung: <lint-rule|pre-pr-check|pr-template|doc|config|none-cheaper> — <便宜一档的解法是什么,为什么不够>
这四问今天有多硬 —— 三个面强度不同,不要读成一回事
| 面 | 强度 | 什么时候 |
|---|---|---|
bun scripts/issue-cost-gate.ts --body-file <f> --title <t> |
硬:缺段落 / 答案不在封闭词表 / 原样复制占位符 → 退出码 3 | 你自己跑的时候 |
PreToolUse hook .claude/hooks/record-ungated-factory-issue.ts |
只建议:permissionDecision 是 "allow",附一条 advisory reason,不拦,只留一条 would-have-blocked 样本给规划期的 --scan |
无人值守真开单的时候 |
lint:issue-cost-gate(pnpm lint:arch) |
硬,但它管的是申报表——这条开单路径有没有被分类过,不看任何一条 issue 正文 | PR 时 |
⚠️ 所以:无人值守跑到这里,四问缺段落或原样复制占位符并不会被拦住。 这一段是你自己要守的纪律,不是一道会替你兜底的闸。把它读成「反正过不去」是错的—— 声称的强度与实际强度不符,正是这道闸本身要消灭的东西。
一个 epic 的四问答的是这一簇,不是某一条成员
substrate的凭据用簇内最具体的那条落点(Step 2 定位出来的文件路径), 不要用症状描述——「换个地基就消失」的那一类恰恰是本 skill 最容易聚出来的假簇。cheaper-rung问的是「这一簇能不能被一条 lint / 一个 pre-pr 检查一次性覆盖」。 能,就不该形成 epic——去写那条 lint,那比派 N 个 agent 便宜一整个量级。 这一问因此不是手续——它问的正是「这一簇到底该不该以 epic 的形态存在」。
Step 6 — 自检(G1–G6,全部来自真实事故)
| G | 守卫 | 事故 |
|---|---|---|
| G1 | 建完校验 body 长度 > 0 | gh issue create 返回 URL、退出码 0、标签挂上,body 是空的。「创建成功」与「创建了空壳」完全同色 |
| G2 | epic 的动机若依赖一次测量,该测量必须先有 mutation pair | 「77 条依赖版本钉全部失效」源自一处 .split("@").pop() 取到了 peer 版本;基于它开了个 P1 epic,被认领者用 fixture 推翻 |
| G3 | 交集算文件级 | 目录级把 .claude/verify/checks/ 下的不同文件判成相交 |
| G4 | 摘掉 epic 给自己挂的 epic:<self> |
成员计数虚高 |
| G5 | 纳入前查在飞 PR | 差点重复派一条已有 PR 的 issue |
| G6 | conductor 有权否决成员,否决写回 ledger | 一次真实否决的理由比形成者的判断更准 |
Step 7 — 写 ledger
去掉 --dry-run 重跑,或手工写回。ledger 是下一轮效率的全部来源。
Step 8 — 无簇则静默
形不成合格的簇就什么都不做、不发 comment。沿用 issue-sweep 的「无事则静默」。
来源可换:默认 WorkObjectSource,GitHub 是 opt-in
工作项从 WorkItemSource(scripts/source.ts)来,判定核心不绑 GitHub。
默认 --source 是 WorkObjectSource(AFS /work)。GitHub 只做投影 alias。
bun .../sweep-batch.ts --dry-run # WorkObjectSource(默认)
bun .../sweep-batch.ts --dry-run --source github # GitHubIssueSource(opt-in)
两个适配器过同一套 source.conformance.test.ts——与本仓 provider conformance
同构:换源不得静默改变行为。
为什么这个抽象是效率问题,不只是整洁
GitHub 适配器必须把全部 open 工作项拉下来再本地过滤。gh issue list --label
只能收窄一部分,而本 skill 需要的是「label + 认领状态 + epic 关系 + 变更时间」的
联合过滤,GitHub 侧给不出。所以它每轮读 300 条正文——而正文是本 skill 最贵的
读取成本(路径面要扫全文)。这正是仓库 CLAUDE.md 点名的反模式:
大集合自己做 client 过滤 → 应当用 collection query 下推。
work object(arc #5540)落地后三件事同时变便宜:
GitHub 源(--source github) |
默认 WorkObjectSource | |
|---|---|---|
| 过滤 | 拉全量 300 条正文再本地筛 | /.actions/query 按 label / layer / changedSince 下推 |
| 分类 | 旁路 ledger 文件,多机各存一份 | layer / pathSurface / surfaceState 是对象上的字段,ledger 退役 |
| 关系 | 解析 epic:<n> 字符串 label |
epic → 成员是真实关系边 |
三条合起来,把每轮 sweep 从「全量重扫」变成「只读变化的那几条」。
纪律:声明即配套
capabilities.pushdown 声明了就必须真的在源侧过滤。conformance 有一条诚实臂:
声明下推的源,带过滤的调用必须严格少读——用「取全量再本地 filter」的实现声明它会红。
这条断言最初写成了
<=,一个谎称下推的源全绿通过;是变异测试把这个洞照出来的。<=与「真的下推了」在断言上同色。
WorkObjectSource 走 AFS /.actions/query 下推、member-of 真边、分类 ifMatch 写回。
构造注入 WorkLedgerOps;sweep-batch 默认 arc afs。AFS 不可用必须 throw(exit ≠ 0),
不得返回空数组冒充「成功的 0 items」。id 是 string(GitHub String(issue.number),
work object 是 w_<32hex>),禁止把 DID 哈希成 number。
所有 I/O 走 AFS API(afs.read / afs.list / afs.exec),不得直连后端——
见仓库根 CLAUDE.md「AFS-Only I/O」第一原则。
ledger
默认 .claude/state/sweep-batch-ledger.json。每条 issue 一条记录:
fingerprint(body + 排序 label 的 hash)、layer、pathSurface、surfaceState、
classifiedAt、epic、outcome、exclusionReason。
三条效率来源:
- 增量:fingerprint 未变且未过 TTL(14 天)→ 跳过,不重读正文、不重抽路径
- 负结果也存:「#N 曾被考虑进 epic #M,因爆炸半径过大排除」——下轮不重新论证
- veto 回流:conductor 剔除成员时写回,下次不再塞进同类 epic
长期这份 ledger 迁进 work object(arc #5540),本文件是它的前身。
埋点
沿用 sweep-trace,gate 取 cluster,val 取
epic-formed / unproven-blocked / no-cluster。dry-run 不发 comment、不附 trace。
一句话心智模型
issue-sweep 问「这条该怎么办」;本 skill 问「这几条能不能一起办,而且不撞别人」。