From 28d59115962f1af8905a4e6a09c35b6c43632758 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:18:15 +0000 Subject: [PATCH 1/2] pm-ratchet: meter table-row bytes per file, close the Han+ASCII-punct soft breaks, cover compile-surfaces.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cards against one serial unit, scripts/pm/check-skill-line-ratchet.mjs. #11947 — per-file max-table-row-bytes pins, shrink-only, seeded at each file's own widest table row read from the script's own scanner. Closes the largest unmetered channel the 120-byte line rule leaves open: a table row grows by widening a cell, which costs zero lines and is exempt by shape. #12098 — a CEILINGS row for references/compile-surfaces.md, the last uncovered pm-dispatch references file. Count read from the ratchet's own run (26). #12081 — the 34 measured soft breaks sitting after an ASCII , ; : between Han characters are merged (each rendered as a stray mid-sentence space), and wrapLine no longer offers that break at all. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- .claude/agents/os-dev.md | 8 +- .claude/skills/checklist-author/SKILL.md | 4 +- .claude/skills/checklist-test/SKILL.md | 4 +- .claude/skills/pm-dispatch/SKILL.md | 40 +-- .../references/decision-analysis.md | 10 +- .../references/dispatch-runbook.md | 12 +- .../references/landing-operations.md | 4 +- .../pm-dispatch/references/lanes/cli.md | 4 +- .../pm-dispatch/references/lanes/devx.md | 8 +- .../pm-dispatch/references/lanes/skills.md | 4 +- .../references/release-aftercare.md | 8 +- .../references/review-checklist.md | 4 +- .../references/seat-post-protocol.md | 4 +- .../pm-dispatch/references/true-green.md | 4 +- .../skills/spec-property-retirement/SKILL.md | 20 +- scripts/pm/check-skill-line-ratchet.mjs | 310 +++++++++++++++++- 16 files changed, 374 insertions(+), 74 deletions(-) diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index a28602f712..b52b7fea39 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -41,8 +41,8 @@ model: opus 了。 探针遇 403 ⇒ 停下报 `blocked`,不进重试循环;只有网络错误才值得退避重试。**Scratchpad 按 issue 隔离**:在 scratchpad 目录下建 `issue-/` 子目录,所有临时文件写进去——同批 - agents 共用一个 scratchpad 目录,自然命名(`pr-body.md`)会被彼此的成功回执静默覆盖; - 靠结构隔离,不靠记性。 + agents 共用一个 scratchpad 目录,自然命名(`pr-body.md`)会被彼此的成功回执静默覆盖;靠结构隔 + 离,不靠记性。 2. **issue 已由 PM 认领**(大家共享同一 GitHub 身份)。不要动 assignee;若发现与他人在途 工作重复,停下报 `blocked`。**PR 上不是你设置的状态属于另一个 actor——去问,永不去 「纠正」**:共享身份让所有人的写入都像你写的;被改写的 body 只是关于 body 的证据,不证 @@ -110,8 +110,8 @@ model: opus 6. **整条流水线在前台跑。** build 与 test 都是本任务的步骤:阻塞运行、读真实输出、继续。 ⛔ 永不把验证挂在后台 watcher 上然后停轮(禁令与两种合法终态见「干净收尾」)。 **平台事实(2026-08-20 实测):容器把前台命令钉在 ~10 分钟上限,超时 SIGTERM 杀掉 - (`exit 143`,日志常常连一个发现都没写出)。** 这不改前台纪律,它划定前台里放什么: - 重活走规则 1 的锁 —— 串行化之后不再与并行 build 抢 CPU,争用下顶到上限的命令轻载只 + (`exit 143`,日志常常连一个发现都没写出)。** 这不改前台纪律,它划定前台里放什么:重活走 + 规则 1 的锁 —— 串行化之后不再与并行 build 抢 CPU,争用下顶到上限的命令轻载只 要 ~2 分钟;仓级扫描归 CI(见「本地验证范围」);消融/变异脚本自带还原 trap(硬线在「标 准条款」的 ablation 条)。 diff --git a/.claude/skills/checklist-author/SKILL.md b/.claude/skills/checklist-author/SKILL.md index 507e79b4ae..030eed035a 100644 --- a/.claude/skills/checklist-author/SKILL.md +++ b/.claude/skills/checklist-author/SKILL.md @@ -58,5 +58,5 @@ metadata: **没有子代理工具时,按角度顺序跑 —— 并在交付里声明这一轮是顺序执行的。** 会话里不 存在 Task/子代理工具是允许的退化路径,不是阻塞;五个角度仍要逐个走完,一个都不省。 但 SWEEP.md 的全部主张是「不同角度捕获不同的遗漏类,**因为读者彼此独立**」:塌缩成 -一个读者后,补上的测试项依然成立,**「没有别的遗漏」这个结论不再成立** —— 不声明, -就没人分得清一轮降级的 sweep 和一轮完整的 sweep。 +一个读者后,补上的测试项依然成立,**「没有别的遗漏」这个结论不再成立** —— 不声明,就 +没人分得清一轮降级的 sweep 和一轮完整的 sweep。 diff --git a/.claude/skills/checklist-test/SKILL.md b/.claude/skills/checklist-test/SKILL.md index aafa077a47..4f3d33d32e 100644 --- a/.claude/skills/checklist-test/SKILL.md +++ b/.claude/skills/checklist-test/SKILL.md @@ -146,8 +146,8 @@ expected-fail 探针 —— 永不在真实缺陷上把条款打成绿。 挂 `qa-run`,正常进分诊首触。清单准确性发现与 fixture 缺口归波次锚卡(sweep 跟踪 issue)收口,⛔ 不抽取;环境阻塞记录在案即可。逐条 `fail` 的四分法处置、收口评论的 机器行与抽取卡的溯源行见 §5。⛔ 认证 / 授权漏洞的抽取卡不写复现,见 Guardrails。 -- **完成判据** —— 一个区只有在**每一项都有判定**时才算跑完。跑不完的一轮照常立单: - 交出已有的判定,**外加一份点名未触达项的交接清单**,标题按上面的分数如实写。 +- **完成判据** —— 一个区只有在**每一项都有判定**时才算跑完。跑不完的一轮照常立 + 单:交出已有的判定,**外加一份点名未触达项的交接清单**,标题按上面的分数如实写。 `not-run` 是诚实判定 —— 判 1 项、11 项没跑就写 `(1/12) · … · 11 NOT-RUN`。 同一份逐条款表 + 环境准备与测试的耗时之比,回报给维护者(chat),并链接已立的 issue。 diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 3d7e3004b6..fed4e98899 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -138,9 +138,9 @@ fire 一轮烟测,判据取**GitHub 上的产出**;③ 每 fire 一轮:读座位 - **写后回读按风险定向;标签写恒为硬步骤 read-modify-write + 回读**(维护者 2026-08-18 裁定):① 先取现集 → ② 只增删目标标签 → ③ 写合并集 → ④ 写后回读核验 —— 整组 PUT 写的是 本席快照,并发席落在你读与写之间的标签被静默剥掉,受害者常是你没打算碰的那枚;**承 - 载闸门语义的标签**(如 `needs:contract-review`)挂与清两向同此四步,闸门被剥不是红灯是放行, - 与「从未挂过」在证据上不可区分,回读是唯一察觉手段;多席可写面恒读回(防竞态覆盖,不 - 只防 sanitizer);「API 返回 200」≠「落地内容正确」。 + 载闸门语义的标签**(如 `needs:contract-review`)挂与清两向同此四步,闸门被剥不是红灯是放 + 行,与「从未挂过」在证据上不可区分,回读是唯一察觉手段;多席可写面恒读回(防竞态覆 + 盖,不只防 sanitizer);「API 返回 200」≠「落地内容正确」。 - **PM 写进 GitHub 的文本 ⛔ 不用尖括号路径占位符**,改写成「后跟显式路径」的说法 —— 被 sanitizer 吃掉后剩下的裸命令读起来完整而其实不是(实测:一条派发令就 在「永不用裸形式」之前展示了它要禁的那条裸形式);backtick、围栏块与标题同吃,都不是 @@ -313,8 +313,8 @@ skills,类似分诊」)—— 贴内指针指向其车道文件,升级走技能 正文 + 晚于正文最后编辑的评论(维护者 2026-08-12 批准;更早的已被吸收,是存档不是现状),中 途状态 由卡上 claim/ACCEPT 评论承载。**热文件串行队是正文具名段**(区域写不清就只能整文件串 -行)。无心跳, -活性惰性判定;竞态三招 —— 动手前重 fetch 正文、审计评论时间戳先到先得、写后回 +行)。无心跳,活性惰性判定;竞态三招 —— 动手前重 fetch 正文、审计评论时间戳先到先得、 +写后回 读。**换班报告默 认零建议**(维护者 2026-08-12 裁定,原话:「还有各车道下班时会提交 skills 建议有必要吗?就是那些 搞得后来skills越来越乱。」):强制建议清单已退役,离任报告只收三类 —— 原则错/缺(→ skills 席专题) @@ -336,8 +336,8 @@ skills,类似分诊」)—— 贴内指针指向其车道文件,升级走技能 收过不过得去?):in-scope ⇒ 挂父单sub-issue 下轮自动入队;顺带发现 ⇒ 独立立单进修复落地 仓 backlog(查重先行,⛔ 不借sub-issue 通道把未分诊的塞进池子);触 spec/公共契约 ⇒ 转 spec 座 位 -队列 + epic 侧写`Blocked-by:`;每次分流留一行审计评论。进度视图:父单维护 checklist 汇总评论, -决策仍锚在具体 sub-issue。收尾四步、僵尸回收与领地防撞(声明式,越窄越诚实)细则见 +队列 + epic 侧写`Blocked-by:`;每次分流留一行审计评论。进度视图:父单维护 checklist 汇总评 +论,决策仍锚在具体 sub-issue。收尾四步、僵尸回收与领地防撞(声明式,越窄越诚实)细则见 `references/seat-post-protocol.md`。 ## 分诊座位职责 @@ -405,8 +405,7 @@ sanitizer 截断的卡不 `Task` 才是代裁候选)、发版板缺陷扫描(`type:Bug is:open`)、普通队列 Bug 优先平手判据。 **路由即分诊的技术判断,永不升级「哪个仓?」。** 读五仓代码定落点;跨仓的按contract-first -拆分; -父单已有子结构的,父单队列标签即可(sub-issue 自动成为候选),分诊逐个展开路由、补 +拆分;父单已有子结构的,父单队列标签即可(sub-issue 自动成为候选),分诊逐个展开路由、补 `Blocked-by:` 排序,父单是协调节点**永不派发**。每张留一条英文审计评 论(「Triage: lands in …; rationale: …」) @@ -511,8 +510,8 @@ sub-issue 自动是 **批次独立性。** 一批内任两单不得可能碰同一个包/registry/barrel/spec schema;拿不准就串 行。**同 文件单跨轮硬串行;延后不是搁置**(被延后那一刻就把已知的坑记到该 issue 上)。**家族派 -发(family dispatch)是本条的范围澄清,不是豁免**:独立性防的是两个 agent 相撞同一区域, -一个 dev **有意**覆盖 N 张同区域已裁卡是平凡满足不变量,可折叠为一次派发(折叠认领约定见「认 +发(family dispatch)是本条的范围澄清,不是豁免**:独立性防的是两个 agent 相撞同一区域,一个 dev +**有意**覆盖 N 张同区域已裁卡是平凡满足不变量,可折叠为一次派发(折叠认领约定见「认 领」节)。准入五门全过才可折:① 同缺陷形态同修法(⛔ 不是同关键词/同子系统);② 同包/区域(一 worktree、一 changeset、一队列位);③ **每张成员都已裁/已定级,⛔ 决策箱内一张不许**(承重 门:防 @@ -607,8 +606,8 @@ dev 侧推分支要早 —— 远程分支是在飞工作最硬的证据。**死 目经理技能 必须要使用 Fable 5」。**派发模型是 PM 的逐卡显式决定** —— 下限 `sonnet`(机械 卡:正确 性由门禁农场机械判定,失败在漏跑门不在判断);默认判断档 `opus`(M/L、裁决实施、任何带设 -计判断的卡; -拿不准就升一档 —— 错派低档的返工贵过省下的额度);上限 `fable`(最重协议/流程/编排 +计判断的卡;拿不准就升一档 —— 错派低档的返工贵过省下的额度);上限 `fable`(最重协议/流 +程/编排 卡,按卡取用非新 默认)。**⛔ 强制条款两条**:① 凡改**协议语义面**的卡一律 `model: "claude-fable-5"` —— 面 = 本 `SKILL.md` 主文件、决策框架拷贝所在文件(以 `check:skill-frame-sync` 强制处为准)与 @@ -616,9 +615,9 @@ dev 侧推分支要早 —— 远程分支是在飞工作最硬的证据。**死 施工队用);仅 `references/**` 面、或任一 pm-dispatch 治理面上一行级机械文本改动的卡降为 **opus 施工**(纯机械一行 PM 酌定可至 sonnet 地板),**补偿控制即既有技能面复核档要求**(ACCEPT 路径 分叉 ①:复核席跑契约复审档位)—— opus 施工的治理面改动只经 fable 档复核到达维护者,⛔ -不新增标签不新增链;② 凡**改变契约接受/拒绝行为或扩大公开面**的卡(`domain:spec` 语义面; -判据即分诊代裁的机械边界测试与 spec 席内分派判据 —— `references/lanes/spec.md`,⛔ 不另抄第 -二份)一律 `claude-fable-5`(维护者 +不新增标签不新增链;② 凡**改变契约接受/拒绝行为或扩大公开面**的卡(`domain:spec` 语义 +面;判据即分诊代裁的机械边界测试与 spec 席内分派判据 —— `references/lanes/spec.md`,⛔ 不另 +抄第二份)一律 `claude-fable-5`(维护者 2026-08-12 裁定,原话:「同意,就按语义面收窄,立卡并通知 spec 席」)—— 契约错毒化一切下 游,全仓最贵(条款②闸门见「入队与落地」)。**卡面对条款②的复述仍是条款②**(座位复述 会丢掉原文旁的例外,而被读的正是复述):额度耗尽豁免及其 `needs:contract-review` 补偿一并及 @@ -811,8 +810,8 @@ main 红了约一小时,逐 PR 连环红到 fix-forward 才止)。本段只适 计数不追记,只有改变修法作用域时才记)。四分支:已知 flaky ⇒ 原样重投;**已修签名再现 ⇒ ⛔ 不重 投**,判新问题重新诊断;基上缺已合修复 ⇒ merge main 推新提交(重跑无效);新签名 ⇒ ⛔ 不 - 重投, - 在 PR 与其 `Fixes` 卡各留完整签名与初判;每次处置留审计评论(重投写签名与台账依据)。 + 重投,在 PR 与其 `Fixes` 卡各留完整签名与初判;每次处置留审计评论(重投写签名与台账依 + 据)。 - **依赖前棒才能转绿的 PR:draft 停放 + 签名级预期红清单 + 解除条件**;**多个已实现 PR 全碰 生成物 ⇒ 串行接力一次只放行一个,每棒一整圈、交接语义不是文本** —— 两式全文见 @@ -833,8 +832,9 @@ main 红了约一小时,逐 PR 连环红到 fix-forward 才止)。本段只适 在内;老化 = 首触欠账);**发版阻塞**(release blockers;三仓 `target:`之和,归零 = 三张板都 空)。 -**波次收工点(维护者 2026-08-09 裁定)。** ① 收工点 = 会话内在飞归零(⛔ 不在复核中途压缩; -云卡不挡 —— 热移交见座位贴协议细则);② 收工存档 = 上下文里的判断 flush 到 GitHub(不过 +**波次收工点(维护者 2026-08-09 裁定)。** ① 收工点 = 会话内在飞归零(⛔ 不在复核中途压 +缩;云卡不挡 —— 热移交见座位贴协议细则);② 收工存档 = 上下文里的判断 flush 到 GitHub(不 +过 夜义务),随后**立即派下一波**,⛔ 不等人闸。`/compact` 由维护者在终端会话择机执行,永远不 是派发前提;①② 令任何压缩无损,前提是 GitHub 恒为唯一权威 —— 失守时压缩即丢判断。 diff --git a/.claude/skills/pm-dispatch/references/decision-analysis.md b/.claude/skills/pm-dispatch/references/decision-analysis.md index b7ba83afdf..2049e0c538 100644 --- a/.claude/skills/pm-dispatch/references/decision-analysis.md +++ b/.claude/skills/pm-dispatch/references/decision-analysis.md @@ -9,8 +9,7 @@ ## 适用面(边界) - 只约束 **`needs-user-decision` 卡与决策箱讨论**的四维分析;内部工具卡(dev 派发用)的四维照 - 旧, - 不强加行业类比。四条轴本身不变,轴定义与绑定句在主文件「升级与决策」。 + 旧,不强加行业类比。四条轴本身不变,轴定义与绑定句在主文件「升级与决策」。 - 只适用新记录,存量分析 ⛔ 不回改(new-records-only,与四维中文化同款先例)。 ## 落卡分析模板(主文件「落卡/升级流程」②的 references 细则;中文,2026-08-19 裁) @@ -39,10 +38,9 @@ 面文本 grep,不依赖注释形状 —— sanitizer 纪律见平台读数);四棱各一行,用中文写(语言例外 见主文件 全体座位的不变量)—— ① 项目长远合理性(缩小还是扩大特例/契约增生);② 实际业务拉 -动(今天谁撞上; -零拉动默认 defer/remove);③ 防 AI 犯错(闭合枚举优于自由结构、响亮拒绝优于静默容忍);④ 创 +动(今天谁撞上;零拉动默认 defer/remove);③ 防 AI 犯错(闭合枚举优于自由结构、响亮拒绝优于 +静默容忍);④ 创 业阶段 不扩散(remove 优于 declare-and-maintain,每个已声明的键都是永久义务);一行推荐 + 字母选项 (A/B/…);**一行强制置信缺口(「本分析看不见什么」)**。四棱行同受六项约束:每行论据从业 -务立场写, -机制名词只作括号补充。 +务立场写,机制名词只作括号补充。 diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index 44983b5cbd..37c9ed6e23 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -7,9 +7,9 @@ 出处(逐字,未译):「被依赖的卡片是不是应该通过label标注提高优先级。项目经理处理任务清 单时,bug 是不是应该提高优先级。」全序本体与 `pm:blocking` 推导在主文件;补遗:级序理由 -一行 —— p0 是显式裁定,blocking 一张挡多张,bug 是已坏的不变量、先于增强,卡龄防饿死; -写入与摘除都在分诊轮 sweep,依赖者全关即摘,手工挂上的按误标处理、sweep 对着索引校正; -种子与消费者注记在 `scripts/pm/ensure-pm-labels.sh`。 +一行 —— p0 是显式裁定,blocking 一张挡多张,bug 是已坏的不变量、先于增强,卡龄防饿死;写 +入与摘除都在分诊轮 sweep,依赖者全关即摘,手工挂上的按误标处理、sweep 对着索引校正;种子 +与消费者注记在 `scripts/pm/ensure-pm-labels.sh`。 ## 紧急卡 fable 直接分诊(维护者 2026-08-13) @@ -144,9 +144,9 @@ docs/指令类 M 卡碰不到 —— 归档义务因此只落云卡。 `create_session` 带 `source_url` 出生即持推送授权。同时带 `outcome_branch`(= 认领分 支)、显式 `model`(trigger 流不可指模型)、`title`(车道名开头,⛔ 不叫 os-dev;形如 `⚡ <车道> #<单号> <短语>`)。 -2. **派发词必带自驱条款**(云会话回合一结束即停摆等 poke):⛔ 不为提问/中期汇报结束回合; - 开放选择按裁决与评估轴自裁记入终报 open_questions;合法回合终点只有 (a) 推送完成 + 终 - 报,或 (b) 硬阻塞详报。 +2. **派发词必带自驱条款**(云会话回合一结束即停摆等 poke):⛔ 不为提问/中期汇报结束回 + 合;开放选择按裁决与评估轴自裁记入终报 open_questions;合法回合终点只有 (a) 推送完成 + + 终报,或 (b) 硬阻塞详报。 3. **交付通道**:dev 自开 draft PR(`Fixes #`,正文含验证记录)+ 终报以 issue 评论 (os-dev-report 标记)交付;未 attach 的姊妹仓够不着,跨仓跟进卡 PM 代立;trigger 定时会 话维持降级通道 —— 推送 outcome branch + 终报走最后一条会话消息,PM 代开 draft PR。 diff --git a/.claude/skills/pm-dispatch/references/landing-operations.md b/.claude/skills/pm-dispatch/references/landing-operations.md index 2ddcaf5532..67f5fe5f29 100644 --- a/.claude/skills/pm-dispatch/references/landing-operations.md +++ b/.claude/skills/pm-dispatch/references/landing-operations.md @@ -34,8 +34,8 @@ auto-merge 与队列成员资格)。 **转 ready / 挂 auto-merge 之前先读 `mergeable_state`**:`dirty` ⇒ 先 merge -`origin/main`(生成物在面上按 A 的固定序)再挂;`unknown` ⇒ 重读一次(惰性计算, -事实行见平台读数)。**ready + 全绿 ≠ 已入队 —— 队列从不主动拉 PR,入队是显式动 +`origin/main`(生成物在面上按 A 的固定序)再挂;`unknown` ⇒ 重读一次(惰性计算,事实行见平台读 +数)。**ready + 全绿 ≠ 已入队 —— 队列从不主动拉 PR,入队是显式动 作。**零 `enqueued` 事件按序查:① `mergeable_state` 是否 `dirty`;② enable-auto-merge 调用根本没落地(GraphQL、吃配额、回显两向不可靠,按既有陷阱行 重发一次,以 timeline 事件验证,⛔ 不看 `auto_merge` 字段);③ PR 碰 diff --git a/.claude/skills/pm-dispatch/references/lanes/cli.md b/.claude/skills/pm-dispatch/references/lanes/cli.md index 4740dcbfd6..d838da1041 100644 --- a/.claude/skills/pm-dispatch/references/lanes/cli.md +++ b/.claude/skills/pm-dispatch/references/lanes/cli.md @@ -20,8 +20,8 @@ 聚合,`in_progress` 不是过;advisory 门禁红进 main 是共享损伤,照样止血立单。 - **PR 侧绿 ≠ 队列侧绿**;队列分支读法与边界在 platform-readings(条目名 `main/pr-{number}-{parent sha}`,parent sha 可重建队序;⚠️ ref 在出队后滞留)。 -- `dispatch-gates.mjs` 只报**路径推导**的地板 —— 条款②从卡**内容**判且优先于它; - 已知盲区:它不点名 `pnpm lint` 族 ⇒ 本车道派发令恒补一句「跑 `pnpm lint`」。 +- `dispatch-gates.mjs` 只报**路径推导**的地板 —— 条款②从卡**内容**判且优先于它;已知盲 + 区:它不点名 `pnpm lint` 族 ⇒ 本车道派发令恒补一句「跑 `pnpm lint`」。 - ⛔ 永不削弱门禁 —— 棘轮上跳是门禁在报你的改动有缺陷;规则文案里自带的逃生舱只用 于它描述的那种情形。 diff --git a/.claude/skills/pm-dispatch/references/lanes/devx.md b/.claude/skills/pm-dispatch/references/lanes/devx.md index 3b1041254a..f4992e3064 100644 --- a/.claude/skills/pm-dispatch/references/lanes/devx.md +++ b/.claude/skills/pm-dispatch/references/lanes/devx.md @@ -11,8 +11,8 @@ 据见 SKILL.md 多仓协调)。 - 边界:守护 skills 的门禁脚本在 `scripts/` 下时归本席 —— 判据是**被改文件的路 径**,SUBJECT 例外与 spec 三面切分在 SKILL.md 域车道表;`.claude/workflows/` - ≠ `.claude/skills/`;**范围内的文件 ≠ 范围内的卡**,归属按卡的 `domain:*` 判, - 反向亦然(本席卡的落点溢进他席文件面时,由此产生的人工合并等后果照单接受)。 + ≠ `.claude/skills/`;**范围内的文件 ≠ 范围内的卡**,归属按卡的 `domain:*` 判,反向亦然(本席卡 + 的落点溢进他席文件面时,由此产生的人工合并等后果照单接受)。 ## 常设承诺 @@ -29,8 +29,8 @@ - **卡片正文冻结在立卡那一刻**,而本仓刻意把裁定/更正/`Blocked-by:` 放在评论里 —— 超过一天的卡,正文是页面上最不当前的东西;先读晚于正文的评论再判可派性。 -- **量具在共享 checkout 上会静默说谎**:读内容用 `git show origin/main:` 带路径, - 跑脚本必须在自己的 worktree,复核 diff 用具名 ref,⛔ 不用 `FETCH_HEAD`(它是共 +- **量具在共享 checkout 上会静默说谎**:读内容用 `git show origin/main:` 带路径,跑脚本必须在自 + 己的 worktree,复核 diff 用具名 ref,⛔ 不用 `FETCH_HEAD`(它是共 享的,会被别的 agent 的 fetch 在两条命令之间冲掉)。 - **门禁的自述必须为真** —— 理由为假的门禁比没有门禁更坏(下一个读者会相信某风险 已被覆盖);新门禁靠**故意复现伤害**自证,语料已被修干净时加反空转肢(某个面读 diff --git a/.claude/skills/pm-dispatch/references/lanes/skills.md b/.claude/skills/pm-dispatch/references/lanes/skills.md index 048af82e8c..a92e053c47 100644 --- a/.claude/skills/pm-dispatch/references/lanes/skills.md +++ b/.claude/skills/pm-dispatch/references/lanes/skills.md @@ -4,8 +4,8 @@ ## 范围 -- 两个技能根:`.claude/skills/**`(含 pm-dispatch 本体)+ `skills/**`(发布目录, - 恒英文);`scripts/pm/**` PM 循环工具;指令架构文件:根 `AGENTS.md` + 根 +- 两个技能根:`.claude/skills/**`(含 pm-dispatch 本体)+ `skills/**`(发布目录,恒英文);`scripts/pm/**` PM + 循环工具;指令架构文件:根 `AGENTS.md` + 根 `CLAUDE.md`;governed 面的治理执行文件(`.github/CODEOWNERS` 治理路由半边 + SUBJECT 是 governed 面本身的门禁/审计)。全量判据与裁决引文在 SKILL.md 域车道 表;governed 面统一定义见 SKILL.md「ACCEPT 之后的路径分叉」。 diff --git a/.claude/skills/pm-dispatch/references/release-aftercare.md b/.claude/skills/pm-dispatch/references/release-aftercare.md index 7b37e0e599..5b13c985d3 100644 --- a/.claude/skills/pm-dispatch/references/release-aftercare.md +++ b/.claude/skills/pm-dispatch/references/release-aftercare.md @@ -15,8 +15,8 @@ tagged 发版滚容器,滚容器就在区域约束内**重掷**落位;区域约 重掷有两条反直觉物理,顺序错了读数就是废的: - **落位跟随唤醒者(WAKER),不跟随 `locationHint`** —— 从运维者自己的机器唤醒就 - 掷到运维者附近。实测:加州机器重掷反复落 Houston;改用 us-east CI runner 唤醒, - 第一次就落 Ashburn。 + 掷到运维者附近。实测:加州机器重掷反复落 Houston;改用 us-east CI runner 唤醒,第一次就落 + Ashburn。 - **测量本身污染下一次掷点** —— 换实例中途发出的探针,自己就成了新实例的唤醒者。 - **协议(顺序不可换)**:rollout → 运维侧**零 HTTP** → us-east runner 唤醒 (`gh workflow run keep-warm`)→ 读 CF instances API 拿到城市 → **才**开始测量。 @@ -45,8 +45,8 @@ tagged 发版滚容器,滚容器就在区域约束内**重掷**落位;区域约 后台长跑循环(重掷、恢复轮询、灰度推进)被会话重启打断时,**它最后记下的「意图」不 是线上此刻的状态**。恢复的第一个动作恒为**重读线上真实状态**,⛔ 永不从循环的最后 -已知意图往下接;⛔ 也不把长循环留在无人认领的后台。实测:一个被孤儿化的重掷循环, -把生产控制面停在它自己掷出的**最差**一档上数小时,而循环留下的最后一行写着「继续重 +已知意图往下接;⛔ 也不把长循环留在无人认领的后台。实测:一个被孤儿化的重掷循环,把 +生产控制面停在它自己掷出的**最差**一档上数小时,而循环留下的最后一行写着「继续重 掷」。 ## E. 诚实条款 —— 后来的测量推翻已合并 PR 的断言,更正公开落到卡上 diff --git a/.claude/skills/pm-dispatch/references/review-checklist.md b/.claude/skills/pm-dispatch/references/review-checklist.md index 492fc21876..22ebb6a54c 100644 --- a/.claude/skills/pm-dispatch/references/review-checklist.md +++ b/.claude/skills/pm-dispatch/references/review-checklist.md @@ -44,8 +44,8 @@ 的绿把「新推送未验」读成「消费者干净」,旧 head 的红把已修掉的缺陷重新挂回 PR)。 - **dev 本地跑的门禁并集,同样先钉 head —— 同一条纪律**:契约要求**最后一次提交之 后**跑并集、把 `git rev-parse --short HEAD` 抄进报告与 PR 正文;与 PR 当前 - `head.sha` 比一次,对不上即死树读数、**双向都不入账**;没抄 ⇒ 按**没有读数**处理, - 以门禁 job 结论为准。复核后又推提交而 HEAD 未动 ⇒ 补跑并集(至少棘轮族)再更报告。 + `head.sha` 比一次,对不上即死树读数、**双向都不入账**;没抄 ⇒ 按**没有读数**处理,以门禁 + job 结论为准。复核后又推提交而 HEAD 未动 ⇒ 补跑并集(至少棘轮族)再更报告。 - **被取代 head 上的 run 永不重跑**:非当前 head 上的 `cancelled` 结论零动作 —— 新推送自带全套 run;重跑烧一整个重量级周期,还能忠实复现已被当前 head 修掉的缺 陷、给绿 PR 挂上假红。 diff --git a/.claude/skills/pm-dispatch/references/seat-post-protocol.md b/.claude/skills/pm-dispatch/references/seat-post-protocol.md index 3b050cda88..8dd2c49b48 100644 --- a/.claude/skills/pm-dispatch/references/seat-post-protocol.md +++ b/.claude/skills/pm-dispatch/references/seat-post-protocol.md @@ -40,8 +40,8 @@ 由,边界刷足以覆盖;中途状态的承载见上「班次叙事」条(原话在主文件)。协议/结构 升级迁移状态、或巡检发现正文不符时,当场改正文 + 审计评论。Routine 座位的收尾简报也 落自己的座位贴(它是下一轮自退守卫的读数)。 -- **收班 = 状态 flush + 看护显式移交,⛔ 不等于在飞归零**(维护者 2026-08-13 拍板; - 实测:云卡本就设计为活过 PM 会话,等它归零曾挂住座位数小时)。在飞归零对子代理 +- **收班 = 状态 flush + 看护显式移交,⛔ 不等于在飞归零**(维护者 2026-08-13 拍板;实测:云卡本 + 就设计为活过 PM 会话,等它归零曾挂住座位数小时)。在飞归零对子代理 dev 是对的(随会话死);对云卡与合并队列尾巴是错的 —— 那些是持久的、GitHub 锚 定的进程,需要的是**看护者**。热移交三件齐即可收班:① 每张在飞卡落**收单注记** (评审判据、落地步骤、当前状态);② 座位贴正文刷新到当下;③ 每件在飞物**点名接手看 diff --git a/.claude/skills/pm-dispatch/references/true-green.md b/.claude/skills/pm-dispatch/references/true-green.md index aa0a4f5e99..1f7b58c854 100644 --- a/.claude/skills/pm-dispatch/references/true-green.md +++ b/.claude/skills/pm-dispatch/references/true-green.md @@ -30,5 +30,5 @@ - 本地 preflight:`preflight-workspace-dist`(workspace 依赖的 dist 新鲜度守卫)—— 按其自身输出的提示跑,先建再测。 - 严格区 `check-test-typecheck` 的失败**不以 `error TS` 行出现在 CI 日志**:turbo 汇总 - 只写 `#typecheck` failed —— 要看到台账消息必须本地跑该包自己的 `typecheck` 脚本; - 对 CI 日志 grep `error TS` 的阴性读数在这一族上不成立。 + 只写 `#typecheck` failed —— 要看到台账消息必须本地跑该包自己的 `typecheck` 脚本;对 CI 日志 + grep `error TS` 的阴性读数在这一族上不成立。 diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index cc1b00f13e..f4ffa125ce 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -38,8 +38,8 @@ preview renderer 不算消费者)与 AGENTS.md §"Touched `packages/spec`?"(八 - [ ] **它是安全/能力形状的吗?** 那么 ADR-0049 约束成立,惰性是缺陷不是债。 `rls.enabled` 曾是「证据错误的 live」而实际**无人读取** —— disabled 的 - policy 仍在贡献它的授权。那个键最后是**被强制执行**,不是被删除。功能存在时, - 强制执行赢。 + policy 仍在贡献它的授权。那个键最后是**被强制执行**,不是被删除。功能存在时,强制 + 执行赢。 - [ ] **它是文档形状的吗?** `hook.label`、`hook.description`、`flow.description` 没有运行时消费者,但被**有意保留** —— 它们为下一个读者(按 ADR-0033,常是模 型)记录意图。把豁免写进台账 `note`,下次审计不再重审。良性展示元数据 @@ -131,8 +131,8 @@ walk 看不见的属性就是 ratchet 管不到的属性。 所以验收顺序是:**先确定路线,再决定该期待什么读数**,不要反过来用读数去猜路线。 整 def 删除还有一条自证信号:`json-schema.manifest/`(#5837 起按 category 分片)的 -ratchet(#2978)会先开火, -要求你**有意删除**对应的 manifest key;删完重跑,per-key ratchet 会自行判定为 #4650 +ratchet(#2978)会先开火,要求你**有意删除**对应的 manifest key;删完重跑,per-key ratchet 会自行判定 +为 #4650 路径 3(`def no longer emitted by this build`)。这串输出本身就是路线的证据,留在 PR 里。 枚举值收窄既然对四张 ratchet 不可见,它的处方就只能挂在**枚举自己的 `error` map** @@ -188,8 +188,8 @@ conversion 是消费者跟的。两个都要写。 注册中了 `ui/FormView:aria`,protocol 11 的 `flow.node.type` 注册中了任意 `.type`(#4658)。对每个常见叶名,保证早已失效。 *条目同时启动老化时钟(#5898):* 门 (c) 读同一张表来决定这个墓碑的 - `authorable-surface/` 行何时可删(~两个 major),所以你把它写在哪个 major 下, - 时钟就从那个 release 数起。比这张表更老的退役是**未申报的、因此不可删的** —— + `authorable-surface/` 行何时可删(~两个 major),所以你把它写在哪个 major 下,时钟就从那个 + release 数起。比这张表更老的退役是**未申报的、因此不可删的** —— 没有东西能诚实地给它们定日期(叶匹配曾把 `data/Index:type` 的日期从一个不相干 的 `flow.node.type` 定到 major 11,而 baseline 文件自己的 git 历史始于 17.0.0-rc.0)。要删那类行,先考证它真实的 major、补条目、在 PR 里写明。 @@ -197,8 +197,8 @@ conversion 是消费者跟的。两个都要写。 - [ ] **`surface` 保持散文 —— 它不再被匹配。** 按作者写元数据的方式写 (`flow.nodes[].outputSchema`),那也是 upgrade guide 打印的。多键 conversion 仍用恰好 `' / '` 连接子句(tool 清扫以来的 house style)。下游不再有任何东西 - 从它解析归属 —— 那个职责移给了上面的条目。#5898 起这对**每个**消费者都成立: - 门 (c) 的 *aged-out tombstone* 证明曾是最后一个叶匹配者,现在也读同一张精确键 + 从它解析归属 —— 那个职责移给了上面的条目。#5898 起这对**每个**消费者都成立:门 + (c) 的 *aged-out tombstone* 证明曾是最后一个叶匹配者,现在也读同一张精确键 表,再没有任何规则从 `surface` 解析归属。 - [ ] **`retiredFromLoadPath: true`** —— 退役恒真。两种论证,不可互换:对*改名*它意 味着「没有 alias 窗口,故意的」(拒绝由墓碑负责;条目存在是为了 @@ -271,8 +271,8 @@ conversion 是消费者跟的。两个都要写。 —— 由 `check:skill-examples` 与 `check:skill-refs` 把门。 - [ ] **Docs** —— `content/docs/**` 的散文、表格与代码块 —— **除了 `content/docs/releases/`,代码 PR 永不碰它**(AGENTS.md Documentation - Guardrails)。release notes 在发布时从 changesets + D2/D3 registry 集中编写; - 本清单曾要求的逐 PR 加行,把 `releases/v.mdx` 变成了全仓最热的冲突磁 + Guardrails)。release notes 在发布时从 changesets + D2/D3 registry 集中编写;本清单曾要求的逐 PR + 加行,把 `releases/v.mdx` 变成了全仓最热的冲突磁 铁。你的 changeset(下一项)才是通往它们的输入。`content/docs/**` 的其余部 分:先 grep 键名,再读周边文件 —— 被删的键会藏在离参考表三节远的一个 `defineFlow` 示例里。 diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 781c416f6f..c2598dd435 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -137,6 +137,29 @@ * because that one WOULD render as a space. That property is what let the one-off * re-wrap prove itself: every file compared byte-identical after whitespace * normalization, with its inline code spans identical in sequence. + * + * ### The junction that leaked (#12081), and why it is invisible in the source + * + * One junction escaped the paragraph above and had to be measured to be found: a + * break directly after an ASCII `,` `;` `:` that follows a HAN character. It is + * not a CJK↔Latin junction — both sides are Chinese — but the MARK between them + * is a narrow byte, so the segment break is not between two wide characters and + * renders as a space. 34 such breaks survived the #11106 re-wrap across 15 + * ratcheted files. ⚠️ The defect is invisible in the source and visible only in + * the render, which is the inverse of the usual reading: a reviewer reads these + * files as source, where the break looks like ordinary wrapping, while the space + * exists only for the agent that reads the RENDERED text — and that is the whole + * population these files are written for. + * + * ⭐ It is also SELF-CONCEALING under re-wrap, which is why the mechanism is + * written down here rather than left to a PR body. Re-flowing such a paragraph + * and re-wrapping it FAITHFULLY must PRESERVE the space (it is what the text + * renders as), so the space migrates out of the line break and into a literal + * `", "` in the source. It then looks like the re-wrap introduced a space. It did + * not — it only made an existing render visible. Anyone auditing a re-wrap by eye + * will reach the wrong conclusion here. {@link hanAsciiPunctTail} closes the + * channel at its source by refusing to OFFER the break, so no future wrap has to + * choose between preserving a defect and hiding it. */ import { readFileSync } from 'node:fs'; @@ -193,7 +216,10 @@ export const CEILINGS = new Map([ // Business-perspective decision-analysis writing guide (maintainer ruling // 2026-08-20: the four-facet analysis must argue from the business // standpoint). Set at landed line count (headroom 0, same convention). - ['.claude/skills/pm-dispatch/references/decision-analysis.md', 48], + // Lowered 48 → 46 by the #12081 soft-break closure (lowering is always + // legitimate): three of this file's measured Han+ASCII-punct breaks merged + // back into their paragraphs, and two of the three paid for themselves. + ['.claude/skills/pm-dispatch/references/decision-analysis.md', 46], // 134 → 133: whole-text restructuring round, PR-2 (maintainer ruling // 2026-08-23) — the three write-side sanitizer rows consolidated to one // author rule + one measured-behaviour row per surface (body / comment). @@ -235,6 +261,18 @@ export const CEILINGS = new Map([ // line count read from this ratchet's own run — headroom 0, same convention as // the entries above. ['.claude/skills/pm-dispatch/references/true-green.md', 34], + // Per-surface compile/typecheck coverage index — which surfaces a repo-wide + // typecheck actually reaches, which are compiled only by their own package's + // script, and the frozen ones. #12098: after true-green.md above it was the + // LAST pm-dispatch references file with no row, found by enumerating the + // directory against this map rather than by noticing one file. A references + // file is read per seat session like every entry above, so its absence was a + // coverage gap, not the header's deliberate omission (that one is the + // published `skills/` catalog, and only it). Set at the landed line count read + // from this ratchet's own run — headroom 0, same convention. ⚠️ Its seven + // over-120B lines are all `table` rows, structurally exempt and NOT + // re-wrappable; they are metered instead by MAX_TABLE_ROW_BYTES below. + ['.claude/skills/pm-dispatch/references/compile-surfaces.md', 26], // Lane job descriptions (maintainer ruling 2026-08-19: per-lane PM job // descriptions move from seat-post prose into versioned skill references). // Set at landed line counts (headroom 0, same convention as above). @@ -302,6 +340,88 @@ export const CEILINGS = new Map([ ['CLAUDE.md', 86], ]); +/** + * Per-file MAX TABLE ROW BYTES — the second ratchet, and the only metered thing + * in this file that is not a line count (#11947). + * + * ## The hole it closes + * + * The 120-byte line rule above exempts a markdown table row by shape, and that + * exemption is correct — a wrapped `|` row is a different table. But it is not + * free, and until this map nothing measured what flowed through it. A table row + * grows by WIDENING A CELL, which costs zero lines and passes both existing + * controls: the line ratchet counts it as one line, and the length rule exempts + * it. Measured on the corpus when this landed, the five longest surviving lines + * were all table rows, led by `AGENTS.md` at 1,081 bytes. + * + * The widening is not hypothetical and not broad decay — it is HEAVY-TAIL, which + * is what sized this remedy. Per-row byte drift keyed by first cell between two + * refs of `origin/main`: of ~100 matched rows, essentially none moved, while the + * corpus's two longest lines both reached their length inside two weeks through + * exactly this channel (`AGENTS.md` 639 → 1,081 bytes; one `domain:engine` row + * 101 → 765, a 7x). A corpus-wide new RULE would be sized for a defect that + * lives in two or three rows per file; a shrink-only PIN catches precisely the + * measured defect and nothing else. + * + * ## The ruling + * + * Maintainer ruling 2026-08-25 (issue #11947, comment 5406811814), verbatim and + * untranslated: 「同意」 — accepting option 1, the A-ratchet, over option 2 (a + * whole-file byte ceiling on the two table-heavy files, the narrowed form of the + * option already declined corpus-wide by #11106) and option 3 (nothing, refuted + * by the measurement above). The other five structural exemption classes — + * quotation, blockquote, unbreakable, anchored, fence — are untouched and remain + * load-bearing and human-audited. + * + * ## The discipline, which is the ceilings' discipline + * + * Seeded at each file's OWN widest table row on the day this landed, read from + * {@link scanTableRows} rather than measured by hand — no invented constants, + * the same idiom as the ceilings above and {@link MAX_LINE_BYTES}. Shrink-only: + * lowering is always legitimate and consolidation pays the pin down; raising one + * requires a maintainer ruling quoted in the raising PR. + * + * ⚠️ A pin of 0 is a MEASUREMENT, not a disabled row: that file has no table row + * today, so its widest is 0. It is deliberately not an omission — an uncovered + * file is the one place a row could widen unmetered again, which is the whole + * defect. Every key of {@link CEILINGS} carries a pin and the self-test holds the + * two maps in step, because enforcement cannot: a missing pin would simply never + * be consulted. Since every pin is seeded at headroom 0, ANY positive headroom + * means a row has since been paid down and the pin should follow it — `run` + * prints that as a hint rather than a failure. + */ +export const MAX_TABLE_ROW_BYTES = new Map([ + // The five files that carry a table row today, each seeded at its own widest. + // The corpus's #1 longest LINE of any shape is the AGENTS.md row below. + ['.claude/skills/pm-dispatch/SKILL.md', 765], + ['.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], + ['.claude/skills/pm-dispatch/references/decision-analysis.md', 0], + ['.claude/skills/pm-dispatch/references/platform-readings.md', 0], + ['.claude/skills/pm-dispatch/references/rest-channel.md', 0], + ['.claude/skills/pm-dispatch/references/review-checklist.md', 0], + ['.claude/skills/pm-dispatch/references/landing-operations.md', 0], + ['.claude/skills/pm-dispatch/references/release-aftercare.md', 0], + ['.claude/skills/pm-dispatch/references/seat-post-protocol.md', 0], + ['.claude/skills/pm-dispatch/references/true-green.md', 0], + ['.claude/skills/pm-dispatch/references/compile-surfaces.md', 352], + ['.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/devx.md', 0], + ['.claude/skills/pm-dispatch/references/lanes/skills.md', 0], + ['.claude/skills/pm-dispatch/references/lanes/spec.md', 0], + ['.claude/skills/pm-dispatch/references/lanes/hotcrm.md', 0], + ['.claude/agents/os-dev.md', 0], + ['.claude/skills/checklist-test/SKILL.md', 221], + ['.claude/skills/checklist-author/SKILL.md', 0], + ['.claude/skills/dogfood-verification/SKILL.md', 0], + ['.claude/skills/spec-property-retirement/SKILL.md', 328], + ['AGENTS.md', 1081], + ['CLAUDE.md', 0], +]); + /** * The repo-ROOT files of the map above, spelled so `scripts/pm/dispatch-gates.mjs` * can derive this gate from a card that touches one. @@ -395,6 +515,19 @@ const WIDE = // Kinsoku: never strand a closing mark at a line head, never leave an opener at a line tail. const NO_BREAK_BEFORE = '。,、;;::??!!))]】}』」〉》·…%,.;:?!)]}’”'; const NO_BREAK_AFTER = '(([【{『「〈《‘“'; +// #12081 — the OTHER no-break-after class, and the reason it is a separate test +// rather than three more characters in the string above. `,` `;` `:` are legal +// break points in ASCII prose ("first, second") and must stay legal there. What +// is never legal is one of them written directly after a HAN character: this +// corpus spells CJK sentence-internal punctuation in ASCII throughout (measured +// on `.claude/agents/os-dev.md`: 197 ASCII commas after a Han character, 0 +// fullwidth U+FF0C), so such a mark is a CJK sentence mark that happens to be a +// narrow byte. A segment break after it is NOT between two wide characters, so +// the CSS segment-break transformation KEEPS it and it renders as a SPACE — +// mid-sentence, in prose that has no spaces anywhere else. The successor +// character is irrelevant to that: the mark itself is narrow, so the break +// renders as a space whatever follows. +const HAN = /\p{Script=Han}/u; // Sequences markdown reads as the start of a NEW block — a continuation line may never begin with one. const BLOCK_START = /^(#{1,6}(\s|$)|[-*+](\s|$)|\d+[.)](\s|$)|>|\||`{3}|~{3}|-{3}|={3})/; @@ -461,10 +594,33 @@ export function atomize(body) { return atoms; } -function breakLegal(a, b) { +/** + * Does `atoms[i]` end in an ASCII `,` `;` `:` that itself follows a Han + * character? Then a break placed AFTER it renders as a space (see the comment + * on {@link HAN}) and {@link breakLegal} refuses it. + * + * The mark can sit either inside the atom (`态,` never happens — a wide char is + * its own atom — but `PR,` and `第2章,` do) or be the whole atom, in which case + * the Han character is the tail of the PRECEDING atom and only when no space + * separates them: `常态, 不是` has the mark after `态`, while `见 , 后` does not. + */ +export function hanAsciiPunctTail(atoms, i) { + const a = atoms[i]; + if (!a || !/[,;:]$/.test(a.text)) return false; + const inner = a.text.slice(0, -1); + if (inner.length > 0) return HAN.test(inner[inner.length - 1]); + if (a.sp) return false; // whitespace before the mark — it follows nothing + const prev = atoms[i - 1]; + return !!prev && HAN.test(prev.text[prev.text.length - 1]); +} + +export function breakLegal(atoms, k) { + const a = atoms[k - 1]; + const b = atoms[k]; const last = a.text[a.text.length - 1]; if (NO_BREAK_AFTER.includes(last)) return false; if (NO_BREAK_BEFORE.includes(b.text[0])) return false; + if (hanAsciiPunctTail(atoms, k - 1)) return false; // #12081 — renders as a space if (b.sp) return true; // an existing space becomes the newline return a.cjk && b.cjk; // segment break between two wide chars renders as nothing } @@ -503,13 +659,13 @@ export function wrapLine(line, limit = MAX_LINE_BYTES) { if (bytes(full) <= limit) { out.push(full); break; } let chosen = -1; for (let k = from + 1; k < atoms.length; k++) { - if (!breakLegal(atoms[k - 1], atoms[k]) || startsBlock(atoms, k)) continue; + if (!breakLegal(atoms, k) || startsBlock(atoms, k)) continue; if (bytes(renderSeg(atoms, from, k, pfx)) <= limit) chosen = k; else break; // segment length is monotonic in k } if (chosen === -1) { // nothing fits: take the first legal break at all, if any for (let k = from + 1; k < atoms.length; k++) { - if (breakLegal(atoms[k - 1], atoms[k]) && !startsBlock(atoms, k)) { chosen = k; break; } + if (breakLegal(atoms, k) && !startsBlock(atoms, k)) { chosen = k; break; } } } if (chosen === -1) { out.push(full); break; } @@ -581,6 +737,60 @@ export function scanLineLengths(text) { return { offenders, exempt }; } +// ───────────────────────────────────────────────────────────────────────────── +// The max-table-row-bytes pin (#11947). See MAX_TABLE_ROW_BYTES for why. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The widest markdown table row in `text`, in bytes, with its 1-based line + * number. Fenced and front-matter regions are not table rows however they are + * spelled, so the block state is carried exactly as {@link scanLineLengths} + * carries it. A file with no table row measures 0 — that is a MEASUREMENT, not a + * sentinel, and it is what seeds the pin of every such file. + */ +export function scanTableRows(text) { + const lines = text.split('\n'); + if (text.endsWith('\n')) lines.pop(); + let state = initialState(); + let widest = 0; + let at = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const before = state; + state = advanceState(line, state, i); + if (before.fence || isFence(line) || before.frontMatter) continue; + if (!isTableRow(line)) continue; + const b = bytes(line); + if (b > widest) { widest = b; at = i + 1; } + } + return { widest, line: at }; +} + +export function tableRowVerdict(rel, widest, at, pin) { + if (pin === undefined) { + return { + ok: false, + msg: + `${rel} has no max-table-row-bytes pin — red, not a skip (#4690). Every file in the ` + + 'ceiling map carries one, seeded at its own widest table row, so an uncovered file is the ' + + 'one place a row could widen unmetered again.', + }; + } + if (widest > pin) { + return { + ok: false, + msg: + `${rel} has a ${widest}-byte table row at L${at}; the max-table-row-bytes pin is ${pin}. ` + + 'A table row is the one shape the 120-byte line rule cannot reach — a wrapped `|` row is a ' + + 'different table — so its width is metered here instead, and widening a cell costs zero ' + + 'lines. Move the row\'s detail into prose or a reference file, or consolidate two rows into ' + + 'one; either pays the pin down. There is no allowlist, and raising a pin requires a ' + + 'maintainer ruling quoted in the PR.', + }; + } + return { ok: true, msg: `${rel}: widest table row is ${widest} bytes (pin ${pin}; headroom ${pin - widest}).` }; +} + export function lengthVerdict(rel, offenders) { if (offenders.length === 0) return { ok: true, msg: `${rel}: every line is within ${MAX_LINE_BYTES} bytes (or structurally exempt).` }; const shown = offenders.slice(0, 5).map((o) => `L${o.line} (${o.bytes}B)`).join(', '); @@ -613,6 +823,18 @@ function run() { failed++; console.error(`✗ check-skill-line-ratchet: ${lv.msg}`); } + const tr = scanTableRows(text); + const tv = tableRowVerdict(rel, tr.widest, tr.line, MAX_TABLE_ROW_BYTES.get(rel)); + if (!tv.ok) { + failed++; + console.error(`✗ check-skill-line-ratchet: ${tv.msg}`); + } else { + const slack = MAX_TABLE_ROW_BYTES.get(rel) - tr.widest; + if (slack > 0) { + console.log(`ℹ️ ${rel}: max-table-row-bytes headroom is ${slack} — every pin is seeded at 0 headroom, so a row has been paid down; lower the pin to ${tr.widest} (shrink-only ratchets tighten opportunistically).`); + } + console.log(`✓ check-skill-line-ratchet: ${tv.msg}`); + } const v = verdict(rel, countLines(text), maxLines); if (!v.ok) { failed++; @@ -645,6 +867,9 @@ function selfTest() { ['the other four skills are covered (#9473)', ['checklist-test', 'checklist-author', 'dogfood-verification', 'spec-property-retirement'].every((n) => CEILINGS.has(`.claude/skills/${n}/SKILL.md`)), true], ['root AGENTS.md is covered (#9792)', CEILINGS.has('AGENTS.md'), true], ['root CLAUDE.md is covered (#9965)', CEILINGS.has('CLAUDE.md'), true], + // #12098: the last uncovered pm-dispatch references file. Found by + // enumerating the directory against this map, not by noticing one file. + ['references/compile-surfaces.md is covered (#12098)', CEILINGS.has('.claude/skills/pm-dispatch/references/compile-surfaces.md'), true], // The dispatch-gates declaration (#9964). Enforcement cannot hold any of // these: the declaration is read by another tool entirely, so a wrong or // missing entry runs perfectly green here and only shows up as a dev @@ -739,6 +964,83 @@ function selfTest() { ['wrapLine never strands a closing 。 at a line head', wrapped.slice(1).every((l) => !'。,、;:?!)]}'.includes(l.trimStart()[0])), true], ]; })(), + // ── #12081: no break after an ASCII `,;:` that follows a Han character ─── + // Every case is a PAIR discriminating on the Han condition alone, because + // the failure mode of a blunt fix is silently forbidding the ASCII-prose + // comma break too, which no corpus line would ever reveal. + ...(() => { + const hanComma = atomize('常态,不是许可'); // 常 态 , 不 是 许 可 + const asciiComma = atomize('first, second'); // "first," · "second" + const spaced = atomize('常态 , 不是'); // the mark follows a space + // The one shape where the OLD wrapper actually took the break: the atom + // after the mark carries a space and is too long to append, so the only + // segment that fits ends ON the mark. + const hanTrap = `${'中文内容'.repeat(9)}, \`${'p/'.repeat(40)}\``; + const latinTwin = `${'word '.repeat(18)}end, \`${'p/'.repeat(40)}\``; + const wrappedTrap = wrapLine(hanTrap); + const wrappedTwin = wrapLine(latinTwin); + return [ + ['an ASCII , after a Han character is a no-break-after mark', hanAsciiPunctTail(hanComma, 2), true], + ['...and ; and : are the same mark class', ['常态;不是', '常态:不是'].every((s) => hanAsciiPunctTail(atomize(s), 2)), true], + ['...but after a LATIN word it stays an ordinary break point', hanAsciiPunctTail(asciiComma, 0), false], + ['...and a mark with a space before it follows nothing', hanAsciiPunctTail(spaced, 2), false], + ['...and an atom not ending in one is never the mark', hanAsciiPunctTail(hanComma, 1), false], + ['breakLegal refuses the break after a Han+ASCII mark', breakLegal(hanComma, 3), false], + ['...still allows the CJK-to-CJK break one atom earlier', breakLegal(hanComma, 1), true], + ['...and still allows an ordinary ASCII space break', breakLegal(asciiComma, 1), true], + // End to end, on the shape that used to produce the defect. + ['wrapLine splits the trap line', wrappedTrap.length > 1, true], + ['no wrapped line ends on a Han+ASCII mark — the break is no longer OFFERED', wrappedTrap.every((l) => !/\p{Script=Han}[,;:]$/u.test(l)), true], + ['...the line it took instead is still within budget', wrappedTrap.every((l) => Buffer.byteLength(l, 'utf8') <= 120), true], + ['...and it moved only whitespace, as every wrap must', wrappedTrap.join('').replace(/\s+/g, ''), hanTrap.replace(/\s+/g, '')], + // The RED twin: identical shape, Latin before the mark. If this one also + // stopped breaking, the rule would be over-broad and the case above + // would pass for the wrong reason. + ['the SAME shape with a Latin word before the mark still breaks there', wrappedTwin.some((l) => l.endsWith('end,')), true], + ['wrapLine is still idempotent under the new rule', wrappedTrap.flatMap((l) => wrapLine(l)).join('\n'), wrappedTrap.join('\n')], + ]; + })(), + // ── #11947: the per-file max-table-row-bytes pin ──────────────────────── + ...(() => { + const row = `| a | ${'中文内容'.repeat(20)} |`; // 6 + 240 + 2 = 248 bytes + const doc = `# t\n\n| a | b |\n|---|---|\n${row}\n`; // the row lands on line 5 + const fenced = `\`\`\`\n| a | ${'x'.repeat(200)} |\n\`\`\`\n`; + const clean = { fence: false, frontMatter: false }; + const red = tableRowVerdict('f.md', 999, 3, 100); + return [ + // The pairing that IS this card: the length rule exempts the row by + // shape, and the pin meters the same row instead. A case asserting only + // one half would pass under either control alone. + ['a long table row is EXEMPT from the 120-byte line rule', classifyLine(row, clean), 'table'], + ['...and the same row is METERED by its file pin', tableRowVerdict('f.md', 248, 5, 247).ok, false], + ['at exactly the pin -> green', tableRowVerdict('f.md', 248, 5, 248).ok, true], + ['one byte wider -> RED (widening a cell is the measured defect)', tableRowVerdict('f.md', 249, 5, 248).ok, false], + ['narrower than the pin -> green', tableRowVerdict('f.md', 100, 5, 248).ok, true], + ['a pin of 0 is a measurement — a file with no table row passes it', tableRowVerdict('f.md', 0, 0, 0).ok, true], + ['...and the FIRST table row in such a file is RED', tableRowVerdict('f.md', 30, 4, 0).ok, false], + ['a missing pin is RED, not a skip (#4690)', tableRowVerdict('f.md', 0, 0, undefined).ok, false], + ['...and says so rather than naming a width', tableRowVerdict('f.md', 0, 0, undefined).msg.includes('no max-table-row-bytes pin'), true], + ['the RED message names the width', red.msg.includes('999-byte'), true], + ['the RED message names the line', red.msg.includes('L3'), true], + ['the RED message names the remedy', red.msg.includes('reference file'), true], + ['the RED message names consolidation as the way to pay it down', red.msg.includes('consolidate'), true], + ['the RED message offers NO allowlist', red.msg.includes('no allowlist'), true], + ['the RED message says raising needs a maintainer ruling', red.msg.includes('maintainer ruling'), true], + // scanTableRows — the seed instrument. The pins were read from it, so a + // wrong scan would have written wrong pins that then ran green forever. + ['scanTableRows finds the widest row', scanTableRows(doc).widest, 248], + ['...and reports its line number', scanTableRows(doc).line, 5], + ['a `|` line inside a FENCE is not a table row', scanTableRows(fenced).widest, 0], + ['a `|` line in FRONT MATTER is not a table row', scanTableRows('---\n| a |\n---\n').widest, 0], + ['a file with no table row measures 0', scanTableRows('中文内容\n').widest, 0], + // The two maps must stay in step; enforcement cannot hold this, because + // a file missing from the pin map is simply never consulted. + ['every ceilinged file carries a pin', [...CEILINGS.keys()].every((k) => MAX_TABLE_ROW_BYTES.has(k)), true], + ['and the pin map names no file the ceiling map does not cover', [...MAX_TABLE_ROW_BYTES.keys()].every((k) => CEILINGS.has(k)), true], + ['every pin is a non-negative integer', [...MAX_TABLE_ROW_BYTES.values()].every((n) => Number.isInteger(n) && n >= 0), true], + ['the published skills/ catalog is uncovered here too', [...MAX_TABLE_ROW_BYTES.keys()].some((k) => k.startsWith('skills/')), false], + ]; + })(), ].map((c) => (Array.isArray(c[1]) || (c[1] && typeof c[1] === 'object') ? [c[0], JSON.stringify(c[1]), JSON.stringify(c[2])] : c)); let failed = 0; for (const [name, actual, expected] of cases) { From acf9bedfc67655e6b1a8e48f709032c4ae3df115 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:19:39 +0000 Subject: [PATCH 2/2] pm-ratchet: make the breakLegal #12081 case load-bearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The case used an atom pair with no space after the mark, where the break was already illegal for an unrelated reason — it passed under ablation of the very guard it names. Pinned both shapes instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- scripts/pm/check-skill-line-ratchet.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index c2598dd435..2156787696 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -970,6 +970,7 @@ function selfTest() { // comma break too, which no corpus line would ever reveal. ...(() => { const hanComma = atomize('常态,不是许可'); // 常 态 , 不 是 许 可 + const hanCommaSpaced = atomize('常态, 不是'); // 常 态 , 不(sp) 是 const asciiComma = atomize('first, second'); // "first," · "second" const spaced = atomize('常态 , 不是'); // the mark follows a space // The one shape where the OLD wrapper actually took the break: the atom @@ -985,7 +986,12 @@ function selfTest() { ['...but after a LATIN word it stays an ordinary break point', hanAsciiPunctTail(asciiComma, 0), false], ['...and a mark with a space before it follows nothing', hanAsciiPunctTail(spaced, 2), false], ['...and an atom not ending in one is never the mark', hanAsciiPunctTail(hanComma, 1), false], - ['breakLegal refuses the break after a Han+ASCII mark', breakLegal(hanComma, 3), false], + ['breakLegal refuses the break after a Han+ASCII mark', breakLegal(hanCommaSpaced, 3), false], + // Why only ONE shape ever leaked, pinned so the case above cannot pass + // for the wrong reason: with no space after the mark there was never a + // legal break there anyway (neither side is wide), so the defect could + // only enter where the author had written `, ` and the wrapper spent it. + ['...a mark with no following space was never a break point to begin with', breakLegal(hanComma, 3), false], ['...still allows the CJK-to-CJK break one atom earlier', breakLegal(hanComma, 1), true], ['...and still allows an ordinary ASCII space break', breakLegal(asciiComma, 1), true], // End to end, on the shape that used to produce the defect.