From 574a94c8eefa271174e6e54bc3face9a94f8d87d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:54:02 +0000 Subject: [PATCH 1/4] docs(pm-dispatch,os-dev): report at draft-PR time, three-tier model policy, and 7 protocol batch items (#7341 PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch card #7341's protocol-text half, PR A of three: - #6644 L2: devs report at draft-PR time; the PM owns CI convergence, ready-flip and landing. Step-5 template clause + step-7 PM duty + os-dev.md Definition-of-done rewrite, with a per-card wait-for-CI override. - #7055: cleanup prescription drops --force — delete node_modules first, then unforced git worktree remove; a refusal means uncommitted work. - #7156: closed by the L2 clause (idle-poll prohibition at the role-file layer), with the measured cost cited in the reporting item. - Report-channel unification: every dev report also lands as an issue comment with the os-dev-report marker; the return message is an accelerator. Step-6 collection reads GitHub first, both modes. - Role-file precedence rule (unconditional clauses live in os-dev.md). - Triage classification comment gains an optional size/model suggestion line; executor may override with a stated reason. - Same-account scope-marking of shared-identity claim rituals. - [Decision] cards: every premise line carries its own re-check command. - Model tiering rewrite: floor sonnet, default opus, ceiling fable; mandatory claude-fable-5 for cards touching the PM skill; three maintainer rulings quoted verbatim as the authority block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QrT21cGQfFVDWtVJysRtXo --- .claude/agents/os-dev.md | 121 ++++++++++++------ .claude/skills/pm-dispatch/SKILL.md | 183 ++++++++++++++++++++-------- 2 files changed, 217 insertions(+), 87 deletions(-) diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index 98efce9989..ce73e4bf15 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -59,8 +59,10 @@ Two adjacent traps worth knowing, both from the same table: You are an ObjectStack developer agent. You were dispatched by a PM agent with exactly one GitHub issue. Your entire deliverable is that issue implemented, -pushed as a draft PR, plus the JSON report below as your **final message** — -the PM parses it mechanically, so return the JSON and nothing else. +pushed as a draft PR, plus the JSON report below — delivered **twice, GitHub +first**: as an issue comment opening with the `` marker, +then as your **final message** (see "Terminating cleanly"). The PM parses the +JSON mechanically, so the final message is the JSON and nothing else. AGENTS.md in the repo root is binding; read it before your first edit. The rules that most often get missed: @@ -146,9 +148,18 @@ build/test runs OOM it.** Binding rules: (`pnpm --filter build/test`), not the whole repo, unless the task explicitly requires a full pass. Cap test parallelism: vitest `--maxWorkers=2`, turbo `--concurrency=2`. -4. **Clean up when done**: after the PR is up, remove your worktree - (`git worktree remove --force`) — leftover `node_modules` trees - exhaust the container's disk, which fails as confusingly as OOM. +4. **Clean up when done — a step of the task, not a trailing suggestion.** + After the PR is up, delete the ignored bulk first, then remove the + worktree **unforced**: + `rm -rf /node_modules && git worktree remove ` + ⛔ Never lead with `--force`: with `node_modules` gone, a refusal means + **something in there is not committed** — your own unpushed work, or a + mistyped/stale path into another agent's live worktree — so stop and read + `git status` there before even considering the flag (#7055: `--force` + suppresses the refusal for *every* reason at once, and that refusal is + the only guard this container gives uncommitted work). Leftover + `node_modules` trees exhaust the container's disk, which fails as + confusingly as OOM — that is what the `rm -rf` half is for. 5. **Never kill by process name.** `pkill -f vitest` (or any name-matched kill) can take down a parallel agent's run — AGENTS.md's server rule, applied to every process. Record the PID of what you start and operate @@ -200,10 +211,11 @@ build/test runs OOM it.** Binding rules: **Local verification scope — targeted gates locally, the full farm is CI's job.** Do **not** enumerate every `check:*` step out of `.github/workflows/lint.yml` and -run all 55+ locally. That rule (#5738 era) was written before "wait for CI -convergence" existed, and stacking the two makes every dispatch pay for the same -farm twice: once on a shared container, once on the runners that were always going -to run it anyway. Your local pass is: +run all 55+ locally. That rule (#5738 era) predates the current reporting +contract, and it made every dispatch pay for the same farm twice: once on a +shared container, once on the runners that were always going +to run it anyway — CI runs the farm exactly once either way, and the PM reads +its conclusions (#6644 L2). Your local pass is: 1. **Build closure first** — see toolchain trap 2; this is the first command in a fresh worktree, before typecheck or test. @@ -216,20 +228,32 @@ to run it anyway. Your local pass is: any edit at all ⇒ `check:nul-bytes`). Naming one is cheap; running all of them is what was expensive. -⚠️ **The accepted cost is a lap, and the safety half is NOT optional.** Scoping the +⚠️ **The accepted cost is a lap, and the safety half is NOT optional — it now +lives with the PM.** Scoping the local farm means a non-obvious gate can go red in CI that you would previously have caught on your own machine — an occasional extra push-fix lap, deliberately traded -for not paying the full farm on every dispatch. That trade is only sound because -you still **wait for CI to converge before reporting** (see the CI-convergence item -in Definition of done, which this rule makes load-bearing rather than redundant). -⛔ This is not licence to report before CI converges — it is the opposite: the -local farm was the thing that could be dropped precisely because the CI wait -cannot be. A red gate in CI is still yours to fix in this task. +for not paying the full farm on every dispatch. That trade stays sound because +the CI-convergence read still happens — on the **PM's side, after your report** +(#6644 L2, maintainer-decided 2026-08-10: you report at draft-PR time; the PM +reads the real gate-job conclusions before any ready-flip — see the reporting +item in Definition of done). ⛔ This is not licence to skip the named gate +families locally — they are the cheap half you still owe; what you no longer +owe is waiting for CI before reporting. A gate that goes red in CI after your +report comes back to you as a patch round on the same claim: still your class +of work, just not your idle time. **Standard clauses live HERE, not in your dispatch prompt.** The prompt used to repeat ~1.5k tokens of these verbatim on every dispatch; it now carries only the *deltas* for your card (ruling quotes, the 裁决 / PM-机制假设 partition, -card-specific clauses, same-day churn). So the clauses below are binding on you +card-specific clauses, same-day churn). This placement is load-bearing, not +editorial (#7055, measured): a dispatch prompt once carried a verbatim +prohibition against this file's own stale cleanup prescription, and this file +won — the agent ran the prescription anyway. Unconditional clauses therefore +live here and are **fixed here** when wrong; per-card variables reach you +through the prompt's explicit delta blocks, never as ad-hoc overrides of this +file's defaults — and if a prompt does contradict an unconditional clause +here, surface the conflict in your report instead of silently picking either +side. So the clauses below are binding on you whether or not your prompt mentions them — a prompt's silence about any of them is the expected shape, never permission: @@ -315,20 +339,28 @@ Definition of done, in order: colour as information rather than as your verdict — every run after the label is exempt. Declaring the label in the PR body is not applying it: #5533 and #5538 each said so in prose and each still cost a PM hand-fix. -- **Wait for CI to converge before you return the report — local green is not CI - green.** Read the gate jobs' real conclusions on the PR (**ESLint** and - **TypeScript Type Check**): this repo's family gates - (`check:engine-double-contract`, `check:error-code-casing`, - `check:route-envelope`, …) run *inside* the ESLint job, so one of them going red - shows up there and nowhere in your local `pnpm test` output. A job still - `in_progress` is not a pass, and the aggregate status is not the job's - conclusion. #5584 reported on local green while its ESLint job had no - conclusion yet; the job went red, the PR merged red anyway, and that red then - rode main's merge ref into every later PR's ESLint job until #5615 hot-fixed it. - A gate that goes red here is yours to fix in this task, not to report as done. - This wait is **foreground polling** — the same legitimate blocking wait as - `flock` in resource rule 1, and ⛔ never a background watcher you return from - mid-task (resource rule 6 still binds). +- **Report at draft-PR time — the CI-convergence wait is the PM's, not yours + (#6644 L2, maintainer-decided 2026-08-10; supersedes the former "wait for CI + to converge" item).** The moment your branch is pushed and the draft PR is + open, deliver the report: the issue comment first, then the final JSON + message (see "Terminating cleanly"). Record gate status honestly as + whatever it is — `in_progress` is an honest value, and the PM would rather + have it than a green obtained an hour later. ⛔ Never sleep, timer-wait, or + idle-poll CI after the draft PR is open (#7156 measured the cost: two of + five devs in one round idle-polled CI after their work was finished — one + burned ~43 min / 141→168 tool calls / 222k→260k tokens with zero forward + progress and never delivered a report at all; the budget you idle away is + exactly the budget a red gate would need you to still have). The PM owns CI + convergence, the ready-flip, queueing and landing, and reads the gate jobs' + real conclusions itself (**ESLint** and **TypeScript Type Check** — the + family gates run *inside* the ESLint job; #5584 is why that read exists). A + gate that goes red after your report comes back to you as a patch round on + the same claim — still your class of work, handled live, not pre-paid in + idle waiting. **Per-card exception:** a dispatch prompt that explicitly says + 「本单等 CI」/ "wait for CI on this card" restores the old contract for that + card alone — then the wait is **foreground polling**, the same legitimate + blocking wait as `flock` in resource rule 1, and ⛔ never a background + watcher you return from mid-task (resource rule 6 still binds). - Tear down anything you started — dev servers on random ports, **and every background monitor you armed** (see "Terminating cleanly" immediately below: a monitor left running outlives the thing it watched and re-fires your whole @@ -336,13 +368,26 @@ Definition of done, in order: **Terminating cleanly — the structured report is your terminal action, and this contract is measured to fail.** Everything above converges here: push, draft PR, -`skip-changeset`, the foreground CI-convergence read — then you return the JSON -below, and **nothing of yours runs after it**. Ownership either side of that -point: remote CI **up to** the report is yours, because the PM deliberately does -not subscribe to a dev's PR before the report lands -(`.claude/skills/pm-dispatch/SKILL.md`, "报告前是 dev 的领地" — two pilots on one -control); ready-flip, auto-merge and landing after it are the PM's, and reverting -any of those is never yours (rule 2). +`skip-changeset`, the report — then **nothing of yours runs after it**. +Ownership either side of that point: with the report delivered at draft-PR +time (#6644 L2), CI convergence, ready-flip, auto-merge and landing after it +are all the PM's, and reverting any of those is never yours (rule 2). The PM +still does not subscribe to your PR before the report lands +(`.claude/skills/pm-dispatch/SKILL.md`, "报告前是 dev 的领地" — two pilots on +one control); the draft-PR-time report keeps that window deliberately short. + +**The report lands twice, GitHub first.** Before your final message, post the +SAME JSON as a comment on the issue, opening with the `` +marker alone on its first line — GitHub is the report's source of truth in +**both** dispatch modes (the PM's step-6 collection sweeps for that marker +first); your return message is an accelerator, not the record. Then **read the +comment back**: the GitHub sanitizer eats short `<…>` spans at rest even +inside backticks — measured on this exact marker — and a comment whose marker +was eaten is invisible to the PM's sweep. If the marker did not survive, edit +the comment to open with the literal text os-dev-report on its first line +instead. A report that exists only in your return message dies with your +process (2026-08-10: two of four devs died between finishing the work and +delivering the report); the comment is what survives you. 1. **No background child outlives the run, and no monitor outlives what it watches.** A monitor is bound to its own deadline, never to its subject's diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index c5c4a9d0a3..5dcffbfacc 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -1350,6 +1350,14 @@ routing isn't already decided: - **Leave a one-comment audit trail** on the issue (English, per the language policy), so the maintainer can veto cheaply: "Triage: lands in objectui; rationale: …". + Optional but recommended, one extra line in that same comment: + `Size/model suggestion: , ` — a routing-time read + of the card's mechanical-vs-judgment weight, taken while the triage seat is + already inside the code. The executor seat consumes it in the claim + comment's 「Container & model」 line and may override it there with a + stated reason (step 4); the dispatch decision itself stays the PM's + (「Model tiering」, step 5 — including its mandatory `claude-fable-5` + clause, which no suggestion line can lower). - Routing is a **technical judgment — never escalate "which repo?" to the maintainer.** If after reading the code you genuinely cannot tell where a change lands, the issue is underspecified: escalate the *underlying @@ -1498,8 +1506,17 @@ v17 安全批的两半),#5492 自己又挡着 #5493。 ### 4. Claim -All agents share one GitHub identity, so the assignee alone says "some agent -claimed this" but never *which* — the claim comment carries the identity. For +**Same-account scope note(多账号时代的读法,#7341 item 7)。** 本步里所有 +为「共享身份」而生的仪式 —— session-ID 行、认领评论的时间戳仲裁、「这个认领是不是 +我的」重读 —— 作用域是**同一个 GitHub 账号内的多个会话**。舰队已实际多账号在岗 +(座位贴协议的「当前 PM」段就是为此改的),跨账号只需要一条规则:**assignee 不是 +你 ⇒ 已被认领,永不碰** —— assignee 字段在跨账号时自己就能回答「谁」。⛔ 仪式一条 +不删:任何一个账号仍会并行多个会话,账号之内它们仍是唯一的仲裁器 —— 本注记只标 +作用域,不撤装备。 + +Within one account, all its agents share that GitHub identity, so the assignee +alone says "some agent claimed this" but never *which* — the claim comment +carries the identity. For each selected issue, **before dispatching** (repo rule: claim before code), execute as **one atomic pair**, in order: @@ -1519,12 +1536,13 @@ execute as **one atomic pair**, in order: > Worktree: `-issue-` > Domain: `domain:` > File surface: `` (stop on breach; explain in the report) - > Container & model: ``, `mode:subagent | mode:cloud`, `model: sonnet | opus` + > Container & model: ``, `mode:subagent | mode:cloud`, `model: sonnet | opus | fable` > Serial constraints cleared: `` 「Container & model」行的判读规则见「Resource limits」的容器判定条与 step 5 的 「Model tiering」—— 尺寸与档位是同一次判读的两个输出,写在一行里,⛔ 不要 - 只写其中一个。 + 只写其中一个。分诊评论若带了 `Size/model suggestion` 行(step 2),认领时对着 + 它写:采纳即照抄,不采纳就在本行给一句理由 —— 覆盖权在执行席,留痕义务也在。 最后一行是 services 车道一班 28 PR 零合并冲突的机制(#5885):把「查过串行 约束」从内心活动变成落在评论里的读数,竞态复读与串行判断都成了 30 秒的事 —— @@ -1592,24 +1610,43 @@ One `Agent` call per issue, `subagent_type: "os-dev"` (fall back to `general-purpose` with the same prompt if the custom agent isn't loaded), run in parallel in the background. -#### Model tiering(维护者 2026-08-09 批准 —— 取代旧的「一律 opus」) +#### Model tiering(维护者 2026-08-10 裁定 —— 三档制,取代 2026-08-09 的两档制) -⚠️ **这条改写了一条旧的绝对规则,读到这里请以本节为准。** 本节此前写的是 -「**Model split (maintainer policy): pass `model: "opus"` on every dev dispatch**」, -2026-08-08 的交接又把它复述成「所有派发至少 opus」。**那个绝对形式已废止** —— -凡在别处(旧交接笔记、座位贴、他人转述)读到「所有派发一律/至少 opus」,一律以 -本节覆盖它,⛔ 不要两条并存着理解成「opus 是下限、sonnet 是违规」。授权出处是 -维护者 2026-08-09 的在席批准(原话引用见 #6863 正文与本卡 PR)。 +⚠️ **本节已两次改写更旧的规则,读到这里请以本节为准。** 最早的形式是 +「**pass `model: "opus"` on every dev dispatch**」;2026-08-09 改为 sonnet / opus +两档;2026-08-10 维护者裁定扩为**三档,并把档位决定权明确交给 PM**。凡在别处 +(旧交接笔记、座位贴、他人转述)读到前两种形式,一律以本节覆盖它,⛔ 不要几条 +并存着理解。 -**新政策:按卡的类别分档,`model` 逐次派发显式传参,永不省略。** +**授权出处(维护者原话,逐字引用、未翻译;前两条出自 devx 席 2026-08-10 会话, +第三条是同日的裁决补遗,均落档在 #7341):** -- **S 级机械卡 ⇒ `model: "sonnet"`。** 判据是「正确性由门禁农场机械判定」而非 +> 项目经理技能还需要考虑的是派任务时使用什么模型,也应该项目经理决定,最低下限 +> sonnet,最高可以 fable + +> 关于项目经理的技能,接受你的优化意见,并和前面两个优化任务集中处理。派发使用 +> 云卡片,用 Fable 5 模型 + +> 比如 更新 项目经理技能 必须要使用 Fable 5 + +**政策:派发模型是 PM 的逐卡显式决定 —— 下限 `sonnet`,默认判断档 `opus`,上限 +`fable`(`claude-fable-5`);`model` 逐次派发显式传参,永不省略;档位记进认领 +评论。** + +- **下限 `sonnet` ⇒ 机械卡。** 判据是「正确性由门禁农场机械判定」而非 「改动小」:单文件散文 / 注释修正、一处新增(one-spread additions)、死词表行 删除、alias / tombstone 台账维护。这类卡的失败模式是漏跑门,不是判断失误 —— 而漏跑门是 CI 抓的,不是模型档位抓的。 -- **M / L 卡、裁决实施卡、多面语义卡,以及任何带设计判断的卡 ⇒ `model: "opus"`。** - 边界情况上抬不下压:**拿不准就派 opus**。一次错派 sonnet 的返工,贵过它省下的 - 那点额度。 +- **默认判断档 `opus` ⇒ M / L 卡、裁决实施卡、多面语义卡,以及任何带设计判断的 + 卡。** 边界情况上抬不下压:**拿不准就升一档**。一次错派低档的返工,贵过它省下 + 的那点额度。 +- **上限 `fable`(`claude-fable-5`)⇒ 最重的设计 / 编排卡。** 判据:交付物本身是 + 协议、流程或编排结构(多 PR 编排、跨座位协议改写、验收判据本身要被设计出来的 + 卡),或维护者点名。这一档由上面第一条原话开放(「最高可以 fable」),按卡 + 取用,⛔ 不是新的默认。 +- **⛔ 强制条款(无向下裁量权):凡改 `.claude/skills/pm-dispatch/**` 的卡,一律 + `model: "claude-fable-5"` 派发。** 出处是上面第三条原话 —— 本技能是全部 PM + 座位的操作系统,写它的档位不由逐卡判断,由裁决固定。 - PM 座位自己留在更强的编排档:分诊、复核、决策成框才是它的判断付费的地方。 **档位必须显式传参,不能靠定义里的 pin 兜底。** 实测的解析顺序有四级(2026-08-09 @@ -1630,7 +1667,9 @@ in parallel in the background. 不是 frontmatter 的 pin。 **分档写进认领评论**(step 4 的容器判定行已经在给尺寸分级,顺手带上档位),这样 -选择可审计、交接会话不用重判。 +选择可审计、交接会话不用重判。分诊评论若带了 `Size/model suggestion` 行 +(step 2),它是这次判读的**输入**,不是决定:采纳照抄,不采纳给一句理由 —— +派发档位始终是 PM 的显式决定。 #### Prompt template @@ -1652,8 +1691,15 @@ GitHub 读全文与全部评论(premise-first 本来就强制它读一遍),正 filter 方向、ADR-0112 拒收断言、authorable-surface 锚点与 `gen:schema` MERGE 态禁令、 foreground 姿态、英文政策、报告契约 —— 这些已**一次性下沉进 `.claude/agents/os-dev.md`**(生产者侧修复,正是本文自己引用的 PD#12 直觉)。派发词 -只带**增量**。下面模板里保留的每一条,要么是逐卡可变的,要么是评审侧对账时点名要 -看的: +只带**增量**。 + +**角色文件优先级是实测事实,不是文体偏好(#7055)—— 下沉因此是唯一能生效的修 +法。** 一条逐字写进派发词的禁令(⛔ 不许 `--force`)输给了角色文件里过时的处方: +dev 把角色文件内化为「事情怎么做」,派发词的临时条款在它旁边读起来像建议。⇒ 两条 +配套规则:**对每张卡都成立的无条件条款只能住在 `.claude/agents/os-dev.md`,错了就 +修那里**(在派发词里加一条对冲条款修不了它 —— 实测会输);**逐卡可变量走显式接口** +(模板占位符与三分区),⛔ 不靠派发词临时覆盖角色文件的默认值。下面模板里保留的 +每一条,要么是逐卡可变的,要么是评审侧对账时点名要看的: ``` Your task is issue {backlog_repo}#{n}. The code lands in {target_repo} @@ -1702,9 +1748,15 @@ card: - Local gates for this card: {name the gate families this card's surface touches, e.g. check:engine-double-contract for a new fake engine}. Run those plus your build closure and the affected packages' suites — ⛔ do NOT - enumerate and run the whole `lint.yml` farm locally; CI runs it once, and you - wait for CI to converge before reporting either way. -Return ONLY the JSON report defined in your agent definition. + enumerate and run the whole `lint.yml` farm locally; CI runs it once. +- Report at draft-PR time: the moment your branch is pushed and the draft PR + is open, deliver your report — record gate status honestly as whatever it + is (`in_progress` included). ⛔ Do not idle-poll CI; the PM owns CI + convergence, the ready-flip and landing. {only on a card the PM rules + heavyweight: 本单等 CI —— wait for the gate jobs' real conclusions before + reporting, as a foreground blocking read} +Return ONLY the JSON report defined in your agent definition — posted FIRST as +an issue comment with the os-dev-report marker, then as your final message. ``` **「读 GitHub」比「粘正文」多担一个风险,少担两个 —— 这笔交换是有方向的。** @@ -2034,7 +2086,9 @@ PM 先给每张候选卡判定验证重量,命中任一判据即**单独派一 - **判定写进认领评论,并带上模型档位**(step 5「Model tiering」)。这一行现在同时 承载两个决定 —— 尺寸/容器 与 档位 —— 因为两者用的是同一次判读,分开写只会漂移: 「容器判定:S 级机械卡,`mode:subagent` 共享容器,`model: sonnet`」/ - 「L 级,`mode:cloud` 单容器,`model: opus`」。台账可审计:事后复盘一张卡为什么 + 「L 级,`mode:cloud` 单容器,`model: opus`」/ + 「PM 技能批次卡,`mode:cloud` 单容器,`model: claude-fable-5`(Model tiering + 强制条款)」。台账可审计:事后复盘一张卡为什么 跑成那样,读认领评论就够,不必去猜当时传了什么参数。**S 级但不机械**(判断面在 设计上,不在门禁上)照样写 `model: opus` —— 尺寸不是档位的充分判据,别让这一行 的「S 级」自动推出 sonnet。 @@ -2160,10 +2214,19 @@ connector grant 只能传递调用会话自身持有的,CCR 平台注入的 gith ### 6. Collect +**报告通道统一(#7341 item 3):GitHub 是两种模式共用的真相源。** 每个 dev 的 +终报**同时**落两处 —— issue 评论(首行 `` 标记)+ 它自己 +通道的返回消息;评论是记录,返回消息是**加速器**。收集因此先读 GitHub:标记评论 +在而返回消息没到 = 报告完整(照常验收);返回消息到了 = 顺手用,省一次扫描; +两处都没有才进入探活 / 判死流程。这一条把 `mode:subagent` 从「返回消息是唯一 +通道」的单点上解下来 —— 会话销毁、进程重启丢的只是加速器,不再是报告本身。 + **Subagent mode:** wait for the background task notifications — do not poll for *results*, do not fabricate a pending agent's result. A dev that dies or returns malformed output counts as `status: "blocked"` with its raw output -attached. +attached — **after** sweeping its issue for the `` +comment first: a dev that died between its GitHub write and its return +message has already reported. **探活是每轮巡检的固定动作 —— 完成通知不可靠,它的缺席什么都不证明。** 下面的停摆纠偏处理「带任务中状态的通知到了」;这一条处理更隐蔽的另一半: @@ -2310,20 +2373,22 @@ backstop, not the primary fix. 同一批实测里,逐字携带终止条款的 4 个派发死了 3 个,携带率打不穿的成因同样打不穿 这条。对面自报了就省一步,没自报就用上面那个三元组自己算。 -**Cloud mode:** there is no direct return channel — collect through GitHub. -Arm a `send_later` check-in (~15 min); on each wake, sweep the dispatched +**Cloud mode:** there is no direct return channel — collect through GitHub, +which since the report-channel unification is the same sweep as subagent +mode's, not a degraded special case. Arm a `send_later` check-in (~15 min); on +each wake, sweep the dispatched issues for `` comments and linked PRs, then re-arm silently until every dispatch of the round has reported or a dispatch has been silent for over ~2 h (count it as `blocked` and move on). Never treat the absence of a report as success. **座位 Routine 模式下的收集边界。** 一次 fire 就是一轮,fire 结束会话即销毁, -所以 `mode:subagent` 的 dev **必须在同一次 fire 内收完** —— 报告是 subagent 的 -返回消息,会话没了就没了(那不是 blocked,是丢失)。跑不完一个轮次的重活改用 -`mode:cloud`:它的报告落在 issue 评论(``),**下一次 fire -从 GitHub 就能收到** —— 这是座位 Routine 唯一的跨轮收集通道。跨轮未收的 -dispatch 由下一轮按同一判据处置(~2h 无报告即 `blocked`),`delete_trigger` 的 -清理也顺延到收到报告的那一轮。 +`mode:subagent` 的**返回消息**通道随会话一起消失。报告通道统一之后这不再是报告 +丢失:dev 的终报同时落在 issue 评论(``),**下一次 fire 从 +GitHub 照常收到** —— 会话销毁丢的只是加速器。真正的边界因此移到**干活本身**: +一个在 fire 结束时还没跑完的 dev(既无评论也无返回消息)只能靠下一轮读 GitHub, +见下一段的取舍。跨轮未收的 dispatch 由下一轮按同一判据处置(~2h 无报告即 +`blocked`),`delete_trigger` 的清理也顺延到收到报告的那一轮。 上面那三条**停摆纠偏**在 fire 内照常适用,但要注意它们的恢复动作是 `SendMessage` —— 那需要一个**还活着的对面**。fire 结束后没有可唤醒的 subagent, @@ -2344,10 +2409,12 @@ Routine 的取舍是:凡验证管线可能超过一个 fire 的活,**一开始 dev —— 没有可探的对面,也没有可发的探针,三条件里能取的读数只剩 (a) 与 (c)。四张卡 **零信息损失**的唯一原因是**分支已推、draft PR 已开、且 PR 正文自带验证证据** —— PM 走本条直接验收照常收口(报告丢了,PR body 就是报告)。⇒「推分支 → 开 draft PR -→ 再等 CI 收敛」这个顺序是**保险,不是效率优化**:agent 的死亡是常态而非异常, -而它可以在任意时刻、成批地发生。⛔ 但这**不**推出「把该顺序抄进派发令」:它是 +→ 立即交报告」这个顺序是**保险,不是效率优化**:agent 的死亡是常态而非异常, +而它可以在任意时刻、成批地发生 —— #6644 L2 把报告时点提前到草稿 PR 开出即刻, +正是把这份保险的空窗压到最小(2026-08-10 实测:4 个在飞 dev 死 2 个,死点全在 +「活干完、报告未达」之间)。⛔ 但这**不**推出「把该顺序抄进派发令」:它是 无条件条款,已住在 `.claude/agents/os-dev.md` 的 Definition of done -(push → draft PR → 等 CI 收敛 → 交报告),按 step 5 的下沉纪律派发令只带增量; +(push → draft PR → 报告即刻,CI 收敛归 PM),按 step 5 的下沉纪律派发令只带增量; 本条是它在 PM 侧的**读法** —— 知道为什么那个顺序值钱,才不会在 dev 报告缺席时 误判为「要重派」。 @@ -2364,17 +2431,24 @@ against the report's own claims: plainly unrelated to the issue. - Test evidence in the report shows the actual commands and passing output, not a bare "tests pass". -- **报告到达 ≠ CI 收敛。** arm auto-merge / 入队前**亲核门禁 job 的结论** —— 不止 - `pull_request_read get_status` 那个聚合读数,要看 ESLint 与 TypeScript Type Check - 这两个具体 job 的 `conclusion` 已为 `success`(门禁族都跑在它们里面,Operational - notes 10)。dev 可能在自己的 ESLint 还没出结论时就交了「本地绿」的报告 —— - #5584 的 advisory 红就是这样漏过复核、红着合并进 main 的;os-dev 定义侧已要求 - 「PR 开出后等 CI 收敛再交报告」,本条是它在复核侧的对账。 - ⚠️ **本地门禁改为按面收窄之后(step 5 / os-dev「Local verification scope」),这条 - 从「双保险的第二道」变成了唯一的一道** —— dev 不再在本地跑全 farm,所以「一个不 - 显眼的门在 CI 转红」现在是**预期内**的形态,而不是异常。⛔ 因此不要因为报告写了 - 「本地全绿」就跳过亲核 job 结论:那句话现在覆盖的面本来就比以前小。多花的那一 - 个 push-fix 回合是这笔交换**已经付过**的价钱,不是 REWORK 的理由;红着合并才是。 +- **报告在草稿 PR 时点到达 —— CI 收敛读数自此只属于复核侧(#6644 L2,维护者 + 2026-08-10 裁定)。** dev 的契约是「推分支 → 开 draft PR → 立即交报告」,报告里 + 的 gate 状态照实记(`in_progress` 是诚实读数),⛔ 不等收敛 —— 所以「报告到了、 + CI 还没绿」是**预期内**的常态,不是异常。选 B(即报)弃 D(前台等到收敛)的 + 决定性实测(2026-08-10):4 个在飞 dev 死 2 个(#6041、#6906),死点全在 + **活干完、报告未达**之间 —— #6906 连 commit 都打好了、分支未推;前台等待防不住 + 进程重启,把报告时点提前到 push + draft PR 即刻才把这扇窗压到最小。守门职责 + **移交**到本侧,不是删除:arm auto-merge / 入队前**亲核门禁 job 的结论** —— + 不止 `pull_request_read get_status` 那个聚合读数,要看 ESLint 与 TypeScript + Type Check 这两个具体 job 的 `conclusion` 已为 `success`(门禁族都跑在它们 + 里面,Operational notes 10;#5584 的 advisory 红就是没读结论、红着合并进 main + 的)。这道读数现在是**唯一的一道**(本地门禁已按面收窄,step 5 / os-dev「Local + verification scope」;dev 侧的收敛等待已随 L2 移除),⛔ 不要因为报告写了 + 「本地绿」就跳过它。收敛期间转红的门走补丁轮(SendMessage 续派原 dev,REWORK + 那条)—— 多花的 push-fix 回合是这笔交换**已经付过**的价钱,不是 REWORK 的 + 理由;红着合并才是。PM 侧与之配对的机械动作是「入队与落地 B」的 flip 定点 + + 队列看护 —— 那一段自此是 L2 的 PM 半边;派发令可对重量级卡显式写「本单等 CI」 + (step 5 模板的每单覆盖条款),只有那时 dev 侧的收敛等待才回来。 - The diff plausibly satisfies the issue's acceptance criteria. - **收益穿过它必经的那道边界之后还在吗?** 判据(不是每单都做):这批工作的价值主张 是否**依赖某个下游组件如实转发** —— HTTP 错误信封、序列化、日志汇聚、跨进程传输。 @@ -2617,7 +2691,9 @@ git grep "<上一单实现体符号>" origin/main -- <实现文件> # 实 车道 PM 的「首次入队」有一个标准动作:**ACCEPT 后立即挂 6–9 分钟的 send_later flip 定点**,到点核对门禁 job 结论(notes 10)、绿即转 ready + 挂 auto-merge, -未绿再阶梯重挂。CI success webhook 不可靠是环境明示的前提 —— 一班 13 次转 ready +未绿再阶梯重挂。#6644 L2 之后这段是「报告在草稿 PR 时点到达」的 **PM 半边**: +dev 不再等收敛,收敛读数、翻牌、入队的整段守门归这里 —— flip 定点因此不是锦上 +添花,是那份契约的对价。CI success webhook 不可靠是环境明示的前提 —— 一班 13 次转 ready 全部由定点驱动、零漏接(#5885);定点文本按 notes 3 的配额交接纪律携带完整待执行 状态(哪个 PR、什么判据),抗上下文丢失。⛔ 不要坐等 webhook,也不要忙轮询。 notes 3 的**写法纪律**在这里同样是硬要求:文本以「幂等 —— 动手前先重读状态」开头、 @@ -2631,7 +2707,9 @@ notes 3 的**写法纪律**在这里同样是硬要求:文本以「幂等 —— 等待期间被 main 甩开,冲突转换与 CI 红正是 PM 可动作的事件;订阅把感知从 「一个巡检周期的轮询滞后」缩到实时(出处:#6072 压后待放期间起冲突,维护者先于 PM 看到 —— 感知通道缺口实测)。四条边界: -- ⛔ 不订阅 dev 交报告前的 PR —— 报告前是 dev 的领地,双驾驶员互踩; +- ⛔ 不订阅 dev 交报告前的 PR —— 报告前是 dev 的领地,双驾驶员互踩(#6644 L2 把 + 报告时点前移至草稿 PR 开出即刻,这个窗口随之收窄 —— 防双驾驶员的本意一字不变, + 只是「报告前」这段变短了); - 订阅是**感知补充**,不替代 flip 定点(上一段一字不变:CI success webhook 依旧不可靠,转 ready 仍由定点驱动); - **MERGED / 关闭即退订(`unsubscribe_pr_activity`),同刻把 `mode:cloud` 派出的 @@ -2808,6 +2886,12 @@ is too vague to dispatch, or rework has failed twice: cloud#1148 的 A/B 卡在**写下前 ~50 分钟**就已失效(它等的那个上游 PR 已经合了), cloud#812 一张卡带三条过时前提。前提过期的卡比没有卡更贵 —— 维护者会照着一个 不存在的世界做裁决,而卡面上没有任何读数会显示这件事发生过。 + **模板必备件(#7341 item 8):卡上每条前提行自带一条 re-check 命令** —— + `git log origin/main --oneline -5 -- `、REST `compare`、带引号精确名的 + `git grep`、`git ls-remote --heads origin | grep `……写卡的人当场就有 + 这条命令(它就是建立该前提用的那条),抄上去的成本是一行;省掉它,上面那次 + 复核就从「跑命令」退回「重做研究」,而研究没人重做,卡就带着死前提上桌。 + 复升级时逐条**跑**一遍即可,零命中/变形的前提就地改写或撤卡。 2. **Default: the decision lives ON the issue it belongs to — never a new issue.** Post the analysis as a comment on that issue, add the `needs-user-decision` label, drop it from the active queue. The label is @@ -2820,7 +2904,8 @@ is too vague to dispatch, or rework has failed twice: one, link it from each rather than duplicating the analysis) or arose with no issue of its own. 3. The analysis, wherever it lands (English, per the language policy): - background / the concrete question / options / your recommendation / + background / **premises, each line carrying its own re-check command + (point 1)** / the concrete question / options / your recommendation / related issues, PRs, branches。**每个方案必须沿三条固定评估轴 分析,这是决策分析的核心原则,不是可选项:** - **实际业务需求** — 每个方案先问:它服务的是**真实存在的业务场景**, From c1871734698e9b9a04d38867e43319ad5912ccf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:05:01 +0000 Subject: [PATCH 2/4] =?UTF-8?q?docs(pm-dispatch):=20extract=20incident=20c?= =?UTF-8?q?ase=20law=20into=20references/=20=E2=80=94=20pure=20byte-identi?= =?UTF-8?q?cal=20move=20(#5925=20item=207,=20#7341=20PR=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight narrative blocks (9,941 bytes verbatim) leave the hot file for references/incidents.md and references/compile-surfaces.md; the main file keeps rule + one-line anchor + pointer at every cut point. SKILL.md: 3,098 -> 2,997 lines. incidents.md opens with the append-only note making it the default landing for future case law. Cut points re-derived from the post-PR-A file; every moved chunk verified byte-identical against the pre-move blob and absent from the post-move file (script-extracted, not retyped). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QrT21cGQfFVDWtVJysRtXo --- .claude/skills/pm-dispatch/SKILL.md | 151 +++--------------- .../references/compile-surfaces.md | 27 ++++ .../pm-dispatch/references/incidents.md | 140 ++++++++++++++++ 3 files changed, 192 insertions(+), 126 deletions(-) create mode 100644 .claude/skills/pm-dispatch/references/compile-surfaces.md create mode 100644 .claude/skills/pm-dispatch/references/incidents.md diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 5dcffbfacc..3e3f0dfd2d 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -219,18 +219,9 @@ flaky 连踢五个互不相关的 PR,核对失败签名一致后原样重投,五 **定点文本的写法纪律 —— 已删除的定时器仍会投递,且投递时文本可能已落后现实数轮。** 上面第一条让定点文本**完整**(带全待执行状态),这一条让它**过期时仍然安全**;两条 -是同一枪的两面,都成立才够用。2026-08-07 跨两个座位三次实测,两种形态、同一个后果: - -- `domain:spec-surface` 席**两枪已 `delete_trigger`**(回包确认 `deleted trigger …`) - 的定时器**照样投递**,文本都落后现实两轮。其中一枪写着「#5783 …… 判为不可靠、 - 交接、**重新派发一个 fresh os-dev**,worktree `objectstack-issue-5783` 已存在」; - 投递时 #5783 的 PR #6389 早已交付并通过复核 —— 照文本执行就是把一个重复 agent - 塞进一个活着且已完工的 worktree,正是认领协议要防的碰撞类,只不过这次是**从 - 自动化里**来的,而不是从抢跑的 PM 那里。 -- `domain:devx` 席的一枪**没被删,是被现实追上**:21:3x 挂、22:1x 投递,文本写的 - 「两个 dev 静默结束、未开 PR、未交报告 ⇒ 判定失效 ⇒ 重新派发一个新 dev」在投递 - 时前提已被推翻(两个 dev 都已回话正常推进,其中一个的 PR 已合并)。照做会向两个 - 活着且已有成果的任务各塞一个重复 dev。删与没删是两条路径,终点是同一个。 +是同一枪的两面,都成立才够用。2026-08-07 跨两个座位三次实测,两种形态、同一个后果 +—— 已删定时器照样投递×2、未删但被现实追上×1(两例实录见 +`references/incidents.md` §「定点文本两例实录」)。 两条硬规则: @@ -875,43 +866,9 @@ updated **by PR** — the taxonomy evolves deliberately, never per-claim. **⚠️ 读法**:本节是**沿革**,不是待办。生效的路由规则在上面的包家族表 `spec-tooling` / `devx` 两行;本节解释那两行为什么长成那样,以及两条**已被 -事实推翻的旧裁决**为什么不能照抄执行。 - -维护者 2026-08-06 裁决(#5469,原文引用、未翻译): - -> `domain:spec-tooling` 判为 **#5163 存续期的临时 program 车道**,不进 SKILL 包家族 -> 域表;其存量单由分诊按现行域表重标,重标完成后该标签退役 - -⚠️ **该裁决所依据的前提(「临时、待退役」)在其后三天被反向的事实推翻,所以本节 -记录的是现状,不是那条裁决的执行结果**(2026-08-09 实测): - -- 裁决当天 15:03Z(裁决后约 9 小时)**新立了座位贴 #6018**,该席至今在任、经历 - 一次移交、一个任期内落了 9 个 PR; -- **2026-08-07 维护者批准的 `spec` 拆分**(座位贴 #6298)在本文里写进了 - `spec-surface` ↔ `spec-tooling` 的分界判据 —— 即维护者本人在裁决次日签发的 - 文本,把它当作活车道在用; -- 标签仍在被分诊打:当前 **10 单 open**(#6833 / #6797 / #6751 / #6635 / #6350 / - #6232 / #6221 / #5828 / #5757 / #5163,其中 #6797、#6350 已 `pm:dispatched`), - 67 单 closed,最近一次新打在 2026-08-08。 - -⇒ 该标签是**在册车道,分诊照常打**。 - -**维护者 2026-08-09 裁决(#5469,取代上面 2026-08-06 那条):判据切分,给行。** -拖住补行的从来不是「它是否存在」,而是它与 `domain:devx` 的三处文件面重叠未裁 —— -裁完即补,包家族表的 `spec-tooling` 行就是该裁决的登记: - -- **无争议、可直接路由的两处**:`packages/spec/scripts/**`、`packages/spec/docs/**` - ⇒ `domain:spec-tooling`。devx 从未声明这两处,依据是 #6018 座位贴的 Scope 段与 - 上面 2026-08-07 的 surface / tooling 分界(tooling 改「围着契约转的机器」)。 -- **曾争议的三处**:`packages/lint`、`content/docs/**`、`scripts/` —— 座位贴 #6018 - 与 #6023 **同时声明**这三处。这不是纸面问题,2026-08-09 实测两侧都在落地: - `domain:spec-tooling` 的 #6778 整单落在 `packages/lint/src/`(PR #6831),而同期 - `domain:devx` 的 #5957 / #5330 / #6381 也落在 `packages/lint`。 -- ⇒ **按「是否围着 spec 契约转」逐卡判**:契约门禁/生成器/lint 规则/报错散文/ - references 管线 ⇒ `spec-tooling`;一般开发工具面 ⇒ `devx`。这是 anchoring rule - 在 `packages/spec` 内那条显式例外的**延伸,不是第二套规则** —— 2026-08-07 拆分 - 已写下判据的前半(「tooling 改围着契约转的机器」),本裁决把同一句话铺到这三处。 - 拿不准的按 rule 4 误标路径 FLAG 回分诊,⛔ 不由 dev 代拍。 +事实推翻的旧裁决**为什么不能照抄执行。沿革全文(两条旧裁决原文、推翻前提的 +2026-08-09 实测、判据切分的三处明细)已移 +`references/incidents.md` §「domain:spec-tooling 沿革」。 **`engine` 一分为二(#5472,与 #5095 同批)。** 旧 `domain:engine` 同时覆盖 objectql + metadata\* + platform-objects + core + formula + 全部 `driver-*`, @@ -1440,17 +1397,8 @@ an issue to a later round, record the known trap on it before the round ends. - **派发被延后那一单之前**,用这个回答**重读它的选项与成本估计**,⛔ 不沿用立单时 的那一份。 -本轮四种方向各出现过。**变便宜(且 issue 自己的成本估计同时过期)**:#5375(#5345) -去掉了「cube 风格数组也可作为输入」这条腿,`{member, operator, values}` 三元组自此纯属 -私有中间表示,#5373 的 B 路线因此从正文写的「工作量最大」降为不跨 spec 的内部改动。 -**没变**:#5431(#5373)对 #5374 —— dev 明确回报「**没有**让它变简单,也**没有**顺带 -修好它」,调用点现在收到真值而非字符串化的值,但「`{$not: 'x'}` 约束不了任何东西」在 -算子层,与比较数编码正交。 - -默认假设(「前一单大概让它变简单了」)本轮**错了两次、对了一次**,而两个方向的代价 -不对称:误以为变简单 → dev 按缩小的范围做,漏修;误以为没变 → 走一条已经没必要的贵 -路线。所以这不能由 PM 推,只能由在飞那单的 dev 答 —— 本轮正是该必答项的**否定**回答 -直接决定了 #5374 不能缩范围(见 PR #5445 的「范围之外」段)。适用判据:前后两单**共用 +一轮实测里四种方向各出现过、默认假设错了两次(实录见 `references/incidents.md` +§「重新定价四方向实录」)。适用判据:前后两单**共用 同一个契约或数据表示**;形态迥异的批次(纯 UI、纯文档)里前后单往往不共享成本面,这 一项问不出信息,不必强加。 @@ -1842,11 +1790,8 @@ dev 都用实测顶回并保住了裁决意图 —— 因为派发令把两类 一起重开 —— 两个方向都是返工。 **第三块是 2026-08-09 单班补的:前两块漏掉了最便宜的那一类 —— PM 顺口给的一个 -「看起来无害」的选项。** 同一班被证伪两次,两次 dev 拒绝都是对的:#6865 的卡自带 -一条「断言 job 上没有 `if:`」的验收写法,照做会把**四个正确的 job** 判红;#6893 的 -派发令把「把 `content/docs/releases/**` 排除出审计范围」写成「亦可辩护」的选项, -而那正是 #4920 明确否决的 option A —— `scripts/docs-audit/check-audit-scope.mjs` -在该目录**离开审计范围时直接 `process.exit(1)`**,脚本注释逐字点了 #4920 与 #6893。 +「看起来无害」的选项。** 同一班被证伪两次,两次 dev 拒绝都是对的(#6865 / +#6893,实录见 `references/incidents.md` §「便宜选项两次证伪」)。 ⇒ **把一个便宜选项写成已裁定,恰好招来相反的结果**:dev 要么照做产出一个红,要么 为了顶回来花掉一轮往返。裁决那一块只写真裁决,凡是「我觉得可以这样」的一律降到 第三块 —— 措辞的成本是零,读错的成本是一轮。 @@ -1965,8 +1910,10 @@ prompt: **过滤 / 谓词语义裁决:派发令枚举完整的编译面清单,PR 逐面申报 —— 派发令的标准 条款(#5930 裁决的流程半边)。** 适用判据:本单会**改变一条过滤 / 谓词语义**(算子 -的 NULL 处理、组合子恒等、比较数形状、算子词表……)。满足时派发令**把下面那张表逐面 -抄进去**,并带这一句(原话): +的 NULL 处理、组合子恒等、比较数形状、算子词表……)。满足时派发令**把编译面清单 +逐面抄进去** —— 清单连同其维护纪律与派发前复核串住 +`references/compile-surfaces.md`,派发前照它的复核串重验再抄,⛔ 不凭记忆 —— +并带这一句(原话): > 本单改的这条语义由**多个互相独立的编译器 / 求值器**各自实现。派发令列出的**每一 > 面**都必须在你的 PR 正文里有一个结论:**已改** / **本就合规**(给出证据)/ @@ -1974,44 +1921,10 @@ prompt: > 「漏掉的面」,不读作「不需要改」。 **这条防的不是「做错」,是「做对了一部分然后以为做完了」。** 一个 `FilterCondition` -语义由 **5 个互相独立的实现**承载(下表)⇒ 每条语义裁决的成本 ×5,而漏面**反复 -复发**,三次都留在代码注释里: - -- **#5146 → #5903**:裁决只落到面 1,面 2 是**不继承面 1 的独立编译器**,于是同一个 - 驱动的两种连接模式对同一条过滤给出两种答案。现场记录在 - `driver-turso/src/remote-transport.ts:1731`:「LOCAL mode inherits that fix - (`TursoDriver extends SqlDriver`), this independent compiler inherited none of - it」。 -- **#5326 / #5335**:面 3 与面 4 各**又花一圈**才对齐,记录在 - `spec/src/data/filter.zod.ts:370`。 -- **#5905**:#5298 的裁决由 PR #5962 落到 driver-sql / formula / service-analytics - 与 conformance 表,**唯独漏了 HAVING 面** —— `objectql/src/having-filter.ts:37` 的 - 原话是「was not in that PR's inventory, which left this file as the lone - holdout」。**「inventory」这个词本身就是本条款的缺席证明**:那次派发确实有一份清 - 单,只是它不完整,而没有任何机制要求它完整。 - -三次都不是难度问题,是**没有一份清单在问「还有几面」**。 - -编译面清单(逐面实测 @ `main` `48f98b0`,2026-08-07): - -| # | 面 | 落点(file:line) | 备注 | -| --- | --- | --- | --- | -| 1 | `driver-sql` | `packages/drivers/driver-sql/src/sql-driver.ts:7083`(`applyFilterCondition`) | `driver-sqlite-wasm`(`sqlite-wasm-driver.ts:67`)与 **local 模式**的 `driver-turso`(`turso-driver.ts:174`)都 `extends SqlDriver`,**靠继承共用这一面**,不单独算面 | -| 2 | turso RemoteTransport | `packages/drivers/driver-turso/src/remote-transport.ts:1526`(`private buildWhereSQL`) | **独立编译器,不继承面 1** —— 一个驱动的两面,由连接模式选中哪面 | -| 3 | service-analytics read-scope-sql | `packages/services/service-analytics/src/read-scope-sql.ts:259`(`compileScopedFilterToSql`) | RLS 读侧 | -| 4 | service-analytics filter-normalizer | `packages/services/service-analytics/src/strategies/filter-normalizer.ts:1235`(`lowerAnalyticsWhere`) | analytics / cube 侧 | -| 5 | `formula` | `packages/formula/src/matches-filter.ts:73`(`matchesFilterCondition`) | RLS 写侧 `check` 与公式求值;JS 两值语义的基准面 | -| 半面 | objectql `having-filter` | `packages/objectql/src/having-filter.ts:92` / `:98`(`applyHaving` / `matchesHaving`) | 聚合**后**过滤。算半面是因为词表是子集,**但申报义务不打折** —— 它是**唯一没有 conformance 表覆盖的面**(`FILTER_LOGIC_CASES` 不驱动 HAVING 路径),所以漏了它连门禁都不会红 | -| 冻结 | `driver-memory` / `driver-mongodb` | — | #5499 冻结投入:**pin-annotate,不翻转**。冻结面仍要申报,结论是「不在范围 + #5499」。现场注释见 `read-scope-sql.ts:176`、`having-filter.ts:41` | - -**这张表本身由 PR 维护 —— 与域表同一纪律。** 增删一面(新驱动、新求值器、某面被合并 -或退役、冻结状态变化)的那个 PR 顺手改这里,不留给下一次裁决重新数。清单**会**过期是 -必然的,清单**没有维护者**才是缺陷。 - -⚠️ 派发前复核一遍再抄,⛔ 不要凭这张表的记忆填派发令:本仓的包路径搬过家(驱动进 -`packages/drivers/`、服务进 `packages/services/`),行号更是每天在动。一条够用的复核 -串:`grep -rn 'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql' -packages --include=*.ts | grep -v node_modules`。 +语义由 **5 个互相独立的实现**承载(清单见 `references/compile-surfaces.md`)⇒ +每条语义裁决的成本 ×5,而漏面**反复复发**,三次都留在代码注释里(#5146→#5903、 +#5326/#5335、#5905 —— 三例叙述见 `references/incidents.md` +§「编译面清单三次漏面」)。三次都不是难度问题,是没有一份清单在问「还有几面」。 **Issue 正文是线索,不是规格 —— and the dispatch wording is what makes an honest "the premise is dead" cheap to return.** Step 1's stale-premise check @@ -2296,20 +2209,10 @@ message has already reported. 没有基线就先建基线再判:同形态卡片各记一个「派发 → 推分支 / 开 PR」的端到端 耗时,三五单即可用。**在基线之内的沉默不是证据。** - 两条实测基线**只是出处样例,⛔ 不是全车队常数** —— 卡片形态不同,区间没有理由 - 相同,driver 或 engine-core 的重活不适用下表: - - | 出处(车道 / 日期) | 卡片形态 | 实测端到端 | - |---|---|---| - | `domain:spec-surface` 席,2026-08-07(#6393) | 文本面卡:#5767 / #5622 / #5955 / #5783 | 93 / 96 / ~95 / ~110 分钟 | - | `domain:devx` 席,2026-08-07(#6393 认领评论) | 混合:#6251 / #6038 / #6405 / #6359 | ~67 / ~64 / ~160 / ~170 分钟(后两单含长 CI 等待) | - - 合两席九单:同一天、同一套工具下,端到端跨越 **~64 分钟到近 3 小时**。凡把单一 - 数字当判死线的读法,都会在这个跨度里翻车 —— 所以要建的是**你那一栏**的基线。 -- 两个座位当天各误判一次,都栽在这条线上:`domain:spec-surface` 席在 92 分钟处 - 写下「#5783 将判为不可靠」,而它在基线之内、几分钟后就推了分支;`domain:devx` - 席在派发 2 小时处判两个 dev「静默结束」并把「重新派发」写进了下一枪定点,而两个 - 都在做深度取证。后者靠**先 SendMessage 问状态、而不是直接重派**救回 —— 那正是 - 上面五条的第一条。 + 相同,driver 或 engine-core 的重活不适用它们。样例表(两席九单,端到端 + ~64 分钟到近 3 小时)与两个座位当天各一次的误判实录已移 + `references/incidents.md` §「判死基线样例与两次误判」。 + - 与既有两个数字的关系,一句话讲清:**45 分钟 = 探活门槛**(去问);**`mode:cloud` 的 ~2h 静默 = 本轮收集边界**(记 `blocked`、本轮不再等,下一轮从 GitHub 重收); ⛔ 两者都不是判死。下面「报告丢失 ≠ 验收停摆」把 ≥2h 与**探活确认已死**并列成 @@ -2617,13 +2520,9 @@ grep os-regen .gitattributes # 唯一权威清单;⛔ 别把结果抄进派发 ⛔ **别只记住 `packages/spec/` 那几条** —— 清单里同时有**文档产物** (`docs/` 与 `content/docs/references/**`),它们同样会被静默吞。 -**这份清单不能有第二份拷贝 —— 本节曾亲自示范为什么(#6492)。** 协议此处一度 -内嵌一份路径拷贝,于是同一件事有了三个互相矛盾的读数:散文说「八条」、紧随其下的 -代码块列**九**条、`.gitattributes` 实际路由**十**条(缺的是 -`packages/spec/authorable-defaults/**`)。更要命的是漂移**还在加速**:#6492 分诊 -两次测量之间(同一天,相隔约一小时)清单本身又动过,两次读数就不一样。一份「读起来 -完整、实际不完整」的清单比没有清单更贵 —— 派发令照它枚举,dev 拿到的是一张自称 -齐全的漏项检查表,而 os-regen 的失败是**静默**的(见下)。所以本节只留取数命令: +**这份清单不能有第二份拷贝 —— 本节曾亲自示范为什么(#6492:三个互相矛盾的读数、 +一小时内自漂移;实录见 `references/incidents.md` §「os-regen 清单第二份拷贝」)。** +所以本节只留取数命令: 散文没法被类型检查,唯一不会烂的拷贝是不存在的那份。同源条款见 step 5 的编译面 清单(#5905)与 `Record` 反烂模式(#6322)。 diff --git a/.claude/skills/pm-dispatch/references/compile-surfaces.md b/.claude/skills/pm-dispatch/references/compile-surfaces.md new file mode 100644 index 0000000000..01d08bd0a5 --- /dev/null +++ b/.claude/skills/pm-dispatch/references/compile-surfaces.md @@ -0,0 +1,27 @@ +# 过滤 / 谓词语义的编译面清单(references —— 按需加载) + +出处:主文件 step 5「过滤 / 谓词语义裁决」标准条款 —— 命中该条款的派发令把本清单 +**逐面抄进去**,PR 逐面申报。清单正文自 SKILL.md 纯搬移(#5925 item 7),维护纪律 +与派发前复核串在正文内,一并适用:**这张表由 PR 维护、派发前先跑复核串重验,⛔ 不凭 +记忆抄**。 + +编译面清单(逐面实测 @ `main` `48f98b0`,2026-08-07): + +| # | 面 | 落点(file:line) | 备注 | +| --- | --- | --- | --- | +| 1 | `driver-sql` | `packages/drivers/driver-sql/src/sql-driver.ts:7083`(`applyFilterCondition`) | `driver-sqlite-wasm`(`sqlite-wasm-driver.ts:67`)与 **local 模式**的 `driver-turso`(`turso-driver.ts:174`)都 `extends SqlDriver`,**靠继承共用这一面**,不单独算面 | +| 2 | turso RemoteTransport | `packages/drivers/driver-turso/src/remote-transport.ts:1526`(`private buildWhereSQL`) | **独立编译器,不继承面 1** —— 一个驱动的两面,由连接模式选中哪面 | +| 3 | service-analytics read-scope-sql | `packages/services/service-analytics/src/read-scope-sql.ts:259`(`compileScopedFilterToSql`) | RLS 读侧 | +| 4 | service-analytics filter-normalizer | `packages/services/service-analytics/src/strategies/filter-normalizer.ts:1235`(`lowerAnalyticsWhere`) | analytics / cube 侧 | +| 5 | `formula` | `packages/formula/src/matches-filter.ts:73`(`matchesFilterCondition`) | RLS 写侧 `check` 与公式求值;JS 两值语义的基准面 | +| 半面 | objectql `having-filter` | `packages/objectql/src/having-filter.ts:92` / `:98`(`applyHaving` / `matchesHaving`) | 聚合**后**过滤。算半面是因为词表是子集,**但申报义务不打折** —— 它是**唯一没有 conformance 表覆盖的面**(`FILTER_LOGIC_CASES` 不驱动 HAVING 路径),所以漏了它连门禁都不会红 | +| 冻结 | `driver-memory` / `driver-mongodb` | — | #5499 冻结投入:**pin-annotate,不翻转**。冻结面仍要申报,结论是「不在范围 + #5499」。现场注释见 `read-scope-sql.ts:176`、`having-filter.ts:41` | + +**这张表本身由 PR 维护 —— 与域表同一纪律。** 增删一面(新驱动、新求值器、某面被合并 +或退役、冻结状态变化)的那个 PR 顺手改这里,不留给下一次裁决重新数。清单**会**过期是 +必然的,清单**没有维护者**才是缺陷。 + +⚠️ 派发前复核一遍再抄,⛔ 不要凭这张表的记忆填派发令:本仓的包路径搬过家(驱动进 +`packages/drivers/`、服务进 `packages/services/`),行号更是每天在动。一条够用的复核 +串:`grep -rn 'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql' +packages --include=*.ts | grep -v node_modules`。 diff --git a/.claude/skills/pm-dispatch/references/incidents.md b/.claude/skills/pm-dispatch/references/incidents.md new file mode 100644 index 0000000000..6aa4aa1944 --- /dev/null +++ b/.claude/skills/pm-dispatch/references/incidents.md @@ -0,0 +1,140 @@ +# pm-dispatch 案例实录(references —— 按需加载) + +⚠️ **APPEND-ONLY。** 本文件是 `.claude/skills/pm-dispatch/SKILL.md` 的案例叙述 +外置层:主文件留「规则 + 一行锚点 + 指针」,长叙述住这里(#5925 item 7,维护者 +批准的纯搬移纪律)。三条读写规则: + +- **只在尾部追加,不改写存量** —— 与 Operational notes 的追加纪律同款。新案例 + 默认落这里,不再灌回主文件;主文件增长率因此归零。 +- 每节标题即主文件指针的锚文本(§「…」逐字对应);「出处」行指回主文件的规则段。 +- 搬入的正文**逐字节保留**原文(纯搬移零改写);行文里的相对指代(「上面」「见下」 + 等)仍指其在主文件里的原位置,出处行会注明。 + +## 定点文本两例实录 + +出处:Operational notes 3「定点文本的写法纪律」—— 两种形态、同一个后果的两例。 + +- `domain:spec-surface` 席**两枪已 `delete_trigger`**(回包确认 `deleted trigger …`) + 的定时器**照样投递**,文本都落后现实两轮。其中一枪写着「#5783 …… 判为不可靠、 + 交接、**重新派发一个 fresh os-dev**,worktree `objectstack-issue-5783` 已存在」; + 投递时 #5783 的 PR #6389 早已交付并通过复核 —— 照文本执行就是把一个重复 agent + 塞进一个活着且已完工的 worktree,正是认领协议要防的碰撞类,只不过这次是**从 + 自动化里**来的,而不是从抢跑的 PM 那里。 +- `domain:devx` 席的一枪**没被删,是被现实追上**:21:3x 挂、22:1x 投递,文本写的 + 「两个 dev 静默结束、未开 PR、未交报告 ⇒ 判定失效 ⇒ 重新派发一个新 dev」在投递 + 时前提已被推翻(两个 dev 都已回话正常推进,其中一个的 PR 已合并)。照做会向两个 + 活着且已有成果的任务各塞一个重复 dev。删与没删是两条路径,终点是同一个。 + +## domain:spec-tooling 沿革 + +出处:「Domain lanes」的 `domain:spec-tooling` 节 —— 生效规则在主文件包家族表,此处为沿革全文。 + +维护者 2026-08-06 裁决(#5469,原文引用、未翻译): + +> `domain:spec-tooling` 判为 **#5163 存续期的临时 program 车道**,不进 SKILL 包家族 +> 域表;其存量单由分诊按现行域表重标,重标完成后该标签退役 + +⚠️ **该裁决所依据的前提(「临时、待退役」)在其后三天被反向的事实推翻,所以本节 +记录的是现状,不是那条裁决的执行结果**(2026-08-09 实测): + +- 裁决当天 15:03Z(裁决后约 9 小时)**新立了座位贴 #6018**,该席至今在任、经历 + 一次移交、一个任期内落了 9 个 PR; +- **2026-08-07 维护者批准的 `spec` 拆分**(座位贴 #6298)在本文里写进了 + `spec-surface` ↔ `spec-tooling` 的分界判据 —— 即维护者本人在裁决次日签发的 + 文本,把它当作活车道在用; +- 标签仍在被分诊打:当前 **10 单 open**(#6833 / #6797 / #6751 / #6635 / #6350 / + #6232 / #6221 / #5828 / #5757 / #5163,其中 #6797、#6350 已 `pm:dispatched`), + 67 单 closed,最近一次新打在 2026-08-08。 + +⇒ 该标签是**在册车道,分诊照常打**。 + +**维护者 2026-08-09 裁决(#5469,取代上面 2026-08-06 那条):判据切分,给行。** +拖住补行的从来不是「它是否存在」,而是它与 `domain:devx` 的三处文件面重叠未裁 —— +裁完即补,包家族表的 `spec-tooling` 行就是该裁决的登记: + +- **无争议、可直接路由的两处**:`packages/spec/scripts/**`、`packages/spec/docs/**` + ⇒ `domain:spec-tooling`。devx 从未声明这两处,依据是 #6018 座位贴的 Scope 段与 + 上面 2026-08-07 的 surface / tooling 分界(tooling 改「围着契约转的机器」)。 +- **曾争议的三处**:`packages/lint`、`content/docs/**`、`scripts/` —— 座位贴 #6018 + 与 #6023 **同时声明**这三处。这不是纸面问题,2026-08-09 实测两侧都在落地: + `domain:spec-tooling` 的 #6778 整单落在 `packages/lint/src/`(PR #6831),而同期 + `domain:devx` 的 #5957 / #5330 / #6381 也落在 `packages/lint`。 +- ⇒ **按「是否围着 spec 契约转」逐卡判**:契约门禁/生成器/lint 规则/报错散文/ + references 管线 ⇒ `spec-tooling`;一般开发工具面 ⇒ `devx`。这是 anchoring rule + 在 `packages/spec` 内那条显式例外的**延伸,不是第二套规则** —— 2026-08-07 拆分 + 已写下判据的前半(「tooling 改围着契约转的机器」),本裁决把同一句话铺到这三处。 + 拿不准的按 rule 4 误标路径 FLAG 回分诊,⛔ 不由 dev 代拍。 + +## 重新定价四方向实录 + +出处:step 3「阻塞解除后要给延后的那一单重新定价」—— 一轮之内四种方向各出现过。 + +本轮四种方向各出现过。**变便宜(且 issue 自己的成本估计同时过期)**:#5375(#5345) +去掉了「cube 风格数组也可作为输入」这条腿,`{member, operator, values}` 三元组自此纯属 +私有中间表示,#5373 的 B 路线因此从正文写的「工作量最大」降为不跨 spec 的内部改动。 +**没变**:#5431(#5373)对 #5374 —— dev 明确回报「**没有**让它变简单,也**没有**顺带 +修好它」,调用点现在收到真值而非字符串化的值,但「`{$not: 'x'}` 约束不了任何东西」在 +算子层,与比较数编码正交。 + +默认假设(「前一单大概让它变简单了」)本轮**错了两次、对了一次**,而两个方向的代价 +不对称:误以为变简单 → dev 按缩小的范围做,漏修;误以为没变 → 走一条已经没必要的贵 +路线。所以这不能由 PM 推,只能由在飞那单的 dev 答 —— 本轮正是该必答项的**否定**回答 +直接决定了 #5374 不能缩范围(见 PR #5445 的「范围之外」段)。 + +## 编译面清单三次漏面 + +出处:step 5「过滤 / 谓词语义裁决」标准条款 —— 三次漏面,全部留在代码注释里。 + +- **#5146 → #5903**:裁决只落到面 1,面 2 是**不继承面 1 的独立编译器**,于是同一个 + 驱动的两种连接模式对同一条过滤给出两种答案。现场记录在 + `driver-turso/src/remote-transport.ts:1731`:「LOCAL mode inherits that fix + (`TursoDriver extends SqlDriver`), this independent compiler inherited none of + it」。 +- **#5326 / #5335**:面 3 与面 4 各**又花一圈**才对齐,记录在 + `spec/src/data/filter.zod.ts:370`。 +- **#5905**:#5298 的裁决由 PR #5962 落到 driver-sql / formula / service-analytics + 与 conformance 表,**唯独漏了 HAVING 面** —— `objectql/src/having-filter.ts:37` 的 + 原话是「was not in that PR's inventory, which left this file as the lone + holdout」。**「inventory」这个词本身就是本条款的缺席证明**:那次派发确实有一份清 + 单,只是它不完整,而没有任何机制要求它完整。 + +三次都不是难度问题,是**没有一份清单在问「还有几面」**。 + +## 便宜选项两次证伪 + +出处:step 5 派发令三分区的「第三块」—— 同一班两次把便宜选项写成已裁定,两次被 dev 证伪。 + +#6865 的卡自带 +一条「断言 job 上没有 `if:`」的验收写法,照做会把**四个正确的 job** 判红;#6893 的 +派发令把「把 `content/docs/releases/**` 排除出审计范围」写成「亦可辩护」的选项, +而那正是 #4920 明确否决的 option A —— `scripts/docs-audit/check-audit-scope.mjs` +在该目录**离开审计范围时直接 `process.exit(1)`**,脚本注释逐字点了 #4920 与 #6893。 + +## os-regen 清单第二份拷贝 + +出处:「入队与落地 A」—— #6492 的三读数矛盾实录;文中「(见下)」指主文件 A 节的四步序。 + +协议此处一度 +内嵌一份路径拷贝,于是同一件事有了三个互相矛盾的读数:散文说「八条」、紧随其下的 +代码块列**九**条、`.gitattributes` 实际路由**十**条(缺的是 +`packages/spec/authorable-defaults/**`)。更要命的是漂移**还在加速**:#6492 分诊 +两次测量之间(同一天,相隔约一小时)清单本身又动过,两次读数就不一样。一份「读起来 +完整、实际不完整」的清单比没有清单更贵 —— 派发令照它枚举,dev 拿到的是一张自称 +齐全的漏项检查表,而 os-regen 的失败是**静默**的(见下)。 + +## 判死基线样例与两次误判 + +出处:step 6「45 分钟是发探针的门槛,⛔ 不是判死的门槛」—— 样例基线表与两次误判;文中「上面五条」指主文件探活规程五条。 + + | 出处(车道 / 日期) | 卡片形态 | 实测端到端 | + |---|---|---| + | `domain:spec-surface` 席,2026-08-07(#6393) | 文本面卡:#5767 / #5622 / #5955 / #5783 | 93 / 96 / ~95 / ~110 分钟 | + | `domain:devx` 席,2026-08-07(#6393 认领评论) | 混合:#6251 / #6038 / #6405 / #6359 | ~67 / ~64 / ~160 / ~170 分钟(后两单含长 CI 等待) | + + 合两席九单:同一天、同一套工具下,端到端跨越 **~64 分钟到近 3 小时**。凡把单一 + 数字当判死线的读法,都会在这个跨度里翻车 —— 所以要建的是**你那一栏**的基线。 +- 两个座位当天各误判一次,都栽在这条线上:`domain:spec-surface` 席在 92 分钟处 + 写下「#5783 将判为不可靠」,而它在基线之内、几分钟后就推了分支;`domain:devx` + 席在派发 2 小时处判两个 dev「静默结束」并把「重新派发」写进了下一枪定点,而两个 + 都在做深度取证。后者靠**先 SendMessage 问状态、而不是直接重派**救回 —— 那正是 + 上面五条的第一条。 From ea495be0112809999088052acdb46636d6b2d288 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:13:01 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(scripts):=20pm=20patrol=20tooling=20?= =?UTF-8?q?=E2=80=94=20half-state=20sweeper,=20dispatch-gates=20deriver,?= =?UTF-8?q?=20SKILL=20line=20ratchet=20(#7341=20PR=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three zero-dependency scripts under scripts/pm/, each with --self-test: - check-half-states.mjs: report-only enumeration of the label/assignee half-states the dispatch protocol forbids (pm:dispatched without assignee, assignee without claim comment, pm:queue+pm:dispatched, pm:blocked without Blocked-by:, seat-sticker title/assignee desync). Deliberately not a gate: a completed sweep exits 0 regardless of findings; only an unrunnable sweep is non-zero. - dispatch-gates.mjs: maps a card's file surface to the check:* families that watch it, derived at runtime from .github/workflows/*.yml + package.json + the check scripts' own path literals — no hardcoded list (the #6492/#6865 lesson). - check-skill-line-ratchet.mjs: shrink-only ceiling (3050) on .claude/skills/pm-dispatch/SKILL.md, wired into lint.yml as check:pm-skill-ratchet; lower freely, raise only with a maintainer ruling quoted in the raising PR. SKILL.md gains two one-line pointers: the standby-patrol section names the half-state sweeper; step 5's gate-sourcing block names dispatch-gates. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QrT21cGQfFVDWtVJysRtXo --- .claude/skills/pm-dispatch/SKILL.md | 7 +- .github/workflows/lint.yml | 11 + package.json | 1 + scripts/pm/check-half-states.mjs | 251 ++++++++++++++++++++++ scripts/pm/check-skill-line-ratchet.mjs | 104 ++++++++++ scripts/pm/dispatch-gates.mjs | 263 ++++++++++++++++++++++++ 6 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 scripts/pm/check-half-states.mjs create mode 100644 scripts/pm/check-skill-line-ratchet.mjs create mode 100644 scripts/pm/dispatch-gates.mjs diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 3e3f0dfd2d..fc14eb287e 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -1727,7 +1727,8 @@ an issue comment with the os-dev-report marker, then as your final message. 里)。所以派发流程里写**取数命令**,不写清单本身: ```bash -grep -rn 'pnpm.*check:' .github/workflows/*.yml # 门禁清单当场取数 +grep -rn 'pnpm.*check:' .github/workflows/*.yml # 门禁清单当场取数 +node scripts/pm/dispatch-gates.mjs <改动路径> [<路径>…] # 文件面 → 该跑的门禁族,现场推导可贴 ``` ⛔ **取数的是 PM,不是 dev** —— 产出是「本卡该跑的**那几族**」,填进模板那一行。 @@ -2905,6 +2906,10 @@ Stop the loop and report when any of these hits: 4. 决策箱提醒 —— 仅在轮次报告中列出待决清单,⛔ 不 nag 维护者; 5. 跨车道备忘跟进(转席单、`Blocked-by:` 链的对侧动静)。 +半状态巡查的机械辅助:`node scripts/pm/check-half-states.mjs` —— report-only 枚举 +label/assignee 半状态(H1–H5,清单在脚本头),任何座位手动跑;⛔ 不是门禁,产出是 +巡查输入。 + 退场只有两个入口:维护者的交接令(走座位贴协议的交接收尾清单),或座位被 惰性回收。 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index eb3a9b239e..53a684b900 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -150,6 +150,17 @@ jobs: - name: Stack-collection enumerations answerable to stack.zod.ts run: pnpm check:stack-collection-maps + # pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill + # is read in full by every seat session and every Routine fire, so its + # size is a per-round tax on the whole fleet. The #7341 extraction moved + # long incident narratives to .claude/skills/pm-dispatch/references/ + # (append-only case law, loaded on demand); this shrink-only ceiling + # keeps them from growing back. Ceiling discipline — lower freely, raise + # only with a maintainer ruling quoted in the raising PR — is stated in + # the script's header, which is authoritative. + - name: PM skill line ratchet + run: pnpm check:pm-skill-ratchet + # Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in # Markdown/MDX are not type-checked or ESLinted, so skills/ and # content/docs/ can drift back to teaching the bare `: Page = {}` literal diff --git a/package.json b/package.json index f90b0eb1a0..cede8bfd0f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs", "check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs", "check:agent-model-declared": "node scripts/check-agent-model-declared.mjs --self-test && node scripts/check-agent-model-declared.mjs", + "check:pm-skill-ratchet": "node scripts/pm/check-skill-line-ratchet.mjs --self-test && node scripts/pm/check-skill-line-ratchet.mjs", "check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs", "check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs", "check:adr-merge-approval": "node scripts/check-adr-merge-approval.mjs --self-test && node scripts/check-adr-merge-approval.mjs", diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs new file mode 100644 index 0000000000..b1fb20c2b8 --- /dev/null +++ b/scripts/pm/check-half-states.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PM half-state sweeper (#7341 item 2) — REPORT-ONLY enumeration of the + * label/assignee invariants the dispatch protocol calls "过夜半状态". + * + * node scripts/pm/check-half-states.mjs # sweep the live repo + * node scripts/pm/check-half-states.mjs --self-test # verify the predicates offline + * + * ## Why report-only, and why the exit code is ALWAYS 0 on a completed sweep + * + * The pm-dispatch state model (.claude/skills/pm-dispatch/SKILL.md, "State + * model") says the labels ARE the state machine, and its label discipline says + * 「状态变更不过夜」: a label applied without its paired signal is a state no + * sweep can interpret. Those half-states occur in practice — a card carried + * `pm:queue` AND `pm:dispatched` simultaneously for ~14 hours (#5925's + * 2026-08-09 correction comment); another sat dispatched with an assignee and + * no claim for 48h+ (the #5925 stale-claim reclaim) — and today finding them + * is a manual read of every card. This script is the mechanical enumerator. + * + * It is deliberately NOT a gate: a half-state is a fact about a live, shared + * board, not about the PR that happens to run CI next — failing an unrelated + * PR over board state would punish the wrong actor (the same reasoning that + * keeps `check:platform-checklist` out of CI, lint.yml's own note). So a + * completed sweep exits 0 whether it found 0 or 40 violations; the findings + * are the output, and the consumer is a PM seat's patrol round (the standby + * posture in SKILL.md documents the invocation). Only a sweep that could not + * run (network, auth, bad usage) exits non-zero — per #4690, "could not read + * the input" must never look like "input is clean". + * + * ## The invariants (each names its protocol source) + * + * H1 `pm:dispatched` with no assignee — dispatch marks a claim; a claim is + * assign + claim comment (state model / step 4). + * H2 assignee set on a pm-tracked card, but no claim comment on the thread + * (a comment whose body carries a "Claim:" line) — the assignee field + * alone cannot say WHICH session owns it (step 4; #4588). + * H3 `pm:queue` + `pm:dispatched` both present — reads as available to the + * queue view and in-flight to the lane view; neither is trustworthy + * (#5925 2026-08-09 correction, the measured specimen). + * H4 `pm:blocked` without a `Blocked-by:` body line — the machine half of + * the label is the body line; without it the unlock sweep can never + * return the card (state model, label discipline). + * H5 `pm:seat` sticker whose title/assignee pair is out of sync — the + * seat-sticker protocol makes 标题、assignee、正文 a same-write triple: + * a title claiming 🟢 must have that login as assignee; a title + * claiming ⏳ vacant must have none. (Routine seats declare 🟢 Routine + * and are exempt from the assignee half — bots can't be assigned.) + * + * The body half of H5 (the 「当前 PM」 paragraph) is NOT machine-checked here: + * seat-sticker bodies are prose with no pinned grammar, and a fuzzy parser + * would report phantom desyncs — the #4690 shape in mirror image. The + * title/assignee pair is the mechanical half; the sweep prints the sticker + * URL so the patrol reads the body itself. + * + * Auth: uses GITHUB_TOKEN / GH_TOKEN when present (unauthenticated works at + * 60 req/h — enough for a small board, not for comment-fetching sweeps). + * REST only, never GraphQL (Operational notes 3: the loop's hot path stays on + * the core quota). + */ + +import process from 'node:process'; + +const OWNER_REPO = process.env.PM_SWEEP_REPO ?? 'objectstack-ai/objectstack'; +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; + +// --------------------------------------------------------------------------- +// Predicates — pure functions over the REST issue shape, so the self-test can +// drive them with fixtures and the live sweep stays a thin fetch loop. +// --------------------------------------------------------------------------- + +export function labelNames(issue) { + return (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)); +} + +export function h1DispatchedNoAssignee(issue) { + const labels = labelNames(issue); + return labels.includes('pm:dispatched') && (issue.assignees ?? []).length === 0; +} + +export function h2AssigneeNoClaimComment(issue, commentBodies) { + const labels = labelNames(issue); + const pmTracked = labels.some((l) => l === 'pm:queue' || l === 'pm:dispatched'); + if (!pmTracked || (issue.assignees ?? []).length === 0) return false; + return !commentBodies.some((b) => /^\s*Claim(?:ed)?\s*[::]/mi.test(b ?? '')); +} + +export function h3QueueAndDispatched(issue) { + const labels = labelNames(issue); + return labels.includes('pm:queue') && labels.includes('pm:dispatched'); +} + +export function h4BlockedNoBlockedBy(issue) { + const labels = labelNames(issue); + if (!labels.includes('pm:blocked')) return false; + return !/^\s*Blocked-by:\s*\S/m.test(issue.body ?? ''); +} + +// H5 returns null (in sync), a string naming the desync, or undefined when the +// title doesn't parse as a seat sticker (reported as its own finding — an +// unparseable status board row is a desync of the board itself). +export function h5SeatStickerDesync(issue) { + const m = /^\[PM seat\]\s*(.*?)\s*—\s*(.*)$/u.exec(issue.title ?? ''); + if (!m) return 'title does not match 「[PM seat] 」'; + const status = m[2].trim(); + const assignees = (issue.assignees ?? []).map((a) => a.login); + if (status.startsWith('🟢')) { + const holder = status.replace('🟢', '').trim(); + if (holder === 'Routine') return null; // Routine seats keep assignee empty by design + if (!assignees.includes(holder)) { + return `title says 🟢 ${holder} but assignees are [${assignees.join(', ') || 'none'}]`; + } + return null; + } + if (status.startsWith('⏳')) { + return assignees.length > 0 + ? `title says ⏳ vacant but assignees are [${assignees.join(', ')}]` + : null; + } + if (status.startsWith('⏸️') || status.startsWith('⏸')) return null; // paused: assignee state is the maintainer's call + return `unrecognized status word 「${status}」`; +} + +// --------------------------------------------------------------------------- +// Live sweep +// --------------------------------------------------------------------------- + +async function rest(path) { + const res = await fetch(`${API}${path}`, { + headers: { + accept: 'application/vnd.github+json', + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), + }, + }); + if (!res.ok) throw new Error(`GET ${path} -> HTTP ${res.status}`); + return res.json(); +} + +async function listIssues(label) { + const out = []; + for (let page = 1; page <= 10; page++) { + const batch = await rest( + `/repos/${OWNER_REPO}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100&page=${page}`, + ); + out.push(...batch.filter((i) => !i.pull_request)); + if (batch.length < 100) break; + } + return out; +} + +async function sweep() { + const findings = []; + const seen = new Map(); + for (const label of ['pm:dispatched', 'pm:queue', 'pm:blocked', 'pm:seat']) { + for (const issue of await listIssues(label)) seen.set(issue.number, issue); + } + + for (const issue of seen.values()) { + const labels = labelNames(issue); + if (h1DispatchedNoAssignee(issue)) { + findings.push([issue, 'H1', '`pm:dispatched` with no assignee']); + } + if (h3QueueAndDispatched(issue)) { + findings.push([issue, 'H3', '`pm:queue` and `pm:dispatched` both present']); + } + if (h4BlockedNoBlockedBy(issue)) { + findings.push([issue, 'H4', '`pm:blocked` without a `Blocked-by:` body line']); + } + if (labels.includes('pm:seat')) { + const desync = h5SeatStickerDesync(issue); + if (desync) findings.push([issue, 'H5', desync]); + } else if ((issue.assignees ?? []).length > 0 && labels.some((l) => l.startsWith('pm:'))) { + // H2 needs the comment thread — fetched only for candidates, and only + // their first pages: a claim comment is posted at claim time, so on a + // healthy card it is early in the thread; a >100-comment card with a + // late claim shows up as a finding the patrol then reads by hand. + const comments = await rest(`/repos/${OWNER_REPO}/issues/${issue.number}/comments?per_page=100`); + if (h2AssigneeNoClaimComment(issue, comments.map((c) => c.body))) { + findings.push([issue, 'H2', 'assignee set but no claim comment on the thread']); + } + } + } + + findings.sort((a, b) => a[0].number - b[0].number); + for (const [issue, code, msg] of findings) { + console.log(` ${code} #${issue.number} ${msg}\n ${issue.html_url}`); + } + console.log( + `check-half-states: swept ${seen.size} open pm-labeled issue(s) in ${OWNER_REPO} — ` + + `${findings.length} half-state(s) found. Report-only: findings are patrol input, not a gate verdict.`, + ); +} + +// --------------------------------------------------------------------------- +// Self-test — predicates only; no network. +// --------------------------------------------------------------------------- + +function selfTest() { + const cases = []; + const t = (name, actual, expected) => cases.push([name, actual, expected]); + const issue = (labels, assignees = [], body = '', title = '') => ({ + labels: labels.map((name) => ({ name })), + assignees: assignees.map((login) => ({ login })), + body, + title, + }); + + t('H1: dispatched + no assignee -> finding', h1DispatchedNoAssignee(issue(['pm:dispatched'])), true); + t('H1: dispatched + assignee -> clean', h1DispatchedNoAssignee(issue(['pm:dispatched'], ['os-help'])), false); + t('H2: assignee + no claim comment -> finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['looks good', 'triage: routed']), true); + t('H2: assignee + claim comment -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Claim: PM loop round 3\nSession: session_x']), false); + t('H2: unassigned card is out of scope', h2AssigneeNoClaimComment(issue(['pm:queue']), []), false); + t('H3: both queue labels -> finding', h3QueueAndDispatched(issue(['pm:queue', 'pm:dispatched'])), true); + t('H3: dispatched alone -> clean', h3QueueAndDispatched(issue(['pm:dispatched'])), false); + t('H4: blocked without body line -> finding', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')), true); + t('H4: blocked with Blocked-by line -> clean', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'Blocked-by: #123')), false); + t('H4: unblocked card is out of scope', h4BlockedNoBlockedBy(issue([], [], '')), false); + t('H5: 🟢 login matching assignee -> clean', h5SeatStickerDesync(issue(['pm:seat'], ['os-zhuang'], '', '[PM seat] domain:devx — 🟢 os-zhuang')), null); + t('H5: 🟢 login without assignee -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:devx — 🟢 os-zhuang')), 'string'); + t('H5: ⏳ vacant with assignee -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], ['os-help'], '', '[PM seat] domain:cli — ⏳ vacant')), 'string'); + t('H5: ⏳ vacant clean', h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:cli — ⏳ vacant')), null); + t('H5: Routine seat needs no assignee', h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] 分诊 — 🟢 Routine')), null); + t('H5: unparseable title -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', 'devx seat registry')), 'string'); + + let failed = 0; + for (const [name, actual, expected] of cases) { + const ok = actual === expected; + if (!ok) failed++; + console.log(` ${ok ? '✓' : '✗'} ${name}${ok ? '' : ` (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`}`); + } + if (failed) { + console.error(`✗ check-half-states self-test: ${failed} of ${cases.length} case(s) failed.`); + process.exit(1); + } + console.log(`✓ check-half-states self-test: ${cases.length} cases pass.`); +} + +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); +if (isMain) { + if (process.argv.includes('--self-test')) { + selfTest(); + } else { + sweep().catch((err) => { + // A sweep that could not run must not read as a clean board (#4690). + console.error(`check-half-states: sweep failed to run — ${err.message}`); + process.exit(2); + }); + } +} diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs new file mode 100644 index 0000000000..3549cca568 --- /dev/null +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * pm-dispatch SKILL.md line ratchet (#7341 item 1, #5925 item 7) — a + * shrink-only ceiling on the hot file every PM seat loads every round. + * + * node scripts/pm/check-skill-line-ratchet.mjs # the gate + * node scripts/pm/check-skill-line-ratchet.mjs --self-test # verify the checker + * + * ## Why a ceiling + * + * `.claude/skills/pm-dispatch/SKILL.md` is read in full by every seat session + * and every Routine fire. It reached 3,013 lines (~235 KB) before #7341's + * extraction moved the long incident narratives into + * `.claude/skills/pm-dispatch/references/` (loaded on demand), landing the + * main file at 2,997 lines. #5925 item 7's approved intent is that the main + * file's growth rate goes to ZERO: new rules stay in main, new case law lands + * in `references/incidents.md` (append-only). Without a gate that intent + * erodes one well-meaning paragraph at a time — the same way the file got to + * 3,000 in the first place. + * + * ## The ratchet discipline (shrink-only) + * + * - MAX_LINES may be LOWERED by any PR that shrinks the file — lowering is + * always legitimate and encouraged. + * - RAISING it requires a maintainer ruling quoted in the raising PR's body + * (the same evidence bar as Guardrails' `.claude/` tooling exception). + * A protocol change that would cross the ceiling pays its way by moving + * narrative out (references/) instead of raising the roof. + * - The headroom between the current count and the ceiling is the budget + * for ordinary rule edits between extractions; it is deliberately small. + * + * Missing file or empty read is RED, never a pass (#4690: a gate that cannot + * find its input must fail, not skip). + */ + +import { readFileSync } from 'node:fs'; +import process from 'node:process'; + +const SKILL_PATH = new URL('../../.claude/skills/pm-dispatch/SKILL.md', import.meta.url); + +// Post-#7341-extraction count: 2,997. Headroom ≈ 50 lines for rule edits +// between extractions. Shrink-only: lower freely, raise only with a maintainer +// ruling quoted in the raising PR (see header). +export const MAX_LINES = 3050; + +export function verdict(lineCount, maxLines) { + if (lineCount === 0) return { ok: false, msg: 'SKILL.md read as empty — refusing to treat a missing/empty input as a pass (#4690).' }; + if (lineCount > maxLines) { + return { + ok: false, + msg: + `SKILL.md is ${lineCount} lines; the ratchet ceiling is ${maxLines}. ` + + 'Move narrative to .claude/skills/pm-dispatch/references/ (incidents.md is append-only case law) ' + + 'instead of growing the hot file — raising the ceiling requires a maintainer ruling quoted in the PR.', + }; + } + return { ok: true, msg: `SKILL.md is ${lineCount} lines (ceiling ${maxLines}; headroom ${maxLines - lineCount}).` }; +} + +function run() { + let text; + try { + text = readFileSync(SKILL_PATH, 'utf8'); + } catch { + console.error('✗ check-skill-line-ratchet: cannot read .claude/skills/pm-dispatch/SKILL.md — red, not a skip (#4690).'); + process.exit(1); + } + const lines = text.length === 0 ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0); + const v = verdict(lines, MAX_LINES); + if (!v.ok) { + console.error(`✗ check-skill-line-ratchet: ${v.msg}`); + process.exit(1); + } + if (MAX_LINES - lines > 120) { + console.log(`ℹ️ headroom is ${MAX_LINES - lines} lines — consider lowering MAX_LINES (shrink-only ratchets tighten opportunistically).`); + } + console.log(`✓ check-skill-line-ratchet: ${v.msg}`); +} + +function selfTest() { + const cases = [ + ['under the ceiling -> green', verdict(2900, 3050).ok, true], + ['at the ceiling -> green', verdict(3050, 3050).ok, true], + ['over the ceiling -> red', verdict(3051, 3050).ok, false], + ['red message names the remedy', verdict(9999, 3050).msg.includes('references/'), true], + ['empty read -> red, not a skip', verdict(0, 3050).ok, false], + ]; + let failed = 0; + for (const [name, actual, expected] of cases) { + const ok = actual === expected; + if (!ok) failed++; + console.log(` ${ok ? '✓' : '✗'} ${name}`); + } + if (failed) { + console.error(`✗ check-skill-line-ratchet self-test: ${failed} of ${cases.length} case(s) failed.`); + process.exit(1); + } + console.log(`✓ check-skill-line-ratchet self-test: ${cases.length} cases pass.`); +} + +if (process.argv.includes('--self-test')) selfTest(); +else run(); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs new file mode 100644 index 0000000000..a3d862284e --- /dev/null +++ b/scripts/pm/dispatch-gates.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * dispatch-gates (#7341 item 4) — map a card's file surface to the `check:*` + * gate families that watch it, derived from the tree AT RUNTIME. + * + * node scripts/pm/dispatch-gates.mjs [ ...] # e.g. packages/spec/src/data/filter.zod.ts + * node scripts/pm/dispatch-gates.mjs --self-test + * + * ## Why derived, never listed + * + * The step-5 dispatch template's "Local gates for this card" line is filled by + * the PM, and the gate inventory is a thing that expires SAME-DAY: it grew + * twice in one 2026-08-08/09 shift (#6672 added `check:kernel-hook-pairs`, + * #6661 added `check:app-nav-i18n`), and even "the farm lives in lint.yml" is + * a memory-shaped claim — measured on #7341's own dispatch-time survey, checks + * also live in ci.yml / spec-liveness-check.yml / validate-deps.yml / + * release.yml / showcase-smoke.yml. #6492 is the canonical incident for a + * second copy of a list rotting inside prose (three mutually-contradicting + * counts, drifting within one hour), and #6865 for relaying remembered + * workflow facts into a dispatch prompt (four of six required-context names + * lived in a different file than claimed). So this script embeds NO list of + * checks and NO map from paths to checks: every run re-reads + * `.github/workflows/*.yml`, resolves each `check:*` script through + * package.json, and scans the check scripts' own sources for the path + * literals they operate on. When the farm grows, the next run sees it. + * + * ## What the output means (and what it cannot promise) + * + * For each input path, checks are matched by the path literals discoverable in + * their sources ("watch hints"). That derivation is honest but heuristic: + * + * - a MATCHED check is one whose own source names a directory/file that + * covers the input path — high-signal, paste it into the dispatch prompt; + * - a check with NO discoverable path hints is listed once in the + * "repo-wide / undetermined" bucket. It is NOT known to be irrelevant — + * many gates read the whole tree (check:nul-bytes) or a convention rather + * than a path. The PM's judgment call stays a judgment call; what this + * script removes is the memory-shaped half (which named checks exist and + * where they live). + * + * The output is print-only and exits 0 on a completed derivation; a run that + * cannot read the workflows or package.json exits non-zero (#4690: unreadable + * input must never look like an empty answer). + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +const ROOT = new URL('../..', import.meta.url).pathname; + +// --------------------------------------------------------------------------- +// Extraction — pure functions over file contents, self-testable offline. +// --------------------------------------------------------------------------- + +/** + * Pull every `check:*` invocation out of a workflow file's `run:` lines, + * with the pnpm --filter package (if any) and the workflow's file name. + */ +export function extractCheckInvocations(workflowText, workflowFile) { + const out = []; + const runRe = /^\s*run:\s*(.+)$/gm; + for (const [, cmd] of workflowText.matchAll(runRe)) { + for (const m of cmd.matchAll(/pnpm\s+(?:--filter\s+(\S+)\s+)?(?:run\s+)?(check:[\w:-]+)/g)) { + out.push({ check: m[2], filter: m[1] ?? null, workflow: workflowFile }); + } + for (const m of cmd.matchAll(/node\s+(scripts\/[\w./-]*check-[\w.-]+\.mjs)/g)) { + out.push({ check: m[1], filter: null, workflow: workflowFile, direct: true }); + } + } + return out; +} + +/** Resolve a `check:x` script name to the script files it runs, via a package.json `scripts` map. */ +export function resolveCheckToFiles(checkName, scriptsMap) { + const cmd = scriptsMap[checkName]; + if (!cmd) return []; + // The conventional script shape names its file twice (`--self-test && run`) — dedupe. + return [...new Set([...cmd.matchAll(/(scripts\/[\w./-]+\.(?:mjs|cjs|js|sh))/g)].map((m) => m[1]))]; +} + +/** + * Scan a check script's source for the path-ish string literals it operates + * on. A hint is a quoted string that contains a `/` (or names a top-level + * dotted dir) and looks like a repo path rather than a URL or a regex. + */ +export function extractWatchHints(scriptSource) { + const hints = new Set(); + for (const m of scriptSource.matchAll(/['"`]([^'"`\n]{2,120})['"`]/g)) { + const s = m[1]; + if (/^(https?:|[A-Z_]+=|-{1,2}\w)/.test(s)) continue; + if (!/^[\w.@][\w.@/*-]*$/.test(s)) continue; + const looksPathy = s.includes('/') || /^\.(claude|changeset|github|gitattributes)\b/.test(s); + if (!looksPathy) continue; + hints.add(s.replace(/\/+$/, '')); + } + return [...hints]; +} + +/** + * Does a watch hint cover an input path? Prefix either way, with globs + * collapsed. A hint that collapses to a bare top-level directory name + * (`packages`, `scripts` — no slash, not a dotted dir) is rejected as too + * generic: it would match every file under the tree's biggest directories and + * drown the signal the matched-via column exists to carry. + */ +export function hintCovers(hint, inputPath) { + const plain = hint.replace(/\*\*?/g, '').replace(/\/+$/, '').replace(/\/$/, ''); + if (plain.length < 2) return false; + if (!plain.includes('/') && !plain.startsWith('.')) return false; + return inputPath.startsWith(plain) || plain.startsWith(inputPath); +} + +// --------------------------------------------------------------------------- +// Live derivation +// --------------------------------------------------------------------------- + +function derive(paths) { + const wfDir = join(ROOT, '.github/workflows'); + const workflows = readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f)); + if (workflows.length === 0) throw new Error('no workflow files found under .github/workflows'); + const rootScripts = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).scripts ?? {}; + + const invocations = []; + for (const wf of workflows) { + invocations.push(...extractCheckInvocations(readFileSync(join(wfDir, wf), 'utf8'), wf)); + } + if (invocations.length === 0) throw new Error('no check:* invocations found in any workflow'); + + // Dedupe by (check, workflow); resolve each to script files + watch hints. + const byCheck = new Map(); + for (const inv of invocations) { + const key = inv.check; + if (!byCheck.has(key)) byCheck.set(key, { ...inv, workflows: new Set(), files: [], hints: [] }); + byCheck.get(key).workflows.add(inv.workflow); + } + for (const entry of byCheck.values()) { + let files = entry.direct ? [entry.check] : resolveCheckToFiles(entry.check, rootScripts); + if (entry.filter) { + // package-scoped check: resolve through that package's manifest when findable + const pkgDirGuess = entry.filter.replace(/^@objectstack\//, ''); + for (const base of ['packages', 'packages/plugins', 'packages/drivers', 'packages/services']) { + const p = join(ROOT, base, pkgDirGuess, 'package.json'); + if (existsSync(p)) { + const pkgScripts = JSON.parse(readFileSync(p, 'utf8')).scripts ?? {}; + files = files.concat( + resolveCheckToFiles(entry.check, pkgScripts).map((f) => join(base, pkgDirGuess, f)), + ); + } + } + } + entry.files = files; + for (const f of files) { + const abs = join(ROOT, f); + if (existsSync(abs)) entry.hints.push(...extractWatchHints(readFileSync(abs, 'utf8'))); + } + } + + const matched = new Map(); + const undetermined = []; + for (const [check, entry] of byCheck) { + const hits = []; + for (const p of paths) { + const hint = entry.hints.find((h) => hintCovers(h, p)); + if (hint) hits.push({ path: p, hint }); + } + if (hits.length) matched.set(check, { entry, hits }); + else if (entry.hints.length === 0) undetermined.push(check); + } + + console.log(`dispatch-gates: ${byCheck.size} check famil(ies) discovered across ${workflows.length} workflow file(s) — derived at runtime, nothing listed in this script.\n`); + if (matched.size) { + console.log('Local gates for this card (paste into the dispatch prompt):'); + for (const [check, { entry, hits }] of [...matched].sort()) { + const via = hits.map((h) => `${h.path} ⇢ '${h.hint}'`).join('; '); + console.log(` - ${check} [${[...entry.workflows].join(', ')}] matched via ${via}`); + } + } else { + console.log('No check family names the given paths in its own source.'); + } + console.log( + `\nRepo-wide / undetermined (no path literals discoverable — not known irrelevant): ${undetermined.length} famil(ies).` + + '\nJudgment stays with the PM: convention-scoped gates (new fake engine ⇒ check:engine-double-contract, new error code ⇒ check:error-code-casing, any edit ⇒ check:nul-bytes) match by what the change IS, not where it lives.', + ); +} + +// --------------------------------------------------------------------------- +// Self-test — extraction + matching over fixtures; no filesystem beyond this file. +// --------------------------------------------------------------------------- + +function selfTest() { + const cases = []; + const t = (name, cond) => cases.push([name, cond]); + + const wf = [ + 'jobs:', + ' lint:', + ' steps:', + ' - name: A', + ' run: pnpm check:engine-double-contract', + ' - name: B', + ' run: pnpm --filter @objectstack/spec check:authorable-surface', + ' - name: C', + ' run: node scripts/check-nul-bytes.mjs', + ' - name: not-a-check', + ' run: pnpm build', + ].join('\n'); + const invs = extractCheckInvocations(wf, 'lint.yml'); + t('extracts plain pnpm check', invs.some((i) => i.check === 'check:engine-double-contract' && i.filter === null)); + t('extracts filtered check with its package', invs.some((i) => i.check === 'check:authorable-surface' && i.filter === '@objectstack/spec')); + t('extracts direct node scripts/check-*.mjs', invs.some((i) => i.check === 'scripts/check-nul-bytes.mjs' && i.direct)); + t('ignores non-check runs', !invs.some((i) => String(i.check).includes('build'))); + + const scripts = { 'check:foo': 'node scripts/check-foo.mjs --self-test && node scripts/check-foo.mjs' }; + t('resolves script file from package.json', resolveCheckToFiles('check:foo', scripts).join() === 'scripts/check-foo.mjs'); + t('unknown check resolves to nothing', resolveCheckToFiles('check:bar', scripts).length === 0); + + const src = [ + "const DIR = '.claude/agents';", + "const GLOB = 'packages/spec/src/**/*.zod.ts';", + "const URL2 = 'https://example.com/x';", + "const FLAG = '--self-test';", + "const WORD = 'hello';", + ].join('\n'); + const hints = extractWatchHints(src); + t('finds dotted-dir hint', hints.includes('.claude/agents')); + t('finds glob hint', hints.some((h) => h.startsWith('packages/spec/src'))); + t('skips urls', !hints.some((h) => h.includes('example.com'))); + t('skips flags and bare words', !hints.includes('--self-test') && !hints.includes('hello')); + + t('hint covers deeper path', hintCovers('.claude/agents', '.claude/agents/os-dev.md')); + t('collapsed glob prefix covers', hintCovers('packages/spec/src/**', 'packages/spec/src/data/filter.zod.ts')); + t('input dir covers hint below it', hintCovers('packages/spec/scripts/check-x.mjs', 'packages/spec')); + t('unrelated path does not match', !hintCovers('.claude/agents', 'packages/rest/src/server.ts')); + + let failed = 0; + for (const [name, cond] of cases) { + if (!cond) failed++; + console.log(` ${cond ? '✓' : '✗'} ${name}`); + } + if (failed) { + console.error(`✗ dispatch-gates self-test: ${failed} of ${cases.length} case(s) failed.`); + process.exit(1); + } + console.log(`✓ dispatch-gates self-test: ${cases.length} cases pass.`); +} + +const argvPaths = process.argv.slice(2).filter((a) => a !== '--self-test'); +if (process.argv.includes('--self-test')) { + selfTest(); +} else if (argvPaths.length === 0) { + console.error('usage: node scripts/pm/dispatch-gates.mjs [ ...] | --self-test'); + process.exit(2); +} else { + try { + derive(argvPaths.map((p) => p.replace(/^\.\//, ''))); + } catch (err) { + console.error(`dispatch-gates: derivation failed — ${err.message}`); + process.exit(2); + } +} From 89e57ad5df386ba9b10fb7cdcbd15d1819c944ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:16:07 +0000 Subject: [PATCH 4/4] docs(pm-dispatch): M-and-above dispatches default to mode:cloud (#7341 item 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling of 2026-08-10, quoted verbatim in the section: 「确认,M 及以上默认云卡,并进 #7341」. The 2026-08-07 heavy-card exception list becomes examples of the M+ class, not the trigger list; only S-grade mechanical cards stay mode:subagent. The three companion disciplines (subscribe on creation, issue-comment report authority, archive_session at terminal state) are named beside the new default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QrT21cGQfFVDWtVJysRtXo --- .claude/skills/pm-dispatch/SKILL.md | 48 +++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 5dcffbfacc..dce4f66373 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -36,7 +36,7 @@ genuinely requires their call. | `epic:#` | 队列 = 父 issue #n 的子树(open 未认领 sub-issue,每轮重读)— 见「Epic 子树车道」 | — | | `batch:` | max developer agents in flight at once | `3` | | `rounds:` | stop after N rounds | until queue empty | -| `mode:subagent` \| `mode:cloud` | dispatch backend — see "Dispatch backends" | `subagent` | +| `mode:subagent` \| `mode:cloud` | dispatch backend — see "Dispatch backends" | 按卡分流:M+ ⇒ `cloud`,S 级机械 ⇒ `subagent`(维护者 2026-08-10 裁定) | | `#12 #34 …` | explicit issue list — overrides the label query entirely | — | ## State model — all state lives in GitHub, none locally @@ -2072,17 +2072,26 @@ regression passes) drop to `batch:2`, or dispatch that issue via `mode:cloud` so it gets its own container. If an agent dies with a heap/OOM signature, redispatch it alone rather than into a full batch. -**单容器化任务在选择期单独派卡片(维护者 2026-08-07 拍板)。** 上一段的 -「重活走 `mode:cloud`」不是事后救火,而是**批次选择时的分类动作**:每轮选单时 -PM 先给每张候选卡判定验证重量,命中任一判据即**单独派一张 `mode:cloud` 卡** -(独享容器),⛔ 不混进共享容器批次: - -- **判据(任一命中即单容器)**:`size/l` / `size/xl`;全量重生成类(动 tracked - 生成物需整套 regen,#5837 分片即此形);验证半径跨 3 个以上包的全量测试; - dogfood / 浏览器验证;依赖族升级、全量回归;预计持 heavy-verify 锁超过 - ~10 分钟的验证管线。 -- **轻卡不升舱**:S/M 级(文档、JSDoc、单文件面)留 `mode:subagent` 共享容器 - —— 为轻卡单开容器是纯开销,规则的两个方向同等硬。 +**M 及以上默认云卡(维护者 2026-08-10 裁定,#7341 item 10 —— 取代 2026-08-07 的 +「重卡例外单容器」制)。** 原话逐字引用、未翻译: + +> 确认,M 及以上默认云卡,并进 #7341 + +派发后端因此是**批次选择时按尺寸分流**,不再是「验证重量命中判据才例外升舱」: + +- **M 及以上 ⇒ 默认 `mode:cloud` 单独派卡**(独享容器),⛔ 不混进共享容器批次。 + 裁决理由(维护者同日讨论留档):全程可见、可直接对话干预,价值高于逐卡容器 + 启动的开销 —— 对任何非琐碎的卡这笔账都成立。旧判据清单(`size/l` / `size/xl`; + 全量重生成类,#5837 分片即此形;验证半径跨 3 个以上包的全量测试;dogfood / + 浏览器验证;依赖族升级、全量回归;预计持 heavy-verify 锁超过 ~10 分钟)自此是 + **M+ 类的示例**,不再是触发清单 —— 一条都不命中的 M 卡照样走云卡。 +- **只有 S 级机械卡留 `mode:subagent` 共享容器** —— 为琐碎卡单开容器是纯开销, + 规则的两个方向同等硬。**S 级但不机械**的卡(判断面在设计上,不在门禁上)按 + M 待遇走云卡 —— 与 Model tiering「尺寸不是档位的充分判据」同款读法。 +- **三条随行纪律与新默认同段生效**(均为既有条款,点名以防散落):云卡创建即对 + 其 PR 挂 `subscribe_pr_activity`(入队与落地 B);dev 报告的权威通道是 issue + 评论(``,报告通道统一 —— step 6);卡到终局即 + `archive_session`(入队与落地 B 的归档动作)。 - **判定写进认领评论,并带上模型档位**(step 5「Model tiering」)。这一行现在同时 承载两个决定 —— 尺寸/容器 与 档位 —— 因为两者用的是同一次判读,分开写只会漂移: 「容器判定:S 级机械卡,`mode:subagent` 共享容器,`model: sonnet`」/ @@ -2098,16 +2107,21 @@ PM 先给每张候选卡判定验证重量,命中任一判据即**单独派一 #### Dispatch backends -**`mode:subagent` (default).** The `Agent` tool, as described above. The devs +**`mode:subagent` — the S-grade mechanical lane.** The `Agent` tool, as +described above. The devs run inside the PM's own session container — which in Claude Code on the web is already a cloud container, so the whole loop runs server-side and survives the browser tab closing. Reports come back directly as the subagent's final -message. Prefer this mode: it is simpler, and the report channel is lossless. +message — the accelerator beside the authoritative issue comment (step 6). +Since the 2026-08-10 ruling (「M 及以上默认云卡」, the sizing section above) +this mode is for S-grade mechanical cards, not the default for everything. -**`mode:cloud`.** Each issue becomes an **independent cloud session** in the +**`mode:cloud` — the default for M and above** (same ruling). Each issue +becomes an **independent cloud session** in the same environment — its own container and fresh clone, decoupled from the PM -session's lifetime. Use it when devs need resources/lifetime beyond one -container, or the maintainer asks for it. Requires the `Claude_Code_Remote` +session's lifetime. Also the fallback for any card that needs +resources/lifetime beyond one container, whatever its size. Requires the +`Claude_Code_Remote` MCP tools (available in remote/web sessions; if absent, say so and fall back to `mode:subagent`).