Skip to content

feat(scripts): 派发前框架新鲜度自检门禁 —— 三份框架副本 vs origin/main (#5866) - #6105

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-5866-frame-freshness-gate
Aug 7, 2026
Merged

feat(scripts): 派发前框架新鲜度自检门禁 —— 三份框架副本 vs origin/main (#5866)#6105
hotlong merged 1 commit into
mainfrom
claude/issue-5866-frame-freshness-gate

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #5866

按分诊裁定,严格只做处置 3:给派发前加一道「框架新鲜度」自检门禁。⛔ 处置 1(harness 从 origin/main 读 agent 定义,非本仓代码路径)与处置 2(PM 纪律对齐检出)明确不做,也不在本 PR 讨论范围内。

前提复核(Prime Directive #6,落地前实测 origin/main)

复核项 结果
scripts/check-skill-frame-sync.mjs 在 main 上 ✅ 在(PR #5865 已合)
AXIS_MAP 是否已导出 ❌ 未导出(const AXIS_MAP,第 147 行);COPIESanalyzeCopyrunAllChecks 同样未导出,且 main() 在模块顶层无守卫直接执行
是否已有新鲜度门禁 ❌ 没有。scripts/ 下唯一带 fresh 的是 check-objectui-pin-fresh.mjs,判的是 objectui pin,与本单无关
通道是否仍是活的 仍然活着,而且不是同一个实例。本单落地时实测共享派发检出 /home/user/objectstack 停在 claude/pm-dispatch-devx-rgxpjfgit rev-list --count HEAD..origin/main = 59。这与 issue 记录的 claude/pm-dispatch-devx-987d90(落后 173)是不同分支、不同座位——devx 座位当场 ff 只治好了那一次实例,系统性通道原样敞着

前提成立。附带一条对本门禁设计有直接影响的观察:这棵落后 59 个提交的检出,它的框架恰好还是三轴的。也就是说「落后多少提交」和「框架是否过时」是两件事——门禁若按提交距离判,今天就会对这棵完全健康的树报红。判据必须是框架结构本身(见下)。

两条不变量的边界:为什么是新脚本,不是塞进 #5865

分诊这一条写得很明确,这里把它钉在代码里:

门禁 问题 比对对象
check:skill-frame-sync#5798 / PR #5865 同一棵树内四份副本是否同构 副本 vs 副本,单棵树
check:skill-frame-freshness(本 PR) 这棵树相对 origin/main 是否新鲜 本树 vs 远端 ref

两者互相独立:一棵落后 173 提交、四份副本一致地都停在两轴的旧树,在 sync 上是绿的——副本确实同构,那是按设计的正确行为——而它正是 #5866 报告的缺陷本体。

这条独立性不是写在注释里就算数,self-test 把它钉死了:同一份两轴 fixture 同时喂给两个门禁,断言 sync 绿、freshness 红。实跑输出:

[fixture check] check-skill-frame-sync on the two-axis fixture: GREEN (isomorphic — exactly #5866's "lying green light")

这就是分诊说的「说谎的绿灯」形状,现在有机器证据。

判据复用:一份结构定义,不是第二套锚点

COPIES / AXIS_MAP / analyzeCopy / runAllCheckscheck-skill-frame-sync.mjs import,不复制。对该文件的改动只有 4 个 export 关键字、1 个 import 补充和 1 个入口守卫,零行为改动git diff 中所有其余增行都是注释):

-const COPIES = [                    +export const COPIES = [
-const AXIS_MAP = [                  +export const AXIS_MAP = [
-function analyzeCopy(...)           +export function analyzeCopy(...)
-function runAllChecks(...)          +export function runAllChecks(...)
-main();                             +if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { main(); }

入口守卫是必需的:没有它,本门禁的 import 会把整个 sync 门禁连同它的 process.exit(1) 当作 import 副作用跑掉。这不是新发明,scripts/objectui-changeset-digest.mjscheck-objectui-pin-fresh.mjs import 时用的就是同一套写法(第 665 行)。

⚠️ 守卫最大的风险是把原门禁悄悄改成空转(幻影门禁比 bug 更糟),所以改完立刻实测原门禁仍然真的在跑:pnpm check:skill-frame-sync → 12 个 self-test 用例 + 真实扫描全部照跑,输出未变。

理由:把锚点抄进第二个脚本,等于在门禁自己身上复刻它们所要治的手抄文本漂移病。现在「框架结构」全仓只有一份定义。

判据是结构,不是字节

轴数 + 轴序(经 AXIS_MAP 映射)。不是字节比对——字节比对会在每一条合法修改这段文字的分支上变红(连重新折行都会),一天之内就会被绕过。字节差异只作为信息打印,从不参与判定:

3/3 framework files are byte-identical to the ref (wording differences are not drift).

同理,「落后 N 个提交」只作为上下文打印,不是判据。前提复核里那棵落后 59 提交、框架完好的检出,门禁对它说的是:

HEAD is 1 commit(s) behind, but the frame itself is unchanged — that is fine.

反方向也处理了:如果 HEAD 已经包含了那个 ref,本树按构造不可能过时,此时的结构差异是有意的本地修改(多半就是正在改框架的那个 PR 自己),门禁降级为提示并明说,不去骚扰它。

离线降级:探针选择与实现形态

分诊的硬要求:判据要读 origin/main,因此依赖一次网络 fetch,离线必须降级为警告而非硬红。降级阶梯:

  1. git fetch --quiet --no-tags origin main20 秒超时(派发时刻没人愿意为一个门禁等一分钟),成功 → 判 FETCH_HEAD权威,结构落后即硬红 exit 1
  2. fetch 失败 → 回退到本地 refs/remotes/origin/main降级:同样比、同样报,但只警告 exit 0,并打印该 ref 的最后更新时间——它自己可能和你的树一样旧,所以那份比对是证据不是证明
  3. 连该引用都没有 → 告警放行,并明说这次运行什么都没证明

实现上,降级是单一代码路径上的一次严重度钳制,不是第二条分支:离线跑的是同一段比对、打同一段诊断,只有退出码不同。写成独立的 degraded 分支就没人会执行到,然后腐烂——这道门禁的离线路径就是它的在线路径。

self-test 里最要紧的一对用例正是钉这条契约:同一份 fixture、同一段诊断,严重度只由「有没有拿到活 ref」决定

check-objectui-pin-fresh.mjs「网络失败绝不算绿」的对照值得写明:两边都对,因为受众不同——那道门禁跑在 CI 的 release PR 上,网络是契约的一部分;这道跑在长命的人/agent 工作树上,离线是很平常的一天。同一个词 freshness,作用半径完全不同。硬红一次就会教会所有人绕过它,那比没有门禁更糟(与 #5864「开局就误报的门禁」同一条教训)。

门禁跑在哪里,以及为什么接 CI

跑在:根 package.jsoncheck:skill-frame-freshness,由人 / PM / agent 在派发时刻在长命工作树里手动跑。

不接 lint.yml,两条理由,第二条是决定性的:

  1. CI 检出的是全新的 merge ref,与 base 按构造同步,门禁在那里是恒真的绿——一道永远绿的门禁只是噪音;
  2. 更糟的是假红:在任何合法修改框架的 PR 上(比如 pm-dispatch SKILL:决策分析轴由两条扩为三条 —— 补「实际业务需求」轴与创业聚焦原则(维护者 2026-08-04 指示) #5130已发布 skill objectstack-pm-dispatch 仍是两轴决策框架 —— 与 .claude/ 内部三轴(#5130)分叉,且「创业聚焦」轴是否该泛化给第三方项目未定 #5451/PR docs(skills): 已发布 skill objectstack-pm-dispatch 的决策框架由两轴升为三轴(裁决 B:泛化内核后搬) #5799 那类),「与 main 结构不同」恰恰是该 PR 的目的。接进 CI 会让门禁去红那些正确的 PR,然后它会被删掉。

所以本 PR 完全没有 CI 接线,非阻塞的也没有——刻意不扩大爆炸半径。附带确认:根 package.json没有 check:* 聚合脚本(各门禁在 lint.yml 里逐条显式调用),所以新增这一行不会被任何聚合隐式带进 CI。

命名按族内惯例取 check-skill-frame-freshness.mjs / check:skill-frame-freshness,与 check-skill-frame-sync 共用 skill-frame 前缀,使这对「同构 / 新鲜度」不变量在 ls scripts/ 和 package.json scripts 块里一眼可辨。

已知边界,写出来而不是藏着

一棵老到不包含本文件的树,跑不了本门禁。所以它是前瞻性的:防止树从 main 漂走,治不了已经漂到它自己诞生之前的树。#5866 的处置 1(harness 直接从 origin/main 读 agent 定义)才是治本,且在本仓之外——本门禁是缓解不是根治,分诊原文如此,脚本头注释里也如此写明。

self-test:12 个用例,双向 + 降级

--self-test 接在 check:skill-frame-freshness 同一条链上(--self-test && 真跑),不存在未被调用的幻影自检。fixture 用真实文档(不是合成文本)+ 临时 git 仓库(族内惯例,见 check-empty-changeset.mjs / check-nul-bytes.mjs),origin 指向一个不存在的路径,所以离线那一档是被真实代码真实失败地跑到的,不依赖网络、不用 mock

  ✓ stale tree + authoritative ref → ERROR naming the stale files (the #5866 shape)
  ✓ the SAME stale tree, fetch impossible → degrades to WARN, exit 0, same diagnosis
  ✓ the sync gate is GREEN on that same stale tree — the two invariants are independent
  ✓ tree equals origin/main → OK
  ✓ tree is BEHIND in commits but the frame is unchanged → OK (bytes are not the criterion)
  ✓ HEAD already contains the ref → the difference is a LOCAL edit, WARN not ERROR
  ✓ a framework file exists on the ref but not in the tree → ERROR
  ✓ a framework file exists here but not on the ref → WARN, both readings stated
  ✓ the ref's copy no longer parses with our anchors → ERROR, read as staleness
  ✓ our own copy does not parse → ERROR pointing at check:skill-frame-sync
  ✓ no origin/main reference at all → WARN and pass, stating that nothing was proven
  ✓ --ref names a rev that does not exist → ERROR (never silently judged against something else)
✓ check-skill-frame-freshness self-test: 12 cases pass.

两处值得单独说:

反向验证(方向在跑之前先声明)

声明的方向:普通「红」。 一棵一致停在两轴的工作树、对着三轴的模拟 origin/main,必须响亮变红并点名过时文件;把树恢复成三轴文档,必须变绿。

这里不可能出现 #5046 的「诊断变多」或 #5009 的「反转」方向:本门禁是给一条此前完全无人看守的不变量新加的钉子,没有任何过度伸张的旧规则可供反转,也没有被删掉的 ?? 限支去改变某个计数。方向就是最朴素的那一种,先声明、后实跑。

实跑记录(真实 git 仓库,真实退出码):

=============== STALE tree (limb removed) — severity=error (exit 1) ===============
  • this working tree's decision frame is STRUCTURALLY BEHIND 2c574901b777f0d94763b1a91e7e8164f174641e (HEAD is 1 commit(s) behind):
      .claude/skills/pm-dispatch/SKILL.md  (internal-pm — internal PM dispatch skill, step 8 (Escalate))
        this tree      : 2 axes: long-term-soundness → ai-authoring-safety
        2c574901b777f: 3 axes: business-need → long-term-soundness → ai-authoring-safety
      .claude/agents/os-dev.md  (internal-dev — internal dev-agent definition, "When to stop instead of code")
        this tree      : 2 axes: long-term-soundness → ai-authoring-safety
        2c574901b777f: 3 axes: business-need → long-term-soundness → ai-authoring-safety
      skills/objectstack-pm-dispatch/SKILL.md  (published-pm — published PM skill, the decision frame section)
        this tree      : 2 axes: long-term-soundness → ai-authoring-safety
        2c574901b777f: 3 axes: business-need → long-term-soundness → ai-authoring-safety
      skills/objectstack-pm-dispatch/SKILL.md  (published-dev — published PM skill, the EMBEDDED dev-agent template)
        this tree      : 2 axes: long-term-soundness → ai-authoring-safety
        2c574901b777f: 3 axes: business-need → long-term-soundness → ai-authoring-safety
    Anything dispatched from this tree carries the frame on the FIRST line above.
    That is #5866: the agent escalates on a frame the repo no longer rules by, and
    the report it returns looks complete. Bring the tree forward before dispatching:
      git fetch origin main && git merge --ff-only origin/main

=============== RESTORED tree — severity=ok (exit 0) ===============
  ✓ frame current; 4 copies match 2c574901

方向与声明一致:删轴 → 红且点名全部 4 份副本、缺的正是 business-need;恢复 → 绿。红文案里带着分诊指定的那一句修法原文。

本树实跑(两种模式)

### live, with fetch (mode=fetched):
✓ check-skill-frame-freshness: the decision frame in this tree is current with origin/main (fetched just now).
  4 copies across 3 files; 3 axes: business-need → long-term-soundness → ai-authoring-safety
  HEAD is 1 commit(s) behind, but the frame itself is unchanged — that is fine.
  3/3 framework files are byte-identical to the ref (wording differences are not drift).
EXIT=0

### degraded path, --no-fetch (mode=local-ref):
  ⚠ Compared against a LOCAL ref, not a fresh one (--no-fetch was passed, so nothing was fetched).
    That ref was last updated 2026-08-07T01:14:45+00:00. A green here proves only that
    you match a possibly-stale local copy of main.
✓ check-skill-frame-freshness: the decision frame in this tree is current with refs/remotes/origin/main (local copy, itself possibly stale).
EXIT=0

门禁

检查 结果
pnpm check:skill-frame-freshness(self-test + 真跑) ✅ 12/12 + 绿,exit 0
pnpm check:skill-frame-sync(改动后回归) ✅ 12/12 + 绿,exit 0(证明入口守卫没把它变成空转)
npx eslint scripts/check-skill-frame-freshness.mjs scripts/check-skill-frame-sync.mjs --no-inline-config ✅ exit 0
node scripts/check-nul-bytes.mjs ✅ 5852 个文件,无裸控制字节
自扫(超出门禁扫描面)grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' ✅ 干净

package.json 邻接(PM 排序用)

新增行落在 line 40,紧跟 check:skill-frame-sync(line 39)——语义相邻,且刻意远离冲突热区。建 PR 前实测 origin/main:在飞的 PR #6093check:override-consistency)插在 check:prerelease-pins(line 56)之后,尚未合并。两处相隔 16 行,属不同 hunk,不会冲突,落地顺序无所谓。

交付面

仅三个文件,无 packages/、无 content/docs/未改那三份框架文档本身、无 .claude/ 内容改动:

  • 新增 scripts/check-skill-frame-freshness.mjs
  • scripts/check-skill-frame-sync.mjs:4 个 export + 入口守卫,零行为改动
  • package.json:一行接线

scripts-only ⇒ 不发版 ⇒ 无 changeset,走 skip-changeset 标签(绝不提交空 changeset——#6059 的门禁已在 main 上生效)。


Generated by Claude Code

#5866 的通道:派发提示词由 harness 从磁盘上的工作树装配,而非 origin/main。
共享 PM 检出长期停在长命分支上就会漂移 —— 2026-08-06 实测一棵落后 173 个提交
的检出,三份框架文档全部还是 #5130 之前的两轴决策框架,从它派发出去的每个 dev
agent 都少了 business-need 那条轴,而该轴按 #5130 的裁决是会改变结论的
(#5021#4936 同形状反向判,只有这条轴能把它们分开)。

与 #5865 的 check:skill-frame-sync 是两条不同的不变量,按分诊裁决分脚本:
sync 问「同一棵树内四份副本是否同构」,本门禁问「这棵树相对 origin/main 是否
新鲜」。一棵一致地停在两轴的旧树在 sync 上是绿的(副本确实同构,按设计正确),
却正是 #5866 报告的缺陷 —— self-test 把这条独立性钉死:同一份 fixture 跑两个
门禁,断言 sync 绿、freshness 红。

COPIES / AXIS_MAP / analyzeCopy / runAllChecks 从 check-skill-frame-sync.mjs
导入(仅加 export 关键字 + 入口守卫,零行为改动),使「框架结构」只有一份定义;
把锚点复制进第二个脚本,等于在门禁自己身上复刻它们所要治的手抄漂移病。

判据是轴数与轴序(经 AXIS_MAP 映射),不是字节:字节比对会在每条合法改这段
文字的分支上变红,一天之内就会被绕过。字节差异只作为信息打印,从不参与判定。

离线降级为警告而非硬红(分诊明确要求):fetch 失败时按 refs/remotes/origin/main
降级比对,无该引用则告警放行。实现为单一代码路径上的严重度钳制 —— 离线跑的是
同一段比对、打同一段诊断,只有退出码不同,避免降级分支无人执行而腐烂。

不接 lint.yml:CI 检出的是全新 merge ref,本门禁在那里恒绿;而在任何合法修改
框架的 PR 上,「与 main 不同」恰恰是该 PR 的目的,接进去就是假红。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 1:46am

Request Review

@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file labels Aug 7, 2026
@hotlong hotlong added skip-changeset PR has no user-facing published change; bypasses the changeset gate size/l dependencies Pull requests that update a dependency file and removed size/l dependencies Pull requests that update a dependency file labels Aug 7, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 02:01
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 7efa127 Aug 7, 2026
41 of 44 checks passed
@hotlong
hotlong deleted the claude/issue-5866-frame-freshness-gate branch August 7, 2026 02:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants