diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index 42fc96f5d3..0be12bde8e 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -397,29 +397,10 @@ issue 对塑造公开契约的某个决定欠规格 —— spec/Zod schema、API —— 或两种读法通向两种架构时:不猜,不写投机代码。返回 `status: "needs_decision"`,把每个问 题连同选项、成本与你的推荐写进 `open_questions`。 -**Analyze every option on four fixed axes — this framing is the core of the escalation, -not decoration:** - -- **Real business need**(实际业务需求)— 该方案服务的是**真实存在的业务场景**,还是投 - 机性能力面?证据必须**实测** —— 谁在写这个键、谁在读这个能力、示例应用与真实部署 - 怎么 - 用;「读起来像有用」不作数。这条轴会改变结论,不是陪衬。 -- **Long-term soundness for THIS project**(项目长远合理性)— 哪个方案符合北极星方向与 - 可持续架构(no workarounds、contract-first)—— 补丁式选项的长期代价要明说。 -- **Making AI-written code — especially AI-authored metadata apps — hard to get wrong** - (防 AI 写代码犯错,尤其是 AI 编写的元数据 app)— 优先选在编写时点就结构性防错的方案 - (严格 schema、publish 时响亮拒绝的校验、declared = enforced),而非消费端宽容 —— 宽 - 容的消费端恰是 AI 生成错误藏身并扩散的地方。 -- **Startup scope discipline**(创业阶段不扩散需求)— **创业阶段聚焦原则**(维护者 - 2026-08-04:这是创业项目,核心能力优先):能力扩张默认从紧,无拉动的声明面按 - implementation-first 处置,已发布零消费的能力不因沉没成本获得豁免。 - -Your recommendation must be justified on all four axes;长远合理性权重恒 ≥50%(2026-09-01 -裁「四维分析中,长期合理应该权重最高,至少50%」):推荐以它领起,其余各轴合起来投不翻 -它,按本义读(缩小而非扩大特例/契约增生),⛔ 不为投机扩张背书;四轴冲突时推荐按拉动排 -序:实测拉动→长远,零拉动→不扩散,防错破平,安全与难逆恒人工(权重与序都只排推荐,⛔ -不授权、不移人工地板);权衡如实呈现交维护者拍板。`main` 在你脚下碎了、依赖未合并、CI -基础设施故障⇒`blocked`(附证据),重试到足以排除你的改动。 +升级分析的四轴决策框架由派发词携带 —— 派发的 PM 从自己那份副本填入(已发布模板里 +是 `rules/dev-template.md` 的 `{decision_frame}` 槽位):每个方案逐轴分析,推荐也按那 +些轴给出理由;派发词没带,停下向 PM 索取,⛔ 不自拟一套轴。`main` 在你脚下碎了、依赖 +未合并、CI 基础设施故障⇒`blocked`(附证据),重试到足以排除你的改动。 ## 终报消息 —— 恰好这段 JSON,不带任何环绕散文 diff --git a/scripts/check-skill-frame-freshness.mjs b/scripts/check-skill-frame-freshness.mjs index 1203988631..8941bcf7b5 100644 --- a/scripts/check-skill-frame-freshness.mjs +++ b/scripts/check-skill-frame-freshness.mjs @@ -12,12 +12,12 @@ // ruled explicitly that they stay two scripts, and the reason is worth stating // because "just add it to the other script" is the obvious wrong move: // -// check:skill-frame-sync "are the FOUR COPIES IN THIS TREE isomorphic?" +// check:skill-frame-sync "are THIS TREE's COPIES isomorphic to each other?" // → compares copy against copy, one tree. // check:skill-frame-freshness "is THIS TREE's frame current with origin/main?" // → compares this tree against a remote ref. // -// They are independent: a tree that is 173 commits behind, whose four copies are +// They are independent: a tree that is 173 commits behind, whose copies are // CONSISTENTLY the old two-axis frame, is **green** on the sync gate — correctly // so, the copies really are isomorphic — while being exactly the defect #5866 // reports. The self-test pins that independence rather than asserting it: the @@ -37,10 +37,15 @@ // hypothetical, and the channel is still live — at the time this gate was // written the shared checkout was 59 commits behind on a different branch. // -// The frame lives in three files / four copies; COPIES and AXIS_MAP are imported -// from check-skill-frame-sync.mjs so that "the frame's structure" has exactly ONE -// definition. Forking those anchors into this script would reproduce, in the -// gates themselves, the hand-copied-text disease they exist to police. +// COPIES and AXIS_MAP are imported from check-skill-frame-sync.mjs so that "the +// frame's structure" has exactly ONE definition — how many copies there are, and +// which files hold them, is that table's answer and never a second list here. +// Forking those anchors into this script would reproduce, in the gates +// themselves, the hand-copied-text disease they exist to police. (It was four +// copies in three files until the 2026-09-03 batch-3 ruling, item 4 option B, +// dropped the two dev-side copies; nothing in this file had to move for that +// except the self-test fixtures below, which used to SPELL one of the dropped +// files instead of deriving it.) // // STRUCTURE, NEVER BYTES // ---------------------- @@ -131,6 +136,21 @@ const DEFAULT_FETCH_TIMEOUT_MS = 20_000; const FRAME_FILES = [...new Set(COPIES.map((c) => c.file))]; +/** + * The copy the self-test's file-level fixtures operate on — the one they delete, + * withhold from a ref, or make unparseable. DERIVED, never spelled: these cases + * named `.claude/agents/os-dev.md` until the 2026-09-03 reduction stopped it + * being a frame file, and a spelled path would have gone on asserting about a + * document the frame no longer lives in. Same rule as everything else in this + * file (#8024): locate the fixture the way the gate locates the real thing. + * + * First entry, so the choice is deterministic and non-empty for any COPIES table + * the sync gate can start up with (an empty one fails that gate first). + */ +const SAMPLE_COPY_ID = COPIES[0].id; +const SAMPLE_FRAME_FILE = COPIES[0].file; +const SAMPLE_FRAME_FILE_RX = new RegExp(SAMPLE_FRAME_FILE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + // --------------------------------------------------------------------------- // git plumbing // --------------------------------------------------------------------------- @@ -827,13 +847,13 @@ function selfTest() { label: 'stale tree + authoritative ref → ERROR naming the stale files (the #5866 shape)', run: () => evaluate({ root: dir, ref: current }), expect: 'error', - wants: [/STRUCTURALLY BEHIND/, /\.claude\/agents\/os-dev\.md/, /3 axes: long-term-soundness/, /4 axes: business-need/, new RegExp(REMEDY.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))], + wants: [/STRUCTURALLY BEHIND/, SAMPLE_FRAME_FILE_RX, /3 axes: long-term-soundness/, /4 axes: business-need/, new RegExp(REMEDY.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))], }); cases.push({ label: 'the SAME stale tree, fetch impossible → degrades to WARN, exit 0, same diagnosis', run: () => evaluate({ root: dir }), expect: 'warn', - wants: [/STRUCTURALLY BEHIND/, /\.claude\/agents\/os-dev\.md/], + wants: [/STRUCTURALLY BEHIND/, SAMPLE_FRAME_FILE_RX], alsoAssert: (v) => (v.clamped ? null : 'expected the verdict to be marked as clamped'), }); // The independence proof: the sync gate is GREEN on this very fixture. @@ -894,14 +914,14 @@ function selfTest() { // --- 6: a framework file main has and we do not -------------------------- { const { dir, b: current } = linear('missing-here', real, real); - git(['rm', '-q', '.claude/agents/os-dev.md'], { cwd: dir }); + git(['rm', '-q', SAMPLE_FRAME_FILE], { cwd: dir }); const without = commitAll(dir, 'drop a framework file'); setOriginMain(dir, current); cases.push({ label: 'a framework file exists on the ref but not in the tree → ERROR', run: () => evaluate({ root: dir, ref: current }), expect: 'error', - wants: [/MISSING from this working tree/, /os-dev\.md/], + wants: [/MISSING from this working tree/, SAMPLE_FRAME_FILE_RX], alsoAssert: () => (without ? null : 'fixture did not commit'), }); } @@ -910,9 +930,9 @@ function selfTest() { { const dir = makeRepo('missing-there'); temps.push(dir); - const partial = new Map([...real].filter(([f]) => f !== '.claude/agents/os-dev.md')); + const partial = new Map([...real].filter(([f]) => f !== SAMPLE_FRAME_FILE)); writeFiles(dir, partial); - const refSha = commitAll(dir, 'main without the dev-agent definition'); + const refSha = commitAll(dir, 'main without one framework file'); writeFiles(dir, real); commitAll(dir, 'tree adds it'); setOriginMain(dir, refSha); @@ -926,7 +946,7 @@ function selfTest() { // --- 8: the frame moved on main, our anchors predate it ------------------ { - const moved = withUnreadableCopy(real, 'internal-dev'); + const moved = withUnreadableCopy(real, SAMPLE_COPY_ID); const { dir, b: current } = linear('anchors', real, moved); git(['checkout', '-q', git(['rev-parse', 'HEAD~1'], { cwd: dir }).stdout.trim()], { cwd: dir }); setOriginMain(dir, current); @@ -940,7 +960,7 @@ function selfTest() { // --- 9: our own tree does not parse -------------------------------------- { - const broken = withUnreadableCopy(real, 'internal-dev'); + const broken = withUnreadableCopy(real, SAMPLE_COPY_ID); const { dir, b: current } = linear('broken-here', broken, real); git(['checkout', '-q', git(['rev-parse', 'HEAD~1'], { cwd: dir }).stdout.trim()], { cwd: dir }); setOriginMain(dir, current); diff --git a/scripts/check-skill-frame-sync.mjs b/scripts/check-skill-frame-sync.mjs index 788daab0e8..7025f97e80 100644 --- a/scripts/check-skill-frame-sync.mjs +++ b/scripts/check-skill-frame-sync.mjs @@ -1,13 +1,22 @@ #!/usr/bin/env node // check-skill-frame-sync — isomorphism gate for the escalation decision frame, -// which exists as FOUR hand-written copies across three files (#5798). +// which exists as TWO hand-written copies across two files (#5798, narrowed to +// two by the 2026-09-03 batch-3 ruling, item 4 option B — see COPIES below). // // The frame ("analyze every option on N fixed axes") is binding text for both // the PM agent and the dev agent, and it is written twice over: once for this // repo's internal agent protocol (`.claude/**`, never published) and once for // third-party projects (`skills/objectstack-pm-dispatch/SKILL.md`, which ships -// verbatim via `npx skills add objectstack-ai/objectstack/skills` and embeds its -// own dev-agent template). Nothing compared them. +// verbatim via `npx skills add objectstack-ai/objectstack/skills`). Nothing +// compared them. +// +// Both surviving copies are PM-side. The two dev-side copies this gate used to +// watch — the internal agent definition's block and the published skill's +// embedded dev template — are gone: a dispatching PM now pastes its own copy +// into the dev prompt's `{decision_frame}` slot, so the dev reads the frame the +// PM is holding rather than a third and fourth transcription of it. That is a +// REDUCTION IN COPIES, not a reduction in coverage — the anti-dormancy scan +// below is what keeps a dev-side copy from quietly coming back. // // The drift is not hypothetical. #5130 (2026-08-04) widened the internal frame // from two axes to three; the published mirror was untouched and stayed at two @@ -24,7 +33,7 @@ // frontmatter and generated indexes, never the body. // // WHAT IS COMPARED — structure, deliberately NOT wording: -// • axis COUNT (per copy, and equal across all four), +// • axis COUNT (per copy, and equal across every copy), // • axis NAME SEQUENCE, mapped through the explicit AXIS_MAP below (internal // Chinese ⇄ published English), order significant, // • the BINDING SENTENCE is in place in every copy, @@ -96,14 +105,23 @@ function soft(template) { } /** - * The four copies. `start` is the sentence that declares the frame, `binding` is + * The two copies. `start` is the sentence that declares the frame, `binding` is * the sentence that makes it binding; the axis entries are whatever lies between * them. Both anchors must match exactly once per file — an ambiguous anchor is * reported rather than silently resolved to the first hit. * + * Why two and not four (2026-09-03 batch-3 ruling, item 4 option B): the frame + * had a PM copy and a dev copy on each side of the publish boundary, and the two + * dev copies were transcriptions of the PM copy beside them. They are now filled + * in at dispatch time from the PM copy instead — the published dev template's + * `{decision_frame}` line, and the dispatch prompt the internal PM writes — so + * the text a dev reads is the text its dispatcher is holding, and cannot be a + * copy that stopped being updated. Adding a copy back is a COPIES edit, which + * the anti-dormancy scan below forces rather than merely invites. + * * EXPORTED for scripts/check-skill-frame-freshness.mjs (#5866), which asks the * OTHER invariant about the same documents — "is this working tree's copy of the - * frame still current with origin/main?" as opposed to this gate's "are the four + * frame still current with origin/main?" as opposed to this gate's "are the * copies in one tree isomorphic?". Two invariants, two scripts (the #5866 triage * ruled explicitly against merging them), but exactly ONE definition of what * "the frame's structure" is — forking these anchors into a second script would @@ -118,14 +136,6 @@ export const COPIES = [ start: soft('**每个方案必须沿%N%固定评估轴'), binding: soft('推荐意见必须基于这%N%轴给出理由'), }, - { - id: 'internal-dev', - file: '.claude/agents/os-dev.md', - lang: 'en', - what: 'internal dev-agent definition, "When to stop instead of code"', - start: soft('**Analyze every option on %N% fixed axes — this framing is the core of the escalation'), - binding: soft('recommendation must be justified on %Q% axes'), - }, { id: 'published-pm', file: 'skills/objectstack-pm-dispatch/SKILL.md', @@ -134,14 +144,6 @@ export const COPIES = [ start: soft('**and analyze every option on the %N% fixed axes below.**'), binding: soft('recommendation must be justified on %Q% axes'), }, - { - id: 'published-dev', - file: 'skills/objectstack-pm-dispatch/SKILL.md', - lang: 'en', - what: 'published PM skill, the EMBEDDED dev-agent template', - start: soft('Analyze every option on %N% fixed axes:'), - binding: soft('Justify your recommendation on %Q% axes'), - }, ]; /** @@ -195,8 +197,8 @@ function toCount(word) { } /** - * An axis entry starts either at a list bullet (three of the four copies) or at - * an `**Axis ① — …**` paragraph (the published PM copy). Both shapes are accepted + * An axis entry starts either at a list bullet (the internal PM copy) or at an + * `**Axis ① — …**` paragraph (the published PM copy). Both shapes are accepted * everywhere, so reformatting one copy into the other's shape does not blind the * gate. A blank line closes an entry; wrapped continuation lines belong to it. */ @@ -499,8 +501,11 @@ export function runAllChecks(copies, axisMap = AXIS_MAP, scanFiles = null) { if (r) results.push(r); } - // Two copies share the published file; sections must not overlap, or an anchor - // is reading the wrong instance and the comparison is vacuous. + // Copies MAY share a file (two did until the 2026-09-03 reduction); whenever + // they do, their sections must not overlap, or an anchor is reading the wrong + // instance and the comparison is vacuous. Kept as a live guard rather than + // deleted with the same-file pair: re-declaring one is a COPIES edit away, and + // this is the check that makes such a pair prove it compares two things. for (const a of results) { for (const b of results) { if (a === b || a.copy.file !== b.copy.file) continue; @@ -521,7 +526,7 @@ export function runAllChecks(copies, axisMap = AXIS_MAP, scanFiles = null) { const counts = new Set(results.map((r) => r.declared)); if (counts.size > 1) { problems.push( - `the four copies do not agree on the NUMBER of axes — this is the #5130 drift:\n` + + `the ${results.length} copies do not agree on the NUMBER of axes — this is the #5130 drift:\n` + results.map((r) => ` ${r.declared} axes ${r.copy.id} (${r.copy.file})`).join('\n') + `\n Every copy of the frame must be updated in the same PR, including the ` + `published mirror in skills/ that ships to third parties.`, @@ -648,7 +653,7 @@ function mutate(copies, id, from, to) { `self-test fixture drifted: mutation target not found in ${c.id} (${c.file}): ${from}`, ); } - // Both published copies share one file; keep the sibling's text in step. + // If two copies ever share one file, keep the sibling's text in step. return { ...c, text }; }).map((c, _i, all) => { const mutated = all.find((x) => x.id === id); @@ -674,7 +679,7 @@ function selfTest() { const cases = [ { - label: 'baseline: the four real copies are isomorphic', + label: 'baseline: the two real copies are isomorphic', copies: () => base, expect: 'green', }, @@ -682,27 +687,27 @@ function selfTest() { // The #5130 shape, incoherent half: bullets changed, prose count not. label: 'one copy loses an axis, its declared count unchanged → red (count vs entries)', copies: () => mutate( - base, 'internal-dev', - '- **Long-term soundness for THIS project**', - ' **Long-term soundness for THIS project**', + base, 'internal-pm', + '- **项目长远合理性**', + ' **项目长远合理性**', ), expect: 'red', - wants: [/internal-dev/, /declares 4 axes but 3 axis entries/], + wants: [/internal-pm/, /declares 4 axes but 3 axis entries/], }, { // The #5130 shape, coherent: one side becomes a consistent three-axis frame. label: 'one copy coherently rewritten to three axes → red (cross-copy count)', copies: () => { let c = mutate( - base, 'internal-dev', - '- **Long-term soundness for THIS project**', - ' **Long-term soundness for THIS project**', + base, 'internal-pm', + '- **项目长远合理性**', + ' **项目长远合理性**', ); - c = mutate(c, 'internal-dev', 'on four fixed axes', 'on three fixed axes'); - return mutate(c, 'internal-dev', 'on all four axes', 'on all three axes'); + c = mutate(c, 'internal-pm', '沿四条固定评估轴', '沿三条固定评估轴'); + return mutate(c, 'internal-pm', '基于这四条轴给出理由', '基于这三条轴给出理由'); }, expect: 'red', - wants: [/do not agree on the NUMBER of axes/, /#5130/, /3 axes {2}internal-dev/], + wants: [/do not agree on the NUMBER of axes/, /#5130/, /3 axes {2}internal-pm/], }, { // Regression pin for the draft defect described in soft(): the two-axis form @@ -711,20 +716,20 @@ function selfTest() { label: 'two-axis copy phrased "both axes" → red on the COUNT, not on extraction', copies: () => { let c = mutate( - base, 'published-dev', - '- Making AI-authored code — especially AI-authored metadata — structurally hard', - ' Making AI-authored code — especially AI-authored metadata — structurally hard', + base, 'published-pm', + '\n**Axis ③ — making AI-authored code', + '\n **Axis ③ — making AI-authored code', ); c = mutate( - c, 'published-dev', - '- Startup scope discipline — do not grow the declared surface', - ' Startup scope discipline — do not grow the declared surface', + c, 'published-pm', + '\n**Axis ④ — startup scope discipline.**', + '\n **Axis ④ — startup scope discipline.**', ); - c = mutate(c, 'published-dev', 'Analyze every option on four fixed axes:', 'Analyze every option on two fixed axes:'); - return mutate(c, 'published-dev', 'Justify your recommendation on all four axes', 'Justify your recommendation on both axes'); + c = mutate(c, 'published-pm', 'every option on the four fixed axes below.', 'every option on the two fixed axes below.'); + return mutate(c, 'published-pm', 'must be justified on **all four** axes.', 'must be justified on **both** axes.'); }, expect: 'red', - wants: [/do not agree on the NUMBER of axes/, /2 axes {2}published-dev/], + wants: [/do not agree on the NUMBER of axes/, /2 axes {2}published-pm/], unwanted: [/BINDING sentence is not in place/, /could not extract/], }, { @@ -768,12 +773,12 @@ function selfTest() { { label: 'the declaring sentence is removed → red (extraction failure, not a skip)', copies: () => mutate( - base, 'internal-dev', - '**Analyze every option on four fixed axes', - '**Weigh the options sensibly', + base, 'internal-pm', + '**每个方案必须沿四条固定评估轴', + '**每个方案应当妥善权衡', ), expect: 'red', - wants: [/internal-dev/, /could not extract the decision frame/], + wants: [/internal-pm/, /could not extract the decision frame/], }, { label: 'a frame count mention drifts from the frame → red', @@ -798,7 +803,7 @@ function selfTest() { wants: [/do not agree on the axis NAME SEQUENCE/], }, { - label: 'an undeclared fifth copy appears → red (anti-dormancy)', + label: 'an undeclared extra copy appears → red (anti-dormancy)', copies: () => base, extraScan: [{ file: 'skills/objectstack-somethingelse/SKILL.md', @@ -807,6 +812,31 @@ function selfTest() { expect: 'red', wants: [/looks like ANOTHER copy of the decision frame/], }, + { + // The regression pin for the 2026-09-03 reduction: the dev-side copies were + // deleted, and the thing that keeps them deleted is this scan. The fixture + // uses the REAL path whose declaration this change dropped + // (`.claude/agents/os-dev.md`) rather than a synthetic one, so a future PR + // that re-adds the block there is refused instead of being asked politely. + // + // ⚠️ MEASURED LIMIT, stated rather than implied: the scan's population is + // per FILE (`declaredFiles.has(f.file)`). A second, undeclared copy pasted + // back into a file that IS declared — the published SKILL.md, which keeps + // the published-pm copy — is NOT refused by this scan, and was not refused + // before this change either. That gap is why the published copy is named by + // heading at the paste site rather than re-transcribed: the paste + // instruction is what keeps a second published copy from being written, and + // review is what enforces it. Closing the gap mechanically needs a + // per-occurrence criterion this gate does not have today. + label: 'the dev-side copy returns to the now-undeclared agent definition → red (anti-dormancy)', + copies: () => base, + extraScan: [{ + file: '.claude/agents/os-dev.md', + text: 'Analyze every option on four fixed axes:\n- Real business need — ...\n', + }], + expect: 'red', + wants: [/looks like ANOTHER copy of the decision frame/, /os-dev\.md/], + }, { // The anti-false-positive direction. #5451 route B generalized the published // copy on purpose: no startup self-description, no this-repo issue numbers. diff --git a/skills/objectstack-pm-dispatch/SKILL.md b/skills/objectstack-pm-dispatch/SKILL.md index 80355fb2e2..3cbbed0f50 100644 --- a/skills/objectstack-pm-dispatch/SKILL.md +++ b/skills/objectstack-pm-dispatch/SKILL.md @@ -620,8 +620,11 @@ Stop the loop and report when any of these hits: ## The developer-agent operating template Paste [`rules/dev-template.md`](./rules/dev-template.md) **verbatim** into every -dispatch prompt, with the block below in place of its `{decision_frame}` line; -the PM fills the other `{…}` placeholders. +dispatch prompt; the PM fills its `{…}` placeholders. `{decision_frame}` takes +the stop trigger below, followed by the whole +`#### The four-axis decision frame (binding)` section above: the frame has ONE +copy in this skill and the PM pastes it at dispatch time, so no dev-side copy +can drift from it. ```text When to STOP instead of coding. If the issue underspecifies a decision that @@ -630,36 +633,6 @@ or two readings of the issue lead to different architectures: make no guess, write no speculative code. Return status "needs_decision" with each question, the options, their costs, and your recommendation in open_questions. A wrong guess shipped is far more expensive than a round-trip to the maintainer. -Analyze every option on four fixed axes: -- Real business need — does the option serve a business scenario that ACTUALLY - EXISTS, or a speculative capability surface? Ask this first. The evidence must - be MEASURED, not inferred: who writes this key, who reads this capability, how - the project's example apps and real deployments use it today. "It reads like it - would be useful" does not count. -- Long-term architectural soundness for THIS project — which option matches a - sustainable architecture (no workarounds, contract-first), not which is - cheapest today. Name the long-term cost of any patch-style option. -- Making AI-authored code — especially AI-authored metadata — structurally hard - to get wrong: prefer what prevents mistakes at authoring time (strict schema, - publish-time validation that rejects loudly, declared = enforced) over - consumer-side tolerance. Lenient consumers are where AI-generated errors hide - and multiply. -- Startup scope discipline — do not grow the declared surface: a - shipped-but-unconsumed capability gets no sunk-cost exemption. A declared - surface with no pull is handled implementation-first — narrow the declaration - until declared = enforced (retire it, or park the vocabulary until the - implementation arrives) rather than building implementation to justify the - declaration. How tight the default is comes from the project's conventions - file, not from this template. -Justify your recommendation on all four axes; if they conflict, present the -trade-off and let the maintainer decide, ranking the recommendation pull-first -(real pull → long-term shape; zero pull → non-proliferation; the AI-safety -axis breaks ties; security/destructive → always the maintainer); the ranking -orders recommendations only — a split still escalates. Long-term soundness -carries the highest weight, at least 50%: lead with it, the others together -cannot outvote it, and read it as shrinking special-cases and contract -accretion rather than as licence to expand speculatively; weight ranks -recommendations only, never authority. ``` **Report contract.** The JSON in the template's final-message block is the