From e208c8912630a07c321faace219edb55e9a1d599 Mon Sep 17 00:00:00 2001 From: os-elon-musk Date: Thu, 22 Oct 2026 15:12:50 +0000 Subject: [PATCH 1/6] docs(pm-dispatch): fold the engine, services and cli lanes into domain:server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 乙 on card #24907 (comment 6341410910, verbatim: 「Maintainer ruling: 乙. engine, services and cli fold into `domain:server`, and `packages/qa` moves to `domain:devx`.」). - SKILL.md 域车道 table: the three rows become one `domain:server` row scoped to their union; `packages/qa` moves to the devx row; the retired-label rule takes one bullet reading the three old spellings as `domain:server`. 319 -> 318 lines, widest row 342 -> 341 bytes, both ceilings lowered. - references/lanes/{engine,services,cli}.md -> references/lanes/server.md, three sections (one per former surface); a seat reads only the section its claim's file surface names. The three per-lane spec red lines fold into one and the two intra-lane pointers leave: 88 summed -> 87. lanes/spec.md's `/meta` pointer now names `domain:server`. - check-skill-line-ratchet.mjs: ceilings, widest-row pins and the platform-readings cross-file-move sources follow the merge (`lanes/server.md` enters at `was` 97 = 35 + 30 + 32, so the decrease the two merged sources had paid stays counted). Ceilings down 488. - check-half-states.mjs: LANE_LABEL_ALIASES + canonicalLane; seatLane, newestLaneClaim, the H32 inventory and the census compare lanes through it, so in-flight claims, branches and `Seat:` lines stay valid. New self-test battery `domain:server lane aliases` (24 cases, pinned 22); roster floor 9 -> 10. - ensure-pm-labels.sh: creates `domain:server`; the three retired labels keep alias rows carrying a "Retired 2026-10-22, alias of domain:server" description for --reconcile. check-skill-line-ratchet.mjs and clause2-line.mjs have no label reader, so the alias lives in check-half-states.mjs alone. Seat posts are retitled by their holders and open cards relabelled by triage after landing. No new gate. Claude-Session: https://claude.ai/code/session_01VCLHrrmPbwQHZcc2yqdFHD Co-authored-by: Claude --- .claude/skills/pm-dispatch/SKILL.md | 7 +- .../pm-dispatch/references/lanes/cli.md | 29 ----- .../pm-dispatch/references/lanes/engine.md | 32 ----- .../pm-dispatch/references/lanes/server.md | 87 +++++++++++++ .../pm-dispatch/references/lanes/services.md | 27 ----- .../pm-dispatch/references/lanes/spec.md | 2 +- scripts/pm/check-half-states.mjs | 114 ++++++++++++++++-- scripts/pm/check-skill-line-ratchet.mjs | 61 +++++----- scripts/pm/ensure-pm-labels.sh | 25 +++- 9 files changed, 249 insertions(+), 135 deletions(-) delete mode 100644 .claude/skills/pm-dispatch/references/lanes/cli.md delete mode 100644 .claude/skills/pm-dispatch/references/lanes/engine.md create mode 100644 .claude/skills/pm-dispatch/references/lanes/server.md delete mode 100644 .claude/skills/pm-dispatch/references/lanes/services.md diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 1220ec23a46..2ae459f00ec 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -158,16 +158,15 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 | 标签 | 包家族 | |:--|:--| -| `domain:engine` | `packages/objectql`、`packages/core`、`packages/formula`(CEL / `matches-filter` / RLS 谓词求值)、`plugin-pinyin-search`;`packages/metadata*`、`packages/platform-objects`;`packages/drivers/driver-*`;退役标签 `domain:engine-core` / `domain:metadata` / `domain:drivers` 只退出流通,GitHub 标签对象保留 | -| `domain:services` | `packages/services/*`、`packages/connectors/*`、`packages/triggers/*`、`plugin-approvals`、`plugin-webhooks`、`plugin-email`、`plugin-reports`、`embedder-openai`、`knowledge-*`;`plugin-auth`、`plugin-security`、`plugin-sharing`、`plugin-audit`;退役标签 `domain:identity` 同上只退流通 | -| `domain:devx` | `sdui-parser`、`content/docs/**`、`apps/docs`、`docs/qa/**`、`.githooks/`、`docker/README.md`、`examples/**` 仅测试基建面;`packages/lint`、`scripts/`(门禁类)、`.github/workflows/`(接线)三者按锚定规则例外归 `domain:spec`,门禁按 SUBJECT:governed 面归 skills,代码/文档质量归本域 | +| `domain:server` | 服务端:引擎 + 服务插件 + CLI/运行时;`objectql core formula metadata* platform-objects drivers/* services/* connectors/* triggers/* plugin-* embedder-openai knowledge-* cli runtime verify types rest mcp observability client* cloud-connection create-objectstack adapters/*`;详见 `references/lanes/server.md` | +| `domain:devx` | `sdui-parser`、`packages/qa`、`content/docs`、`apps/docs`、`docs/qa`、`.githooks`、`docker/README.md`、`examples/**` 仅测试基建面;`packages/lint`、`scripts`(门禁类)、`.github/workflows`(接线)三者按锚定规则归 `domain:spec`,门禁按 SUBJECT:governed 面归 skills,代码/文档质量归本域 | | `domain:skills` | governed 面全量(含本文件;统一定义见「governed 面统一定义」行);非门禁的 `scripts/pm/**`(PM 循环工具);governed 面的治理执行文件:`.github/CODEOWNERS` + SUBJECT 是 governed 面本身的门禁/审计(现为 `scripts/pm/check-governed-merges.mjs`) | | `domain:spec` | `packages/spec` 整包:schema 形状、`contracts/**`、退役行为半边、strictness 台账;describe/JSDoc/墓碑散文/错误 guidance 与 alias 表;`packages/spec/scripts/**`、`packages/spec/docs/**` 及按锚定规则的例外归本域的工具链(域边界枚举与席内分派见 `references/lanes/spec.md`) | -| `domain:cli` | `packages/cli`、`runtime`、`verify`、`packages/qa`、`types`、`packages/rest`、`packages/mcp`、`packages/observability`、`packages/client*`、`cloud-connection`、`create-objectstack`、`packages/adapters/*`、`plugin-hono-server`、`plugin-dev` | | `domain:cloud` | 云服务旅程,停放(加 `status:parked`,sweep 免扫)待 cloud PM 迁移,本仓不派 | | (无固定归属,按落点分诊) | `packages/apps/*`、`packages/console`(dist 由脚本生成 ⛔ 不手改;UI 缺陷走 `repo:objectui`)、`examples/*`(归它演练的子系统)、`docs/audits/**` | - 表未覆盖的包首次分诊时归类并走 PR 更新本表;新增或退役 `domain:*` 必须同批改本表。 +- 退役 `domain:*` 只退流通,对象保留;`domain:engine`/`domain:services`/`domain:cli` 读作 `domain:server`。 - `domain:*` 只由分诊席产出,例外一:在飞卡衍生 sub-issue 承父卡域;无标签的卡 ⛔ 不得认领。 - 例外二:立卡人自贴而车道合上表、正文齐立卡门三件 ⇒ 执行席直接认领,分诊事后抽查。 - 跨域例外路径:拆不动的跨域单 PR 由分诊席指定一个车道 PM 认领,认领评论申报文件面。 diff --git a/.claude/skills/pm-dispatch/references/lanes/cli.md b/.claude/skills/pm-dispatch/references/lanes/cli.md deleted file mode 100644 index fde20bf74f0..00000000000 --- a/.claude/skills/pm-dispatch/references/lanes/cli.md +++ /dev/null @@ -1,29 +0,0 @@ -# 车道岗位说明:domain:cli - -见 seat-lifecycle.md;本文是本车道岗位说明,现值状态恒在座位贴,⛔ 不迁入本文。 - -## 范围 - -- `packages/cli`、`runtime`、`verify`、`qa`、`types`、`packages/rest`、 - `packages/mcp`、`packages/observability`、`packages/client*`、 - `cloud-connection`、`create-objectstack`、`packages/adapters/*`、 - `plugin-hono-server`、`plugin-dev`。 -- 红线:按落点判归属 —— 标题挂 cli 而落点在别包的卡只上报误标,⛔ 不改签。 -- `packages/spec` 恒归 spec 座位。 -- `/meta` 路由本体归本席,元数据格式与接受面归 `domain:spec`。 - -## 常设承诺 - -- PR 侧绿不等于队列侧绿;队列分支条目名、队序重建与出队滞留住 `../platform-readings.md`。 -- `dispatch-gates.mjs` 只报路径推导的地板;条款②从卡内容判,只定复核档不定施工档。 -- 已知盲区:它不点名 `pnpm lint` 族 ⇒ 本车道派发令恒补一句跑 `pnpm lint`。 -- ⛔ 永不削弱门禁:棘轮上跳是门禁在报你的改动有缺陷。 -- 规则文案里自带的逃生舱只用于它描述的那种情形。 - -## 席内判断 - -- 探针的 baseline 也是测量,而且是没人复查的那个 ⇒ 判据恒读命中行本身,⛔ 不读命中数。 -- 断言文本被删的探针要锚在消失的那段上:被删串的前缀在删除后仍幸存是构造使然。 -- 静默时长是发探针的门槛,永不是判死的门槛;基线自本车道实录,⛔ 不写成常数。 -- 分支无提交加 SendMessage 探针便宜,且能复活假死的 dev。 -- ⛔ 永不往可能活着的 worktree 派第二个 agent。 diff --git a/.claude/skills/pm-dispatch/references/lanes/engine.md b/.claude/skills/pm-dispatch/references/lanes/engine.md deleted file mode 100644 index 29006cc363e..00000000000 --- a/.claude/skills/pm-dispatch/references/lanes/engine.md +++ /dev/null @@ -1,32 +0,0 @@ -# 车道岗位说明:domain:engine - -见 seat-lifecycle.md;本文是本车道岗位说明,现值状态恒在座位贴,⛔ 不迁入本文。 - -## 范围 - -- `packages/objectql`(含 SchemaRegistry)、`packages/core`、`packages/formula` - (CEL、`matches-filter`、RLS 谓词求值)、`plugin-pinyin-search`(落点在编译与查询核心)。 -- `objectql/src/metadata-facade.ts` 名字带 metadata,仍归本车道。 -- `packages/metadata*`(加载、注册、持久化、缓存、目录)、`packages/platform-objects`。 -- `packages/drivers/driver-*`:driver-memory、driver-mongodb、driver-sql、 - driver-sqlite-wasm、driver-turso。 -- 红线:改元数据格式或接受面 ⇒ `domain:spec`,判据是改变接受面而不是碰到 spec。 -- `/meta` 路由本体在 `packages/rest` ⇒ `domain:cli`;`packages/services/**` ⇒ `domain:services`。 -- `content/docs/**` 与 `packages/lint` ⇒ `domain:devx`;`packages/lint` 的唯一例外见 SKILL.md 锚定规则。 - -## 常设承诺 - -- driver conformance 台账只降不升:driver 卡的派发令要求 dev 前后各读一次 covered 与 DEBT。 -- 放宽接受集或扩大公开面的卡按条款②处理,判据与档位在 SKILL.md,⛔ 不另抄。 - -## 席内判断 - -- 区域不等于文件:区域放行只能来自对方卡真实改动行程,有 PR 读 diff,没有就申报 UNKNOWN。 -- 做删除的卡,其行程没人能从它加了什么的名字推出。 -- 行号数小时内即腐烂,恒按符号定位。 -- 五个 driver 列只有约三个独立实现:sqlite-wasm 与 turso local 都继承 `SqlDriver` 的 filter 编译器。 -- ⇒ 把一致性表当五方佐证会高估证据;同理,单点修 SQL 家族共享 seam 即可覆盖三个 driver。 -- 元数据合并语义按键类各不相同:fields 展开、validations 与 indexes 连接、标量 last-writer-wins。 -- ⇒ 评审 pin 时先问它没有覆盖什么。 -- 标量的 last-writer-wins 是重复立卡的老源头,同 seam 的第三张分歧卡优先裁类,不裁实例。 -- 元数据接受面形状的卡先问生产者在哪:答案常改变卡的范围与域标签,回分诊。 diff --git a/.claude/skills/pm-dispatch/references/lanes/server.md b/.claude/skills/pm-dispatch/references/lanes/server.md new file mode 100644 index 00000000000..91c42c37b6a --- /dev/null +++ b/.claude/skills/pm-dispatch/references/lanes/server.md @@ -0,0 +1,87 @@ +# 车道岗位说明:domain:server + +见 seat-lifecycle.md;本文是本车道岗位说明,现值状态恒在座位贴,⛔ 不迁入本文。 + +- 三节按文件面分读:认领的 `File surface:` 落在哪节就读哪节,⛔ 不读整篇。 +- 红线:触 `packages/spec` 的卡一律归 spec 席;改元数据格式或接受面的卡亦然,不论碰没碰 spec。 + +## 引擎面(原 `domain:engine`) + +### 范围 + +- `packages/objectql`(含 SchemaRegistry)、`packages/core`、`packages/formula` + (CEL、`matches-filter`、RLS 谓词求值)、`plugin-pinyin-search`(落点在编译与查询核心)。 +- `objectql/src/metadata-facade.ts` 名字带 metadata,仍归本节。 +- `packages/metadata*`(加载、注册、持久化、缓存、目录)、`packages/platform-objects`。 +- `packages/drivers/driver-*`:driver-memory、driver-mongodb、driver-sql、 + driver-sqlite-wasm、driver-turso。 +- `content/docs/**` 与 `packages/lint` ⇒ `domain:devx`;`packages/lint` 的唯一例外见 SKILL.md 锚定规则。 + +### 常设承诺 + +- driver conformance 台账只降不升:driver 卡的派发令要求 dev 前后各读一次 covered 与 DEBT。 +- 放宽接受集或扩大公开面的卡按条款②处理,判据与档位在 SKILL.md,⛔ 不另抄。 + +### 席内判断 + +- 区域不等于文件:区域放行只能来自对方卡真实改动行程,有 PR 读 diff,没有就申报 UNKNOWN。 +- 做删除的卡,其行程没人能从它加了什么的名字推出。 +- 行号数小时内即腐烂,恒按符号定位。 +- 五个 driver 列只有约三个独立实现:sqlite-wasm 与 turso local 都继承 `SqlDriver` 的 filter 编译器。 +- ⇒ 把一致性表当五方佐证会高估证据;同理,单点修 SQL 家族共享 seam 即可覆盖三个 driver。 +- 元数据合并语义按键类各不相同:fields 展开、validations 与 indexes 连接、标量 last-writer-wins。 +- ⇒ 评审 pin 时先问它没有覆盖什么。 +- 标量的 last-writer-wins 是重复立卡的老源头,同 seam 的第三张分歧卡优先裁类,不裁实例。 +- 元数据接受面形状的卡先问生产者在哪:答案常改变卡的范围与域标签,回分诊。 + +## 服务插件面(原 `domain:services`) + +### 范围 + +- `packages/services/*`、`packages/connectors/*`、`packages/triggers/*`、 + `plugin-approvals`、`plugin-webhooks`、`plugin-email`、`plugin-reports`、 + `embedder-openai`、`knowledge-*`。 +- 身份侧:`plugin-auth`、`plugin-security`、`plugin-sharing`、`plugin-audit`。 +- 安全与权限边界的放宽是人工地板,恒升级不代裁。 +- 该边界含访问控制、认证流、RLS 与共享语义、审计留痕。 + +### 常设承诺 + +- 每条否定性 pin 都要消融验证:被禁行为放回、测试转红、报失败输出、恢复。 +- 单独的绿不是证据。 +- 安全族卡的披露纪律:复现配方不落任何公开面(卡、PR、评论)。 +- 证据以抽象描述或私有通道承载。 + +### 席内判断 + +- 认证栈版本族升级把 `pnpm-workspace.yaml` overrides 与 `plugin-auth/**` 当同一个热文件面串行。 +- 旧版本族上测得的前提(兼容性、行为断言)在新族落地后逐卡重读再派,⛔ 不沿用旧读数。 +- 多服务共享同一条自动化引擎或数据源 seam 时,同文件卡跨轮硬串行。 +- 后一单派发前先用前一单的报告重新定价,见 SKILL.md 候选与批次的重定价条款。 + +## CLI/运行时面(原 `domain:cli`) + +### 范围 + +- `packages/cli`、`runtime`、`verify`、`types`、`packages/rest`、 + `packages/mcp`、`packages/observability`、`packages/client*`、 + `cloud-connection`、`create-objectstack`、`packages/adapters/*`、 + `plugin-hono-server`、`plugin-dev`;`packages/qa` 已归 `domain:devx`。 +- 红线:按落点判归属 —— 标题挂 cli 而落点在别包的卡只上报误标,⛔ 不改签。 +- `/meta` 路由本体归本节,元数据格式与接受面归 `domain:spec`。 + +### 常设承诺 + +- PR 侧绿不等于队列侧绿;队列分支条目名、队序重建与出队滞留住 `../platform-readings.md`。 +- `dispatch-gates.mjs` 只报路径推导的地板;条款②从卡内容判,只定复核档不定施工档。 +- 已知盲区:它不点名 `pnpm lint` 族 ⇒ 本车道派发令恒补一句跑 `pnpm lint`。 +- ⛔ 永不削弱门禁:棘轮上跳是门禁在报你的改动有缺陷。 +- 规则文案里自带的逃生舱只用于它描述的那种情形。 + +### 席内判断 + +- 探针的 baseline 也是测量,而且是没人复查的那个 ⇒ 判据恒读命中行本身,⛔ 不读命中数。 +- 断言文本被删的探针要锚在消失的那段上:被删串的前缀在删除后仍幸存是构造使然。 +- 静默时长是发探针的门槛,永不是判死的门槛;基线自本车道实录,⛔ 不写成常数。 +- 分支无提交加 SendMessage 探针便宜,且能复活假死的 dev。 +- ⛔ 永不往可能活着的 worktree 派第二个 agent。 diff --git a/.claude/skills/pm-dispatch/references/lanes/services.md b/.claude/skills/pm-dispatch/references/lanes/services.md deleted file mode 100644 index 1f3fd84cf4e..00000000000 --- a/.claude/skills/pm-dispatch/references/lanes/services.md +++ /dev/null @@ -1,27 +0,0 @@ -# 车道岗位说明:domain:services - -见 seat-lifecycle.md;本文是本车道岗位说明,现值状态恒在座位贴,⛔ 不迁入本文。 - -## 范围 - -- `packages/services/*`、`packages/connectors/*`、`packages/triggers/*`、 - `plugin-approvals`、`plugin-webhooks`、`plugin-email`、`plugin-reports`、 - `embedder-openai`、`knowledge-*`。 -- 身份侧:`plugin-auth`、`plugin-security`、`plugin-sharing`、`plugin-audit`。 -- 红线:零持有 `packages/spec` —— 触 spec 的卡一律转 spec 座位。 -- 安全与权限边界的放宽是人工地板,恒升级不代裁。 -- 该边界含访问控制、认证流、RLS 与共享语义、审计留痕。 - -## 常设承诺 - -- 每条否定性 pin 都要消融验证:被禁行为放回、测试转红、报失败输出、恢复。 -- 单独的绿不是证据。 -- 安全族卡的披露纪律:复现配方不落任何公开面(卡、PR、评论)。 -- 证据以抽象描述或私有通道承载。 - -## 席内判断 - -- 认证栈版本族升级把 `pnpm-workspace.yaml` overrides 与 `plugin-auth/**` 当同一个热文件面串行。 -- 旧版本族上测得的前提(兼容性、行为断言)在新族落地后逐卡重读再派,⛔ 不沿用旧读数。 -- 多服务共享同一条自动化引擎或数据源 seam 时,同文件卡跨轮硬串行。 -- 后一单派发前先用前一单的报告重新定价,见 SKILL.md 候选与批次的重定价条款。 diff --git a/.claude/skills/pm-dispatch/references/lanes/spec.md b/.claude/skills/pm-dispatch/references/lanes/spec.md index e5bf47e076f..23c92d54c9e 100644 --- a/.claude/skills/pm-dispatch/references/lanes/spec.md +++ b/.claude/skills/pm-dispatch/references/lanes/spec.md @@ -21,7 +21,7 @@ - 收窄不触发条款②,但按 `yes` 申报恒不是错误;⛔ 个案裁决不改本行。 - 机器面改围契约的仪器,与辅助面不交,⛔ 不碰 `packages/spec/src/**/*.zod.ts` 与 strictness 台账。 - 机器面卡只按北极星「仪器为车队服务」的那一问评级。 -- 改元数据格式或接受面的照旧归 `domain:spec`;`/meta` 路由本体在 `packages/rest` 归 `domain:cli`。 +- 改元数据格式或接受面的照旧归本席;`/meta` 路由本体在 `packages/rest` 归 `domain:server`。 - 拿不准 FLAG 回分诊,⛔ 不自设判据;档位随面走,表在 SKILL.md 模型分档,⛔ 不另抄。 ## 常设承诺 diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 23a11e25341..aa1cad038fd 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -1114,6 +1114,40 @@ export function labelNames(issue) { return (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)); } +/** + * Lane labels retired INTO another lane, read as that lane everywhere this + * sweep compares a card's `domain:*` label with a seat's (maintainer ruling + * 2026-10-22, letter 乙: `domain:engine`, `domain:services` and `domain:cli` + * fold into `domain:server`). The transition is designed to interrupt nothing: + * in-flight cards keep their old label until they land, open cards are + * relabelled in batches by triage after the merge lands, and seat posts are + * retitled at each seat's next round — so for a while one lane is spelled four + * ways on the board. Every lane comparison below goes through + * {@link canonicalLane}, which is what lets a `domain:server` seat count a + * `domain:engine` queue card as its own, a seat still titled `domain:cli` see + * a claim on a `domain:server` card, and the census count a card carrying both + * spellings once. ⛔ An alias map, never a vocabulary: it says nothing about + * which labels EXIST (that is `scripts/pm/ensure-pm-labels.sh`), and the + * cleanup PR that retires the three label objects deletes these rows with them. + */ +export const LANE_LABEL_ALIASES = Object.freeze({ + 'domain:engine': 'domain:server', + 'domain:services': 'domain:server', + 'domain:cli': 'domain:server', +}); + +/** + * A lane label with its alias resolved. Anything the map does not name — a + * live lane, a `repo:*` lane, a non-lane label, a non-string — is handed back + * untouched, so this is safe to apply to every label a reader compares. + * + * @param {unknown} label + * @returns {unknown} + */ +export function canonicalLane(label) { + return typeof label === 'string' && Object.hasOwn(LANE_LABEL_ALIASES, label) ? LANE_LABEL_ALIASES[label] : label; +} + /** * The "awaiting a manual maintainer action" state (#11196 fix 5, maintainer * ruling 2026-08-23, verbatim: 「可以新标签,最好 pm: 开头」 — the spelling is @@ -7479,7 +7513,8 @@ export function seatLane(issue) { const lane = (at ? at[1] : raw).trim(); const elsewhere = at ? at[2] !== SWEEP_REPO.repo.split('/')[1] : false; if (!/^domain:[a-z0-9][a-z0-9._-]*$/i.test(lane)) return { lane: null, foreign: true, seat }; - return { lane, foreign: elsewhere, seat }; + // A post not yet retitled after a lane merge speaks for the merged lane. + return { lane: canonicalLane(lane), foreign: elsewhere, seat }; } /** @@ -8832,10 +8867,13 @@ export function claimSeatNumber(body) { */ export function newestLaneClaim(lane, issues, commentsByNumber, seat = 1) { let best = null; + // The lane and the card's labels are compared through the alias map, so a + // card still carrying a retired spelling is on the lane that absorbed it. + const want = canonicalLane(lane); for (const issue of issues ?? []) { const labels = labelNames(issue ?? {}); // Both conditions intersected locally — see the OR/AND note above. - if (!labels.includes('pm:dispatched') || !labels.includes(lane)) continue; + if (!labels.includes('pm:dispatched') || !labels.some((l) => canonicalLane(l) === want)) continue; const rows = commentsByNumber?.get?.(issue.number); if (!rows) continue; // Only this seat's claims: the filter runs on the rows, the predicate is reused. @@ -8995,7 +9033,9 @@ export function censusPageExhausted(batch) { * a reader could not see. */ export function censusLanesOf(issue) { - const lanes = labelNames(issue ?? {}).filter((l) => /^(?:domain|repo):/u.test(l)); + // Through the alias map, deduplicated: a card carrying a retired spelling + // beside the lane that absorbed it is one lane, never two rows. + const lanes = [...new Set(labelNames(issue ?? {}).filter((l) => /^(?:domain|repo):/u.test(l)).map(canonicalLane))]; return lanes.length > 0 ? lanes : ['(no lane)']; } @@ -22408,7 +22448,9 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen }; for (const issue of seen.values()) { const labels = labelNames(issue); - const lanes = labels.filter((l) => /^domain:/u.test(l)); + // Through the alias map, deduplicated, so a retired spelling counts toward + // the lane that absorbed it and a card carrying both is counted once. + const lanes = [...new Set(labels.filter((l) => /^domain:/u.test(l)).map(canonicalLane))]; if (lanes.length === 0) continue; const unclaimed = labels.includes('pm:queue') && (issue.assignees ?? []).length === 0; const inFlight = labels.includes('pm:dispatched'); @@ -23860,10 +23902,19 @@ export const SELF_TEST_BATTERIES = Object.freeze({ // closed-list controls (`bash`, `gradle`, `nodemon`) proving the head set did // not become a guess at command-shaped words. 'H9 node command head': 9, + // Registered with the domain:server lane merge (maintainer ruling 2026-10-22, + // letter 乙), pin just under the count. What this battery floors is a + // WIDENING of every lane comparison — three retired spellings now read as + // one lane — so the controls live inside it: the pass-through of a lane the + // map does not name, the seat title and the claim index read through the + // alias in BOTH directions, and the census that must not count one card + // twice. A widening whose controls can drift out of the suite is how an + // alias becomes a wildcard. + 'domain:server lane aliases': 22, }); /** The floor on the ROSTER itself — how many batteries must be declared at all. */ -export const SELF_TEST_BATTERY_FLOOR = 9; +export const SELF_TEST_BATTERY_FLOOR = 10; async function selfTest() { const cases = []; @@ -25302,7 +25353,7 @@ async function selfTest() { b(BATTERY19129, 'control: …and `node` counts as a WHOLE head word only; the `manual` opt-out still outranks the head', [classifyRestartWhen('nodemon restarts cleanly'), classifyRestartWhen('node_modules/.bin/foo exits 0'), classifyRestartWhen(`manual — ${v9868}`)].join(), 'prose,prose,manual'); b(BATTERY19129, '⭐ row: a hold carrying the subject line is a LEGAL hold now, in either channel', [h9OnHoldNoRestartWhen(hold(`Restart-when: ${v9868}`)), h9OnHoldNoRestartWhen(hold('parked'), [`Restart-when: ${v9868}`])].join(), ','); b(BATTERY19129, 'row: …and the gathering policy moved with the verdict — a body that now answers buys no comment fetch', needsRestartWhenComments(hold(`Restart-when: ${v9868}`)), false); - b(BATTERY19129, 'floor: this battery is DECLARED on the frozen roster with a positive pin, and the roster floor rose to NINE with it', [Object.hasOwn(SELF_TEST_BATTERIES, BATTERY19129), SELF_TEST_BATTERIES[BATTERY19129] > 0, Object.isFrozen(SELF_TEST_BATTERIES), SELF_TEST_BATTERY_FLOOR === 9, Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR].join(), 'true,true,true,true,true'); + b(BATTERY19129, 'floor: this battery is DECLARED on the frozen roster with a positive pin, and the roster floor rose to TEN with it', [Object.hasOwn(SELF_TEST_BATTERIES, BATTERY19129), SELF_TEST_BATTERIES[BATTERY19129] > 0, Object.isFrozen(SELF_TEST_BATTERIES), SELF_TEST_BATTERY_FLOOR === 10, Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR].join(), 'true,true,true,true,true'); // -- H9: `issue-ref` is the DECLARATION position (#17605) ------------------- // The wide form (`/#\d+\b/`) read a MENTION as an exit, so four live holds @@ -27007,7 +27058,7 @@ async function selfTest() { b(BATTERY18901, 'H68 band: …and the sweep really pushes it, so the registry sees it', familyRegistryCoverage().emitted.includes('H68'), true); b(BATTERY18901, 'H68 band: no code is left unregistered and none registered that the sweep never pushes', familyRegistryCoverage().missing.length + familyRegistryCoverage().extra.length, 0); b(BATTERY18901, 'floor: this battery is DECLARED on the roster, with a positive pin', Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, BATTERY18901) && SELF_TEST_BATTERIES[BATTERY18901] > 0, true); - b(BATTERY18901, 'floor: the roster now declares NINE batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR === 9 && Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR, true); + b(BATTERY18901, 'floor: the roster now declares TEN batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR === 10 && Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR, true); // -- H64 — a seat- or dev-signed artefact that names no session (#18069, // re-keyed by #18237) --------------------------------------------------- @@ -28143,7 +28194,7 @@ async function selfTest() { b(BATTERY18664, '#18664 floor: this battery is DECLARED on the roster', Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, BATTERY18664), true); b(BATTERY18664, '#18664 floor: …with a positive pin, so an empty battery cannot satisfy it', SELF_TEST_BATTERIES[BATTERY18664] > 0, true); b(BATTERY18664, '#18664 floor: the roster is frozen', Object.isFrozen(SELF_TEST_BATTERIES), true); - b(BATTERY18664, '#18664 floor: the roster now declares NINE batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR, 9); + b(BATTERY18664, '#18664 floor: the roster now declares TEN batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR, 10); b(BATTERY18664, '#18664 floor: …including the five this battery landed BESIDE, so neither side of the base merge silently dropped one', ['H66 released queue card', 'H19 judged-set founding', 'H65 tier declaration spelling', 'H67 queued merged-delivery reading', 'H2/H47/H66 decorated ownership marker'].every((name) => Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, name)), true); b(BATTERY18664, '#18664 floor: …and the roster really carries at least that many', Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR, true); @@ -29646,7 +29697,7 @@ async function selfTest() { b(BATTERY19255, '⭐ H26 stand-down: an exit naming the parked target silences the row in either channel — and once the label leaves, H19 fires the exit while H26 stays quiet', [h26row(exitCard(`Blocked-by: #68\n${LIVE_EXIT}`), [parked], undefined, REPO_OS), h26row(exitCard('Blocked-by: #68'), [parked], [LIVE_EXIT], REPO_OS), h26row(exitCard(`Blocked-by: #68\n${LIVE_EXIT}`), [ruled], undefined, REPO_OS)].every((v) => v === '') && typeof h19exit(exitCard(`Blocked-by: #68\n${LIVE_EXIT}`), [ruled]) === 'string', true); b(BATTERY19255, 'H26 stand-down: ⛔ a comment it was not handed is not read; an exit naming ANOTHER card, or the PR-shaped value, leaves the row firing and the latter is named as a line nothing fires; two parked targets with one exit report only the other', [h26row(exitCard('Blocked-by: #68'), [parked], undefined, REPO_OS) !== '', h26row(exitCard('Blocked-by: #68\nUnlock-action: re-check #987 when label pm:on-hold absent'), [parked], undefined, REPO_OS) !== '', h26row(exitCard('Blocked-by: #68\nUnlock-action: re-check PR #123'), [parked], undefined, REPO_OS).includes('already do (`re-check PR #123`)'), h26row(exitCard(`Blocked-by: #68, #987\n${LIVE_EXIT}`), [parked, tgt(987, ['pm:on-hold'])], undefined, REPO_OS).includes('on 1 target(s)'), h26row(exitCard(`Blocked-by: #68, #987\n${LIVE_EXIT}`), [parked, tgt(987, ['pm:on-hold'])], undefined, REPO_OS).includes('`#987`')].join(), 'true,true,true,true,true'); b(BATTERY19255, 'H26 remedy: the row without an exit prescribes the live spelling and the close, says other spellings fall back silently, and the chain leg carries none of it', [h26row(waiting(75), [parked]).includes('`Unlock-action: re-check #N when label needs-user-decision absent`'), h26row(waiting(75), [parked]).includes('close the waiting card `not planned`'), h26row(waiting(75), [parked]).includes('falls back silently'), h26row(waiting(1395), [tgt(10101, ['pm:blocked'])]).includes('⭐ The exit')].join(), 'true,true,true,false'); - b(BATTERY19255, 'floor: the roster now declares NINE batteries, and this one is on it', SELF_TEST_BATTERY_FLOOR === 9 && Object.hasOwn(SELF_TEST_BATTERIES, BATTERY19255), true); + b(BATTERY19255, 'floor: the roster now declares TEN batteries, and this one is on it', SELF_TEST_BATTERY_FLOOR === 10 && Object.hasOwn(SELF_TEST_BATTERIES, BATTERY19255), true); // -- The UNGATED liveness read + H28: the stale body line (#11747) ---------- // @@ -30205,6 +30256,47 @@ async function selfTest() { t('H38 seat: …while the bare post names seat 1', h38row(seat38(), SEAT_AT_38, claim38(13398, '2026-08-30T07:30:00Z')).includes('seat 1'), true); t('H38 seat: a seat-2 post with no same-seat claim -> clean', h38SeatPostStale(seat38(seat2Title), SEAT_AT_38, newestLaneClaim('domain:services', oneCard, seat1Claim, 2)), null); + // -- Lane label aliases — the domain:server merge (maintainer ruling 2026-10-22, letter 乙) -- + // + // Three lane labels retired INTO `domain:server` keep circulating for a while + // (in-flight cards keep theirs until they land; open cards are relabelled by + // triage after the merge lands; seat posts are retitled at each seat's next + // round), so every reader that compares a card's lane with a seat's goes + // through `canonicalLane`. The controls are the point: a lane the map does + // not name must pass through untouched, and a card carrying an old and the + // new spelling at once is ONE lane, never two. + const BATTERY_SERVER = 'domain:server lane aliases'; + b(BATTERY_SERVER, 'alias: `domain:engine` reads as `domain:server`', canonicalLane('domain:engine'), 'domain:server'); + b(BATTERY_SERVER, 'alias: `domain:services` reads as `domain:server`', canonicalLane('domain:services'), 'domain:server'); + b(BATTERY_SERVER, 'alias: `domain:cli` reads as `domain:server`', canonicalLane('domain:cli'), 'domain:server'); + b(BATTERY_SERVER, 'alias control: `domain:server` is its own canonical form', canonicalLane('domain:server'), 'domain:server'); + b(BATTERY_SERVER, 'alias control: a lane the map does not name passes through untouched', canonicalLane('domain:spec'), 'domain:spec'); + b(BATTERY_SERVER, 'alias control: a non-lane label passes through untouched', canonicalLane('pm:queue'), 'pm:queue'); + b(BATTERY_SERVER, 'alias control: a non-string is handed back, never thrown on', canonicalLane(undefined), undefined); + b(BATTERY_SERVER, 'alias map: frozen, and it names exactly the three retired lanes', [Object.isFrozen(LANE_LABEL_ALIASES), Object.keys(LANE_LABEL_ALIASES).sort().join()], [true, 'domain:cli,domain:engine,domain:services']); + b(BATTERY_SERVER, 'alias map: every value is the one surviving lane', Object.values(LANE_LABEL_ALIASES).every((v) => v === 'domain:server'), true); + // The seat title: a post not yet retitled is the merged lane's seat, number intact. + b(BATTERY_SERVER, 'seat title: a `domain:engine · seat 2` post reads as `domain:server`, seat 2', [seatLane(seat38('[PM seat] domain:engine · seat 2 — 🟢 os-b')).lane, seatLane(seat38('[PM seat] domain:engine · seat 2 — 🟢 os-b')).seat], ['domain:server', 2]); + b(BATTERY_SERVER, 'seat title: a vacant `domain:cli` post reads as `domain:server`', seatLane(seat38('[PM seat] domain:cli — ⏳ vacant')).lane, 'domain:server'); + b(BATTERY_SERVER, 'seat title: …and is not foreign', seatLane(seat38('[PM seat] domain:cli — ⏳ vacant')).foreign, false); + b(BATTERY_SERVER, 'seat title: a retitled `domain:server · seat 3` post reads as itself', [seatLane(seat38('[PM seat] domain:server · seat 3 — 🟢 os-b')).lane, seatLane(seat38('[PM seat] domain:server · seat 3 — 🟢 os-b')).seat], ['domain:server', 3]); + b(BATTERY_SERVER, 'seat title control: a `domain:spec` post is untouched by the alias', seatLane(seat38('[PM seat] domain:spec — 🟢 os-b')).lane, 'domain:spec'); + // The lane claim index, through the alias in BOTH directions. + const engineCard = [laneCard(7, ['pm:dispatched', 'domain:engine'])]; + const serverCard = [laneCard(8, ['pm:dispatched', 'domain:server'])]; + const aliasClaims = new Map([[7, CLAIM_ROW], [8, CLAIM_ROW]]); + b(BATTERY_SERVER, 'claim index: a `domain:server` seat sees the claim on a card still labelled `domain:engine`', newestLaneClaim('domain:server', engineCard, aliasClaims).number, 7); + b(BATTERY_SERVER, 'claim index: a seat still titled `domain:cli` sees the claim on a `domain:server` card', newestLaneClaim('domain:cli', serverCard, aliasClaims).number, 8); + b(BATTERY_SERVER, 'claim index: …and the claim on a sibling retired spelling', newestLaneClaim('domain:services', engineCard, aliasClaims).number, 7); + b(BATTERY_SERVER, 'claim index control: another lane still sees nothing (OR-not-AND holds through the alias)', newestLaneClaim('domain:spec', engineCard, aliasClaims), null); + b(BATTERY_SERVER, 'claim index control: a `pm:queue` card is still not a dispatch claim', newestLaneClaim('domain:server', [laneCard(9, ['pm:queue', 'domain:engine'])], new Map([[9, CLAIM_ROW]])), null); + // The census: one lane per card, whatever its spelling, and never twice. + b(BATTERY_SERVER, 'census: a `domain:cli` card is counted under `domain:server`', censusLanesOf(laneCard(1, ['domain:cli', 'pm:dispatched'])), ['domain:server']); + b(BATTERY_SERVER, 'census: a card carrying an old AND the new spelling is ONE lane, not two', censusLanesOf(laneCard(1, ['domain:engine', 'domain:server'])), ['domain:server']); + b(BATTERY_SERVER, 'census control: a `repo:*` lane passes through untouched', censusLanesOf(laneCard(1, ['repo:hotcrm'])), ['repo:hotcrm']); + b(BATTERY_SERVER, 'census control: a card with no lane still buckets under `(no lane)`', censusLanesOf(laneCard(1, ['pm:queue'])), ['(no lane)']); + b(BATTERY_SERVER, 'floor: this battery is DECLARED on the frozen roster with a positive pin, and the roster floor rose to TEN with it', [Object.hasOwn(SELF_TEST_BATTERIES, BATTERY_SERVER), SELF_TEST_BATTERIES[BATTERY_SERVER] > 0, SELF_TEST_BATTERY_FLOOR === 10, Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR], [true, true, true, true]); + // -- H39 — the closed `pm:*` residue census (#13526, report-only) --------- // // The card's central fence, restated as an assertion rather than as prose: @@ -34164,7 +34256,7 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading. // THE ROSTER — a floor that cannot be satisfied by a zero. b(BATTERY67, 'H67 floor: this battery is DECLARED on the roster', Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, BATTERY67), true); b(BATTERY67, 'H67 floor: …with a positive pin, so an empty battery cannot satisfy it', SELF_TEST_BATTERIES[BATTERY67] > 0, true); - b(BATTERY67, 'H67 floor: the roster grew again with #19255\'s, #18901\'s and #19129\'s batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR, 9); + b(BATTERY67, 'H67 floor: the roster grew again with #19255\'s, #18901\'s and #19129\'s batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR, 10); b(BATTERY67, 'H67 floor: …including the two batteries this row landed BESIDE, so neither side of the base merge silently dropped one', Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, 'H65 tier declaration spelling') && Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, 'H19 judged-set founding'), true); b(BATTERY67, 'H67 floor: …and the roster really carries at least that many', Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR, true); @@ -34507,7 +34599,7 @@ Doubles as the fire's **write self-check** (step 0). \`201\` is not the reading. // FLOOR — this battery is declared, pinned, and the roster grew with it. b(BATTERY68, 'floor: this battery is DECLARED on the roster', Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, BATTERY68), true); b(BATTERY68, 'floor: …with a positive pin, so an empty battery cannot satisfy it', SELF_TEST_BATTERIES[BATTERY68] > 0, true); - b(BATTERY68, 'floor: the roster now declares NINE batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR, 9); + b(BATTERY68, 'floor: the roster now declares TEN batteries, and the floor rose with it', SELF_TEST_BATTERY_FLOOR, 10); b(BATTERY68, 'floor: …including the four this battery landed BESIDE and the one that landed after it, so neither side of the base merge silently dropped one', ['H66 released queue card', 'H19 judged-set founding', 'H65 tier declaration spelling', 'H67 queued merged-delivery reading', 'ISSUE_BODY_LIMIT measured cap'].every((name) => Object.prototype.hasOwnProperty.call(SELF_TEST_BATTERIES, name)), true); b(BATTERY68, 'floor: …and the roster really carries at least that many', Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR, true); diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index e63bec91df6..654a339a942 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -379,7 +379,11 @@ export const CEILINGS = new Map([ // rule line moved verbatim; the ACCEPT path-fork block stays here under // 路径分叉 because check:pm-governed-prose anchors its region in this file. // Landed count, headroom 0, same convention (lowering is always legitimate). - ['.claude/skills/pm-dispatch/SKILL.md', 319], + // Lowered 319 → 318 by the domain:server lane merge (maintainer ruling + // 2026-10-22, letter 乙): the engine, services and cli rows of the 域车道 table + // became one row, and the alias rule for the three retired labels took one + // bullet. Landed count, headroom 0 (lowering is always legitimate). + ['.claude/skills/pm-dispatch/SKILL.md', 318], // Raised 223 → 244 by the triage reading-cost card (maintainer ruling // 2026-08-20, quoted in the raising PR): three mandated conventions land in // the runbook's triage sections. Landed count, headroom 0, same convention. @@ -1321,28 +1325,16 @@ export const CEILINGS = new Map([ // Each −1: whole-text restructuring round, PR-2 (maintainer ruling // 2026-08-23) — the patrol-anchor sentence was carried verbatim by all seven // lanes and now lives once in SKILL.md 执行座位职责, so every lane drops it. - // cli.md pays −2 because it was carrying the map's last line of headroom. - // Lowered 40 → 32 by the rules-only rewrite (maintainer 2026-09-04: - // 「各条规则的出处叙事、事故复盘 根本不重要啊,不需要写入skills啊」) — provenance dates, - // ruling citations and incident narrative left; every rule stayed. Landed count, - // headroom 0, same convention (lowering is always legitimate). - ['.claude/skills/pm-dispatch/references/lanes/engine.md', 32], - // Lowered 30 → 28 and 35 → 31 as the other two SOURCES of the cross-file - // move recorded on platform-readings.md above. A per-repo gate reading in a - // lane charter is read by one lane and missed by the other six, which is the - // whole defect the move repairs: services.md gave up the two aggregate- - // reading boundaries, cli.md the required-check set with its `in_progress` - // and advisory clauses. Landed counts, headroom 0, same convention. - // Lowered 28 → 27 by the rules-only rewrite (maintainer 2026-09-04: - // 「各条规则的出处叙事、事故复盘 根本不重要啊,不需要写入skills啊」) — provenance dates, - // ruling citations and incident narrative left; every rule stayed. Landed count, - // headroom 0, same convention (lowering is always legitimate). - ['.claude/skills/pm-dispatch/references/lanes/services.md', 27], - // Lowered 31 → 29 by the rules-only rewrite (maintainer 2026-09-04: - // 「各条规则的出处叙事、事故复盘 根本不重要啊,不需要写入skills啊」) — provenance dates, - // ruling citations and incident narrative left; every rule stayed. Landed count, - // headroom 0, same convention (lowering is always legitimate). - ['.claude/skills/pm-dispatch/references/lanes/cli.md', 29], + // engine.md 32 + services.md 27 + cli.md 29 → server.md (maintainer ruling + // 2026-10-22, verbatim: 「就叫 server 吧」, then 「同意」 on letter 乙 — the + // engine, services and cli lanes fold into one `domain:server` lane; the + // charter keeps three sections, one per former surface, and a seat reads the + // section its claim's file surface names). A consolidation, not a raise: the + // three per-lane spec red lines became one lane-wide line and the two + // pointers between the former lanes left, so the merged file lands one line + // under the 88 the three rows summed to. A NEW file, so an added row set at + // the landed line count, headroom 0, same convention as above. + ['.claude/skills/pm-dispatch/references/lanes/server.md', 87], // Lowered 39 → 38 by the rules-only rewrite (maintainer 2026-09-04: // 「各条规则的出处叙事、事故复盘 根本不重要啊,不需要写入skills啊」) — provenance dates, // ruling citations and incident narrative left; every rule stayed. Landed count, @@ -1999,8 +1991,16 @@ export const CROSS_FILE_MOVES = new Map([ }, ], sources: [ - ['.claude/skills/pm-dispatch/references/lanes/cli.md', 35], - ['.claude/skills/pm-dispatch/references/lanes/services.md', 30], + // Two of the three original sources, lanes/cli.md (`was` 35) and + // lanes/services.md (`was` 30), merged with lanes/engine.md into + // lanes/server.md on 2026-10-22 (maintainer ruling, letter 乙). The + // merged file carries their place: its `was` is 35 + 30 + 32, the two + // sources' recorded pre-move ceilings plus engine.md's ceiling at the + // merge — engine.md was never a source, so entering it at its own + // ceiling adds nothing to the decrease this move was paid with. The + // 9 lines the two charters had already paid stay counted, and what the + // merge itself deleted is counted once, here. + ['.claude/skills/pm-dispatch/references/lanes/server.md', 97], ['.claude/skills/pm-dispatch/references/rest-channel.md', 93], ], }, @@ -2079,7 +2079,12 @@ export const MAX_TABLE_ROW_BYTES = new Map([ // rows lost their in-cell provenance and rationale; the widest survivor is // the `domain:engine` row. Landed width, headroom 0 (lowering is always // legitimate). - ['.claude/skills/pm-dispatch/SKILL.md', 342], + // Lowered 342 → 341 by the domain:server lane merge (maintainer ruling + // 2026-10-22, letter 乙): the engine, services and cli rows of the 域车道 + // table consolidated into one `domain:server` row that names package + // families and points at lanes/server.md for the enumeration. The widest + // survivor is that row. Landed width, headroom 0. + ['.claude/skills/pm-dispatch/SKILL.md', 341], ['.claude/skills/pm-dispatch/references/dispatch-runbook.md', 0], ['.claude/skills/pm-dispatch/references/state-machine.md', 0], ['.claude/skills/pm-dispatch/references/contract-review.md', 0], @@ -2101,9 +2106,7 @@ export const MAX_TABLE_ROW_BYTES = new Map([ ['.claude/skills/pm-dispatch/references/execution-duties.md', 0], ['.claude/skills/pm-dispatch/references/filing-gate.md', 0], ['.claude/skills/pm-dispatch/references/app-platform-boundary.md', 219], - ['.claude/skills/pm-dispatch/references/lanes/engine.md', 0], - ['.claude/skills/pm-dispatch/references/lanes/services.md', 0], - ['.claude/skills/pm-dispatch/references/lanes/cli.md', 0], + ['.claude/skills/pm-dispatch/references/lanes/server.md', 0], ['.claude/skills/pm-dispatch/references/lanes/devx.md', 0], ['.claude/skills/pm-dispatch/references/lanes/skills.md', 0], ['.claude/skills/pm-dispatch/references/lanes/spec.md', 0], diff --git a/scripts/pm/ensure-pm-labels.sh b/scripts/pm/ensure-pm-labels.sh index 06ecbabefd4..9849bb9928a 100644 --- a/scripts/pm/ensure-pm-labels.sh +++ b/scripts/pm/ensure-pm-labels.sh @@ -50,7 +50,10 @@ # after its own earlier retirement, and identity into domain:services) are # deliberately ABSENT — recreating a retired label puts an ownerless lane back # into GitHub's autocomplete. Their leftover label OBJECTS may still exist in -# the repos; deleting those objects is a separate, deliberate PM action. +# the repos; deleting those objects is a separate, deliberate PM action. One +# TRANSITIONAL exception, recorded at its rows in the domain-lane block below: +# the three lanes that folded into domain:server on 2026-10-22 keep alias rows +# until their last open card lands. # The objectui-repo STREAM labels domain:spec / domain:devx / domain:tooling, # retired in objectui only on 2026-10-22 (maintainer ruling 「甲,现在做」), are # recorded beside the objectui domain:ui row further down, for the reason the @@ -440,9 +443,27 @@ gh label create pm:seat -R objectstack-ai/objectstack -c 1d76db -d "Seat registr # Domain lanes — the roster lives in SKILL.md's domain table; keep both in sync # BY PR whenever a lane is added or retired. -for D in engine services devx spec cli skills; do +# +# domain:server — the engine, services and cli lanes folded into ONE lane +# (maintainer ruling 2026-10-22, verbatim: 「就叫 server 吧」, then 「同意」 on +# letter 乙). Its three former labels are the one TRANSITIONAL exception to the +# header's retired-lanes-are-absent rule: they keep a row each below so that +# `--reconcile` writes the "retired, alias of domain:server" description the +# ruling asks for onto the live objects — in-flight cards keep their old label +# until they land and open cards are relabelled by triage only after the merge +# lands, so the objects stay in use for a while and a reader hovering one must +# learn where it went. The description is the whole point of those rows: +# create-if-missing is a no-op on objects that exist. The readers that keep +# seeing every card through the old spellings live in +# scripts/pm/check-half-states.mjs (LANE_LABEL_ALIASES). The cleanup PR that +# retires the three objects (zero open cards on each) deletes these three rows +# and that map together, and the header's rule resumes for them. +for D in server devx spec skills; do gh label create "domain:$D" -R objectstack-ai/objectstack -c bfd4f2 -d "Domain lane — seat card indexed by label:pm:seat" 2>/dev/null || true done +gh label create domain:engine -R objectstack-ai/objectstack -c bfd4f2 -d "Retired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land" 2>/dev/null || true +gh label create domain:services -R objectstack-ai/objectstack -c bfd4f2 -d "Retired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed" 2>/dev/null || true +gh label create domain:cli -R objectstack-ai/objectstack -c bfd4f2 -d "Retired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land" 2>/dev/null || true # domain:cloud is a PARKING label, not a lane: no seat, never dispatched in this # repo. Cloud-service journeys found in objectstack / objectui keep it until the From 56b333ea247fd5a9d4731e887874d9feabac1ebb Mon Sep 17 00:00:00 2001 From: os-elon-musk Date: Thu, 22 Oct 2026 15:20:29 +0000 Subject: [PATCH 2/6] docs(pm-dispatch): the alias battery compares its composite readings as strings The self-test harness judges `actual === expected`, so the cases of the new `domain:server lane aliases` battery that read an array or a pair now join them to one string before the comparison. Three H38 cases whose fixture lane is the retired `domain:services` spelling expect the merged spelling the readers now return, and the "lane with no dispatched cards" control moves from `domain:engine` (the same lane since the merge) to `domain:skills`. 4941 cases pass; nothing outside the self-test changes. Claude-Session: https://claude.ai/code/session_01VCLHrrmPbwQHZcc2yqdFHD Co-authored-by: Claude --- scripts/pm/check-half-states.mjs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index aa1cad038fd..5620300336a 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -30156,7 +30156,10 @@ async function selfTest() { // The measured specimen: a claim written ~24h after the post's last event. t('H38: a lane claim NEWER than the seat post -> finding', typeof h38SeatPostStale(seat38(), SEAT_AT_38, claim38(13398, '2026-08-30T07:30:00Z')), 'string'); - t('H38: …and the finding names the lane', h38row(seat38(), SEAT_AT_38, claim38(13398, '2026-08-30T07:30:00Z')).includes('domain:services'), true); + // The specimen post is titled `domain:services`; since the 2026-10-22 lane + // merge that spelling is an alias, and the finding names the lane it speaks + // for (the `domain:server lane aliases` battery below holds the controls). + t('H38: …and the finding names the lane, in its merged spelling', h38row(seat38(), SEAT_AT_38, claim38(13398, '2026-08-30T07:30:00Z')).includes('domain:server'), true); t('H38: …and the card whose claim outran the post', h38row(seat38(), SEAT_AT_38, claim38(13398, '2026-08-30T07:30:00Z')).includes('#13398'), true); t('H38: …and states how far behind the post is', h38row(seat38(), SEAT_AT_38, claim38(13398, '2026-08-30T07:09:00Z')).includes('24.0h'), true); // ⛔ The row must not read as an accusation — the card is explicit that both @@ -30201,7 +30204,9 @@ async function selfTest() { t('H38 lane: the newest claim on the lane is found', newestLaneClaim('domain:services', laneIssues, laneComments).number, 13398); t('H38 lane: a claim on ANOTHER lane is not counted (OR-not-AND)', newestLaneClaim('domain:devx', laneIssues, laneComments).number, 13399); t('H38 lane: a `pm:queue` card is not a dispatch claim', newestLaneClaim('domain:services', [laneCard(13400, ['pm:queue', 'domain:services'])], laneComments), null); - t('H38 lane: a lane with no dispatched cards is null', newestLaneClaim('domain:engine', laneIssues, laneComments), null); + // `domain:skills`, not `domain:engine`: since the 2026-10-22 merge the latter + // is the same lane as the fixtures' `domain:services`, so it would be FOUND. + t('H38 lane: a lane with no dispatched cards is null', newestLaneClaim('domain:skills', laneIssues, laneComments), null); t('H38 lane: an unfetched thread contributes nothing', newestLaneClaim('domain:services', laneIssues, new Map()), null); t('H38 lane: the NEWEST of several claims wins', newestLaneClaim('domain:services', [laneCard(1, ['pm:dispatched', 'domain:services']), laneCard(2, ['pm:dispatched', 'domain:services'])], new Map([[1, [{ body: 'Claim: a', created_at: '2026-08-29T00:00:00Z' }]], [2, [{ body: 'Claim: b', created_at: '2026-08-30T00:00:00Z' }]]])).number, 2); // ⛔ The claim predicate is REUSED, not re-spelled: a comment that is not a @@ -30214,7 +30219,8 @@ async function selfTest() { // post is compared only against claims of ITS seat number. The title suffix // and the claim's `Seat:` line are the two readings; both default to seat 1. const seat2Title = '[PM seat] domain:services · seat 2 — 🟢 os-b (session_y)'; - t('H38 seat: a `· seat N` title suffix parses as the LANE, not foreign', seatLane(seat38(seat2Title)).lane, 'domain:services'); + // `domain:server`: the title's `domain:services` is an alias since 2026-10-22. + t('H38 seat: a `· seat N` title suffix parses as the LANE (merged spelling), not foreign', seatLane(seat38(seat2Title)).lane, 'domain:server'); t('H38 seat: …and is not foreign', seatLane(seat38(seat2Title)).foreign, false); t('H38 seat: …and carries the seat number', seatLane(seat38(seat2Title)).seat, 2); t('H38 seat: a bare title is seat 1', seatLane(seat38()).seat, 1); @@ -30273,13 +30279,14 @@ async function selfTest() { b(BATTERY_SERVER, 'alias control: a lane the map does not name passes through untouched', canonicalLane('domain:spec'), 'domain:spec'); b(BATTERY_SERVER, 'alias control: a non-lane label passes through untouched', canonicalLane('pm:queue'), 'pm:queue'); b(BATTERY_SERVER, 'alias control: a non-string is handed back, never thrown on', canonicalLane(undefined), undefined); - b(BATTERY_SERVER, 'alias map: frozen, and it names exactly the three retired lanes', [Object.isFrozen(LANE_LABEL_ALIASES), Object.keys(LANE_LABEL_ALIASES).sort().join()], [true, 'domain:cli,domain:engine,domain:services']); + b(BATTERY_SERVER, 'alias map: frozen', Object.isFrozen(LANE_LABEL_ALIASES), true); + b(BATTERY_SERVER, 'alias map: it names exactly the three retired lanes', Object.keys(LANE_LABEL_ALIASES).sort().join(), 'domain:cli,domain:engine,domain:services'); b(BATTERY_SERVER, 'alias map: every value is the one surviving lane', Object.values(LANE_LABEL_ALIASES).every((v) => v === 'domain:server'), true); // The seat title: a post not yet retitled is the merged lane's seat, number intact. - b(BATTERY_SERVER, 'seat title: a `domain:engine · seat 2` post reads as `domain:server`, seat 2', [seatLane(seat38('[PM seat] domain:engine · seat 2 — 🟢 os-b')).lane, seatLane(seat38('[PM seat] domain:engine · seat 2 — 🟢 os-b')).seat], ['domain:server', 2]); + b(BATTERY_SERVER, 'seat title: a `domain:engine · seat 2` post reads as `domain:server`, seat 2', [seatLane(seat38('[PM seat] domain:engine · seat 2 — 🟢 os-b')).lane, seatLane(seat38('[PM seat] domain:engine · seat 2 — 🟢 os-b')).seat].join(), 'domain:server,2'); b(BATTERY_SERVER, 'seat title: a vacant `domain:cli` post reads as `domain:server`', seatLane(seat38('[PM seat] domain:cli — ⏳ vacant')).lane, 'domain:server'); b(BATTERY_SERVER, 'seat title: …and is not foreign', seatLane(seat38('[PM seat] domain:cli — ⏳ vacant')).foreign, false); - b(BATTERY_SERVER, 'seat title: a retitled `domain:server · seat 3` post reads as itself', [seatLane(seat38('[PM seat] domain:server · seat 3 — 🟢 os-b')).lane, seatLane(seat38('[PM seat] domain:server · seat 3 — 🟢 os-b')).seat], ['domain:server', 3]); + b(BATTERY_SERVER, 'seat title: a retitled `domain:server · seat 3` post reads as itself', [seatLane(seat38('[PM seat] domain:server · seat 3 — 🟢 os-b')).lane, seatLane(seat38('[PM seat] domain:server · seat 3 — 🟢 os-b')).seat].join(), 'domain:server,3'); b(BATTERY_SERVER, 'seat title control: a `domain:spec` post is untouched by the alias', seatLane(seat38('[PM seat] domain:spec — 🟢 os-b')).lane, 'domain:spec'); // The lane claim index, through the alias in BOTH directions. const engineCard = [laneCard(7, ['pm:dispatched', 'domain:engine'])]; @@ -30291,11 +30298,11 @@ async function selfTest() { b(BATTERY_SERVER, 'claim index control: another lane still sees nothing (OR-not-AND holds through the alias)', newestLaneClaim('domain:spec', engineCard, aliasClaims), null); b(BATTERY_SERVER, 'claim index control: a `pm:queue` card is still not a dispatch claim', newestLaneClaim('domain:server', [laneCard(9, ['pm:queue', 'domain:engine'])], new Map([[9, CLAIM_ROW]])), null); // The census: one lane per card, whatever its spelling, and never twice. - b(BATTERY_SERVER, 'census: a `domain:cli` card is counted under `domain:server`', censusLanesOf(laneCard(1, ['domain:cli', 'pm:dispatched'])), ['domain:server']); - b(BATTERY_SERVER, 'census: a card carrying an old AND the new spelling is ONE lane, not two', censusLanesOf(laneCard(1, ['domain:engine', 'domain:server'])), ['domain:server']); - b(BATTERY_SERVER, 'census control: a `repo:*` lane passes through untouched', censusLanesOf(laneCard(1, ['repo:hotcrm'])), ['repo:hotcrm']); - b(BATTERY_SERVER, 'census control: a card with no lane still buckets under `(no lane)`', censusLanesOf(laneCard(1, ['pm:queue'])), ['(no lane)']); - b(BATTERY_SERVER, 'floor: this battery is DECLARED on the frozen roster with a positive pin, and the roster floor rose to TEN with it', [Object.hasOwn(SELF_TEST_BATTERIES, BATTERY_SERVER), SELF_TEST_BATTERIES[BATTERY_SERVER] > 0, SELF_TEST_BATTERY_FLOOR === 10, Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR], [true, true, true, true]); + b(BATTERY_SERVER, 'census: a `domain:cli` card is counted under `domain:server`', censusLanesOf(laneCard(1, ['domain:cli', 'pm:dispatched'])).join(), 'domain:server'); + b(BATTERY_SERVER, 'census: a card carrying an old AND the new spelling is ONE lane, not two', censusLanesOf(laneCard(1, ['domain:engine', 'domain:server'])).join(), 'domain:server'); + b(BATTERY_SERVER, 'census control: a `repo:*` lane passes through untouched', censusLanesOf(laneCard(1, ['repo:hotcrm'])).join(), 'repo:hotcrm'); + b(BATTERY_SERVER, 'census control: a card with no lane still buckets under `(no lane)`', censusLanesOf(laneCard(1, ['pm:queue'])).join(), '(no lane)'); + b(BATTERY_SERVER, 'floor: this battery is DECLARED on the frozen roster with a positive pin, and the roster floor rose to TEN with it', [Object.hasOwn(SELF_TEST_BATTERIES, BATTERY_SERVER), SELF_TEST_BATTERIES[BATTERY_SERVER] > 0, SELF_TEST_BATTERY_FLOOR === 10, Object.keys(SELF_TEST_BATTERIES).length >= SELF_TEST_BATTERY_FLOOR].join(), 'true,true,true,true'); // -- H39 — the closed `pm:*` residue census (#13526, report-only) --------- // From c5c17359be5db4bd08a06b228f8550df204b3faa Mon Sep 17 00:00:00 2001 From: os-elon-musk Date: Thu, 22 Oct 2026 15:24:52 +0000 Subject: [PATCH 3/6] docs(pm-dispatch): SKILL.md ceiling follows the line #25240 paid down Merging `main` brought #25240 (the claim template's `Metadata-protocol:` line), which landed one line under SKILL.md's ceiling and left the ceiling alone. With this card's row already in the ledger, the ceiling takes the landed count: 318 -> 317, headroom 0. Nothing else changes. Claude-Session: https://claude.ai/code/session_01VCLHrrmPbwQHZcc2yqdFHD Co-authored-by: Claude --- scripts/pm/check-skill-line-ratchet.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 654a339a942..2b0af2c35bb 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -382,8 +382,12 @@ export const CEILINGS = new Map([ // Lowered 319 → 318 by the domain:server lane merge (maintainer ruling // 2026-10-22, letter 乙): the engine, services and cli rows of the 域车道 table // became one row, and the alias rule for the three retired labels took one - // bullet. Landed count, headroom 0 (lowering is always legitimate). - ['.claude/skills/pm-dispatch/SKILL.md', 318], + // bullet. Then 318 → 317: #25240 (landed 2026-10-22, the claim template's + // `Metadata-protocol:` line) had paid one line more than it took and left + // the ceiling where it was; this ledger edit, merging `main` behind it, + // takes the ceiling down to the landed count. Headroom 0 (lowering is + // always legitimate). + ['.claude/skills/pm-dispatch/SKILL.md', 317], // Raised 223 → 244 by the triage reading-cost card (maintainer ruling // 2026-08-20, quoted in the raising PR): three mandated conventions land in // the runbook's triage sections. Landed count, headroom 0, same convention. From d062675f2edb243ac0691141a5e44a89dac97f36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:30:24 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(pm):=20board-snapshot=20=E2=80=94=20a?= =?UTF-8?q?=20blank=20listing=20page=20is=20not=20a=20reading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Board Snapshot check went red on this PR (and on every scheduled run since 2026-10-21, #24351) with a count-check SHORTFALL of the whole board: the open walk's first listing page answered `200 []`, the tool read it as the short page, declared the open set complete at zero records, and compared zero with the board's own count. Measured 2026-10-22: the issues listing answers blank to more than half of the requests made to it (39 of 68 probes), each with its own request id and its own count against the rate limit, so no cache is involved; cache directives, a cache-busting query, sort, direction, page size, state and since make no difference, and objectui and hotcrm answer the same way. `/repos/{repo}`, the pulls listing and the per-number comment pages never blanked. So a blank listing page is asked again, same URL and page size, up to `BLANK_PAGE_ASKS` (6) times, each ask a budgeted request and never a sleep. A blank that survives the bound is acted on by where the walk is (`blankPageStep`): past an anchor it is believed, because the cursor does not advance and the next run re-reads from it; from the beginning it is believed only if the board's own `open_issues_count`, read once as the witness, is zero — otherwise the walk stops with its cursor (`stopped_by: blank-listing`), nothing is marked complete, the count check stays pending and the run exits 0 as a planned stop, which the freshness row turns red if it becomes a streak. The manifest counts the re-asks in `run.blank_pages_reasked`; the summary prints the count and the stop. A new header section records the measurement. Self-test: a new battery of 13 cases (the pure verdicts, the measured failure healed on a fake board that blanks once, the bounded stop, the anchored blank believed with its cursor unmoved), floor 12 -> 13, 169 cases. The PR leg's own arguments (`--dry-run --limit=5 --max-requests=25`) ran six times against the live board: six exits 0, one of them a blank-listing stop and two after re-asks. Claude-Session: https://claude.ai/code/session_01VCLHrrmPbwQHZcc2yqdFHD Co-authored-by: Claude --- scripts/pm/board-snapshot.mjs | 150 +++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 4 deletions(-) diff --git a/scripts/pm/board-snapshot.mjs b/scripts/pm/board-snapshot.mjs index cb625f09651..35ee6521904 100644 --- a/scripts/pm/board-snapshot.mjs +++ b/scripts/pm/board-snapshot.mjs @@ -184,6 +184,48 @@ * never sleeps and never retries the request — a loop against a zero budget is * how one repository's automation starves every other caller of that budget. * + * ## A blank page is not a reading — measured 2026-10-22 + * + * Since 2026-10-21 the issues listing of this board answers `200 []` to more + * than half of the requests made to it: 39 of 68 probes on 2026-10-22, each + * with its own `x-github-request-id` and its own count against the rate limit, + * so no cache was involved; `cache-control: no-cache`, a cache-busting query, + * `direction`, `sort`, `per_page`, `state` and a `since` made no difference, + * and objectui and hotcrm answered the same way. `/repos/{repo}`, the pulls + * listing and the per-number comment pages never did (#24351). This tool read + * a blank first page as the short page, declared the open set complete at ZERO + * records, and the count check reported a SHORTFALL of the whole board — the + * PR leg's 12 requests were exactly a blank open listing followed by the + * history walk. + * + * So a listing page that answers EMPTY is not believed on sight. It is asked + * again — the same URL, the same page size, no sleep, each ask a request of + * the budget — up to `BLANK_PAGE_ASKS` times, and a blank that survives the + * bound is acted on by WHERE the walk is (`blankPageStep`): + * + * past an anchor (`since` set, or an offset page) it is believed. The + * cursor does not advance on a blank page, so a blank + * that was false costs one run and nothing permanent: + * the next run re-reads from the same inclusive cursor. + * from the beginning (no `since`, page 1) it is believed only if the board + * itself reports no open item — `open_issues_count` of + * `/repos/{repo}`, read once as the witness when the + * first page is blank. A board that reports open items + * cannot answer a blank first page, so the walk STOPS + * with its cursor (`stopped_by: blank-listing`): nothing + * is marked complete, the count check stays `pending`, + * and the run exits 0 as a planned stop. The next run + * asks again, and a streak of such runs is what the + * freshness row turns red. Believing it would end a + * phase at zero records — the measured failure. + * + * ⛔ This is not the retry the section above forbids: it re-asks a `200` that + * carried no rows, never a refusal; it is bounded by the constant and by the + * budget; and it never sleeps. `run.blank_pages_reasked` counts the re-asks + * and the summary prints the count when it is not zero. The per-number reads + * (comments, reviews) are not re-asked: an empty page is their ordinary + * answer, and they were not seen to blank. + * * ## The count check, and why a mismatch is red rather than a warning * * `rest-channel.md`: an enumeration without a count check is not a reading. So @@ -562,6 +604,29 @@ export function nextWalkStep({ batchLength, lastUpdatedAt, since, page, perPage return { done: false, since, page: page + 1, reanchored: false }; } +/** How many times one listing page is asked before a blank answer is acted on. */ +export const BLANK_PAGE_ASKS = 6; + +/** + * What the walk does with a listing page that answered EMPTY (§ A blank page + * is not a reading). `asks` counts the asks of THIS page so far, blank every + * time; `boardOpenItems` is the board's own `open_issues_count` (issues and + * pull requests), or null when it was not read. + * + * ask-again below the bound: the same page once more. + * believe at the bound, where a blank CAN be the board's answer — past an + * anchor, where the cursor stays put and the next run re-reads. + * stop at the bound, from the BEGINNING of a board that reports open + * items (or whose count is unknown): believing it would end a + * phase at zero records. + */ +export function blankPageStep({ asks, since, page, boardOpenItems, bound = BLANK_PAGE_ASKS }) { + if (asks < bound) return { action: 'ask-again' }; + const fromTheBeginning = since === null && page === 1; + if (fromTheBeginning && boardOpenItems !== 0) return { action: 'stop' }; + return { action: 'believe' }; +} + export const WALK_PHASES = Object.freeze(['open', 'history', 'incremental']); /** @@ -1238,6 +1303,7 @@ async function snapshot(repo, options) { // the history walk at a delta cursor. let stoppedIn = null; let walkComplete = false; + let blankPagesReasked = 0; let board = { open_issues_count: null, open_pull_requests: null, read_at: null }; /** @@ -1248,10 +1314,32 @@ async function snapshot(repo, options) { async function walk(key, state, walkBudget = budget) { let since = cursors[key]; let page = 1; + let blankAsks = 0; + let witness; for (;;) { walkBudget.spend(); const batch = await rest(listingPath(repo, { since, page, state })); const rows = Array.isArray(batch) ? batch : []; + if (rows.length === 0) { + // § A blank page is not a reading. The witness — the board's own open + // count — is read once per walk, and only when a first page is blank. + blankAsks += 1; + if (since === null && page === 1 && witness === undefined) { + walkBudget.spend(); + witness = (await rest(`/repos/${repo}`))?.open_issues_count ?? null; + } + const verdict = blankPageStep({ asks: blankAsks, since, page, boardOpenItems: witness ?? null }); + if (verdict.action === 'ask-again') { + blankPagesReasked += 1; + continue; + } + if (verdict.action === 'stop') { + stopped = { kind: 'blank-listing', reason: `the ${key} listing answered EMPTY ${blankAsks} times from the beginning while the board reports ${witness ?? 'an unknown number of'} open item(s)` }; + return false; + } + } else { + blankAsks = 0; + } for (const raw of rows) { if (seen.has(raw.number)) continue; if (options.limit && seen.size >= options.limit) { @@ -1439,6 +1527,7 @@ async function snapshot(repo, options) { delta_ran: deltaRan, delta_completed_here: deltaCompletedHere, delta_requests: deltaRequests, + blank_pages_reasked: blankPagesReasked, }, }); @@ -1482,7 +1571,7 @@ export function renderRun(result, options) { ` open set ${openLine}`, ` delta ${deltaLine}`, ` since ${m.since ?? '(from the beginning)'} — ${m.since_reason}`, - ` read ${m.run.numbers_read} number(s) in ${m.requests} request(s); walk ${m.run.walk_complete ? 'complete' : 'INCOMPLETE'}`, + ` read ${m.run.numbers_read} number(s) in ${m.requests} request(s); walk ${m.run.walk_complete ? 'complete' : 'INCOMPLETE'}${m.run.blank_pages_reasked ? `; ${m.run.blank_pages_reasked} blank page(s) asked again` : ''}`, ` written ${result.written.length} file(s)${result.manifestMoved ? ' + manifest' : ''}`, ` archive ${m.counts.records} record(s): ${m.counts.issues_open} open / ${m.counts.issues_closed} closed issue(s), ${m.counts.pulls_open} open / ${m.counts.pulls_closed} closed pull request(s)`, ]; @@ -1519,6 +1608,12 @@ export function renderRun(result, options) { ` STOPPED rate limit: ${result.stopped.reason}`, ` Nothing was retried. The next run resumes the ${m.resume.phase} walk at ${m.resume.since}.`, ); + } else if (result.stopped?.kind === 'blank-listing') { + lines.push( + ` STOPPED ${result.stopped.reason} — a blank page is not a reading.`, + ' Nothing was believed and nothing was marked complete; the count check stays pending. The next run asks again,', + ` ${m.resume.since ? `resuming the ${m.resume.phase} walk at ${m.resume.since}` : `from the beginning of the ${m.resume.phase} walk`}.`, + ); } else if (result.stopped) { lines.push( ` paused ${result.stopped.reason} — this is a planned stop, not a failure.`, @@ -1816,8 +1911,11 @@ function rearmThroughProxy(args) { * because those are what the walk is steering by — a stub that ignored them * would agree with any walk order at all. */ -function fakeBoard({ rows, openPulls = [] }) { +function fakeBoard({ rows, openPulls = [], blankListings = 0 }) { const calls = []; + // The blank reply measured 2026-10-22, reproduced: the first N listing GETs + // answer `[]` whatever the board holds. The witness and the pulls do not. + let blanksLeft = blankListings; const serve = (path) => { if (path === '/repos/o/r') return { open_issues_count: rows.filter((r) => r.state === 'open').length }; const url = new URL(`https://board${path}`); @@ -1829,6 +1927,10 @@ function fakeBoard({ rows, openPulls = [] }) { if (thread) return page === 1 ? (rows.find((r) => r.number === Number(thread[1]))?.thread ?? []) : []; if (/^\/repos\/o\/r\/pulls\/\d+\/(reviews|comments)$/.test(url.pathname)) return []; if (url.pathname !== '/repos/o/r/issues') return []; + if (blanksLeft > 0) { + blanksLeft -= 1; + return []; + } const sinceAt = q.get('since') ? Date.parse(q.get('since')) : null; const matching = rows .filter((r) => (q.get('state') === 'open' ? r.state === 'open' : true)) @@ -1907,8 +2009,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the rate-limit stop, and the two properties that are structural': 8, 'the delta walk, driven: the archive catches up with the live board': 19, 'the freshness row: a stale archive is never a green run': 18, + 'the blank page: asked again, then believed or stopped by where the walk is': 13, }); -const SELF_TEST_BATTERY_FLOOR = 12; +const SELF_TEST_BATTERY_FLOOR = 13; const UNATTRIBUTED_BATTERY = '(unattributed)'; let selfTestReachedVerdict = false; @@ -2244,6 +2347,9 @@ export async function selfTest() { const idemDir = mkdtempSync(join(tmpdir(), 'board-snapshot-idem-')); const quietDir = mkdtempSync(join(tmpdir(), 'board-snapshot-quiet-')); const staleDir = mkdtempSync(join(tmpdir(), 'board-snapshot-stale-')); + const blankDir = mkdtempSync(join(tmpdir(), 'board-snapshot-blank-')); + const blankStopDir = mkdtempSync(join(tmpdir(), 'board-snapshot-blank-stop-')); + const plainDir = mkdtempSync(join(tmpdir(), 'board-snapshot-plain-')); try { // THE REGRESSION, driven from the manifest board-archive actually carried. // @@ -2480,8 +2586,44 @@ export async function selfTest() { t('…while the streak stamp it carries forward is the one the previous manifest opened, never this run\'s', staleRun.manifest.freshness.pending_since === readJsonFile(join(staleDir, MANIFEST_NAME)).freshness.pending_since && Date.parse(staleRun.manifest.freshness.pending_since) < Date.parse(staleRun.manifest.generated_at) - STALE_AFTER_MS); + + // -- the blank page ------------------------------------------------------ + battery('the blank page: asked again, then believed or stopped by where the walk is'); + const B = BLANK_PAGE_ASKS; + t('below the bound a blank page is asked again — from the beginning of a board that reports open items', blankPageStep({ asks: 1, since: null, page: 1, boardOpenItems: 118 }).action === 'ask-again'); + t('…and past an anchor alike', blankPageStep({ asks: B - 1, since: '2026-10-21T00:00:00Z', page: 1, boardOpenItems: null }).action === 'ask-again'); + t('at the bound, past an anchor, it is believed — the cursor stays put and the next run re-reads from it', blankPageStep({ asks: B, since: '2026-10-21T00:00:00Z', page: 1, boardOpenItems: 118 }).action === 'believe'); + t('…and on an offset page alike', blankPageStep({ asks: B, since: null, page: 3, boardOpenItems: 118 }).action === 'believe'); + t('at the bound, from the BEGINNING of a board that reports open items, the walk STOPS — believing it would end a phase at zero records', blankPageStep({ asks: B, since: null, page: 1, boardOpenItems: 118 }).action === 'stop'); + t('…and so does a board whose count is unknown: a blank is believed only where it can be true', blankPageStep({ asks: B, since: null, page: 1, boardOpenItems: null }).action === 'stop'); + t('…while a board that reports no open item can answer a blank first page, and is believed', blankPageStep({ asks: B, since: null, page: 1, boardOpenItems: 0 }).action === 'believe'); + const smallOpen = Array.from({ length: 3 }, (_, i) => RAW({ number: 30000 + i, state: 'open', comments: 0, updated_at: `2026-10-2${i}T08:00:00Z` })); + const smallRows = [...smallOpen, oldClosed]; + const blankOnce = fakeBoard({ rows: smallRows, blankListings: 1 }); + const healed = await driveSnapshot({ dir: blankDir, board: blankOnce }); + t('THE MEASURED FAILURE, HEALED: a first listing that answers [] no longer ends the open phase at zero — the second ask sees the rows, the open set completes with every one of them and the count check reads OK', + healed.manifest.walk.open_set.complete === true && healed.manifest.counts.issues_open === smallOpen.length && healed.manifest.count_check.verdict === 'ok' && healed.manifest.run.blank_pages_reasked === 1); + t('…the witness was read between the two asks, and the second ask is the SAME request: same URL, same page size', + blankOnce.calls[1] === '/repos/o/r' && blankOnce.calls[2] === blankOnce.calls[0] && blankOnce.calls[0].includes(`per_page=${PER_PAGE}`)); + const blankAlways = fakeBoard({ rows: smallRows, blankListings: 99 }); + const halted = await driveSnapshot({ dir: blankStopDir, board: blankAlways }); + t('a listing that stays blank from the beginning STOPS the open walk: nothing is marked complete and the count check stays pending', + halted.stopped?.kind === 'blank-listing' && halted.manifest.walk.open_set.complete === false && halted.manifest.count_check.verdict === 'pending' && halted.manifest.resume?.stopped_by === 'blank-listing' && halted.manifest.resume?.phase === 'open'); + t('…bounded to the request: exactly BLANK_PAGE_ASKS listings and one witness read, never a loop', + blankAlways.calls.filter((c) => c.startsWith('/repos/o/r/issues?')).length === B && halted.manifest.requests === B + 1); + const haltedRendered = renderRun(halted, { out: blankStopDir, dryRun: true }); + t('…and the run says so and exits 0 — a planned stop the next run resumes, which the freshness row turns red if it becomes a streak', + haltedRendered.exitCode === EXIT_OK && /STOPPED the open listing answered EMPTY/.test(haltedRendered.text) && /a blank page is not a reading/.test(haltedRendered.text)); + const plain = fakeBoard({ rows: smallRows }); + const unpaid = await driveSnapshot({ dir: plainDir, board: plain }); + const quietBlank = fakeBoard({ rows: smallRows, blankListings: 99 }); + const believed = await driveSnapshot({ dir: plainDir, board: quietBlank }); + t('a board that answers rows pays nothing, and past an anchor a blank that survives the bound is believed: the incremental walk completes with its cursor UNMOVED, so the next run re-reads from it', + unpaid.manifest.run.blank_pages_reasked === 0 && !/asked again/.test(renderRun(unpaid, { out: plainDir, dryRun: true }).text) + && believed.stopped === null && believed.manifest.walk_phase === 'incremental' && believed.manifest.run.walk_complete === true + && believed.manifest.run.blank_pages_reasked === B - 1 && believed.manifest.next_since === unpaid.manifest.next_since); } finally { - for (const d of [liveDir, busyDir, stopDir, idemDir, quietDir, staleDir]) rmSync(d, { recursive: true, force: true }); + for (const d of [liveDir, busyDir, stopDir, idemDir, quietDir, staleDir, blankDir, blankStopDir, plainDir]) rmSync(d, { recursive: true, force: true }); } const declared = Object.keys(SELF_TEST_BATTERIES); From 1e658c5779532b39ea0b5761f3d8c40b690527ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:40:51 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(pm):=20board-snapshot=20=E2=80=94=20sa?= =?UTF-8?q?y=20when=20a=20blank-listing=20streak=20ages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's blank-page section said a streak of blank-listing stops is what the freshness row turns red. That holds once the open set is complete; an archive still in its open phase reads `building` and never ages (§ Stale is not green), which is the PR leg's fresh dry-run archive and not `main`'s — its board-archive manifest has been in the incremental phase since 2026-09-10, open set and history complete. The sentence now says so. Text only; the self-test is unchanged at 169. Claude-Session: https://claude.ai/code/session_01VCLHrrmPbwQHZcc2yqdFHD Co-authored-by: Claude --- scripts/pm/board-snapshot.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/pm/board-snapshot.mjs b/scripts/pm/board-snapshot.mjs index 35ee6521904..142773c1509 100644 --- a/scripts/pm/board-snapshot.mjs +++ b/scripts/pm/board-snapshot.mjs @@ -215,9 +215,14 @@ * with its cursor (`stopped_by: blank-listing`): nothing * is marked complete, the count check stays `pending`, * and the run exits 0 as a planned stop. The next run - * asks again, and a streak of such runs is what the - * freshness row turns red. Believing it would end a - * phase at zero records — the measured failure. + * asks again. Once the open set is complete, a streak + * of such runs is what the freshness row turns red; an + * archive still in its open phase reads `building` and + * never ages (§ Stale is not green) — the PR leg's + * fresh dry-run archive, not `main`'s, which has been + * incremental since 2026-09-10. Believing the blank + * would end a phase at zero records — the measured + * failure. * * ⛔ This is not the retry the section above forbids: it re-asks a `200` that * carried no rows, never a refusal; it is bounded by the constant and by the From 535703354bae425f732a9a90e0f13c4f15e180fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:42:11 +0000 Subject: [PATCH 6/6] docs(pm-dispatch): SKILL.md ceiling follows the line #25348 landed Merging `main` brought #25348 (every autonomous-adjudication channel revoked), which spent the last line of SKILL.md's 319 ceiling on the pointer line binding every seat to the human floor. This branch's net is still -1 on that file, so the merged tree lands at 318 and the ceiling takes the landed count: 317 -> 318, which is a lowering from the 319 `main` carries, not a raise. The widest-row pin stays at 341, the measured widest row. Nothing else changes. Claude-Session: https://claude.ai/code/session_01VCLHrrmPbwQHZcc2yqdFHD Co-authored-by: Claude --- scripts/pm/check-skill-line-ratchet.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 6174f6bfe76..3f380ba8293 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -385,9 +385,13 @@ export const CEILINGS = new Map([ // bullet. Then 318 → 317: #25240 (landed 2026-10-22, the claim template's // `Metadata-protocol:` line) had paid one line more than it took and left // the ceiling where it was; this ledger edit, merging `main` behind it, - // takes the ceiling down to the landed count. Headroom 0 (lowering is - // always legitimate). - ['.claude/skills/pm-dispatch/SKILL.md', 317], + // takes the ceiling down to the landed count. Then 317 → 318, which is still + // a lowering: #25348 (landed 2026-10-23, every autonomous-adjudication + // channel revoked) spent the last line of its 319 ceiling on the pointer + // line that binds every seat to the human floor; this branch, merged behind + // it, lands at 319 − 1 = 318, so the ceiling is the landed count once more. + // Headroom 0 (lowering is always legitimate). + ['.claude/skills/pm-dispatch/SKILL.md', 318], // Raised 223 → 244 by the triage reading-cost card (maintainer ruling // 2026-08-20, quoted in the raising PR): three mandated conventions land in // the runbook's triage sections. Landed count, headroom 0, same convention.