diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 07553435fb..d332524308 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 fde20bf74f..0000000000 --- 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 29006cc363..0000000000 --- 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 0000000000..91c42c37b6 --- /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 1f3fd84cf4..0000000000 --- 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 e5bf47e076..23c92d54c9 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/board-snapshot.mjs b/scripts/pm/board-snapshot.mjs index cb625f0965..142773c150 100644 --- a/scripts/pm/board-snapshot.mjs +++ b/scripts/pm/board-snapshot.mjs @@ -184,6 +184,53 @@ * 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. 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 + * 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 +609,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 +1308,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 +1319,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 +1532,7 @@ async function snapshot(repo, options) { delta_ran: deltaRan, delta_completed_here: deltaCompletedHere, delta_requests: deltaRequests, + blank_pages_reasked: blankPagesReasked, }, }); @@ -1482,7 +1576,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 +1613,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 +1916,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 +1932,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 +2014,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 +2352,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 +2591,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); diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 23a11e2534..5620300336 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) ---------- // @@ -30105,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 @@ -30150,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 @@ -30163,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); @@ -30205,6 +30262,48 @@ 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', 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].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].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'])]; + 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'])).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) --------- // // The card's central fence, restated as an assertion rather than as prose: @@ -34164,7 +34263,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 +34606,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 73b21e631c..3f380ba829 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -379,7 +379,19 @@ 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. 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. 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. @@ -1326,28 +1338,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, @@ -2007,8 +2007,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], ], }, @@ -2087,7 +2095,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], @@ -2109,9 +2122,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 06ecbabefd..9849bb9928 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