diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 475f8fd1a8a..a0c460a6464 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -107,7 +107,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报 | `pm:dispatched` | 已派发(派发评论记轮次),恒带 assignee;与摘 `pm:queue` 同一次标签写入成对落地 | | `needs-user-decision` | 决定待做:永不派发、永不代答;维护者的收件箱,只由人工裁 | | `pm:on-hold` | 决定已做且答案是暂不做:不派发不催;仅当带机器可读 `Restart-when:` 行才合法 | -| `pm:blocked` + 正文行 `Blocked-by: #N` | 等上游:选择期跳过,#N 关闭时由解锁扫描或 `scheduled-full-run-card.yml` 刷新放回;工已完、PR 被外部门禁卡住的同用本态;正文或线程行首 `Blocked-by:` 指向无 `Ruled:` 的 open `needs-user-decision` 卡 ⇒ ⛔ 不派发,挂本态直至其 `Ruled:` | +| `pm:blocked` + 正文行 `Blocked-by: #N` | 等上游:选择期跳过,#N 关单(PR 则合并,关而未合并不放)时由解锁扫描或 `scheduled-full-run-card.yml` 放回;工已完而被门禁卡住的同用本态;双通道行首 `Blocked-by:` 指向无 `Ruled:` 的 open `needs-user-decision` 卡 ⇒ ⛔ 不派发,直至其 `Ruled:` | | `pm:awaiting-maintainer` | 决定已做,只剩一次 GitHub 之外的人工动作:不派发不催;与其它 pm 状态标签互斥;入态恒带行首 `Maintainer-action:` 行(细则见 `references/state-machine.md`),无行即半态 | | `pm:blocking` | 有 open 下游依赖者(自 `Blocked-by:` 索引推导的缓存,⛔ 不手工挂);进选择全序 | | `pm:retriage` | 向分诊提问(改判、跨域 PR 指定车道、改路由、拆卡、裁 dev 报告留下的分叉;既裁冲突砍项与整树席改标归执行席 ⛔ 不问),定车道与改路由限未派发卡,异议评论写明所求;与现行 `pm:*` 并存、⛔ 不摘原标;带本标签的 `pm:queue` 卡跳过派发 | diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index da66b45cb0a..38a07b41e6c 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -249,7 +249,7 @@ - 评论里独立成行的标记同被读路径吃掉留空行 ⇒ 缺失不证写侧剥除,先读 payload 原体。 - `Blocked-by:` 行写入归 BODY,唯 full-run 卡写评论(其正文每红重写);追加按解码后写回执行。 - 评论里的旧行按同程序增量回填,⛔ 不搞批量突击;半状态巡查与 full-run 刷新读双通道。 -- 解锁扫描 `list_issues` 列卡、`issue_read` 直读目标,⛔ 不加常设评论读;PR 判合并读 `merged_at`。 +- 解锁扫描 `list_issues` 列卡、逐目标直读,⛔ 不加常设评论读;PR 合并走 `pull_request_read`/REST。 - 动手前按空的行内代码跨度判 MCP 读可用否;命中即改从原始体取,⛔ 不拿有损读数回写。 - `list_issue_types` 对本集成 403 而 `issue_write type:` 正常,是读权限缺口。 - ⇒ 直接写已知好值(`Bug`/`Feature`/`Task`),写侧报错才是真信号;列表 403 不等于类型不可用。 diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 7dc7436e66b..db82b25ba99 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -2357,7 +2357,10 @@ function namedProseCandidates(rows) { return rows .map((r) => { const ref = `\`${r.local ? `#${r.number}` : r.key}\``; - if (r.state === 'closed') return `${ref} CLOSED${r.closedAt ? ` (${r.closedAt})` : ''}`; + if (r.state === 'closed') { + const word = r.unmerged === true ? 'CLOSED UNMERGED' : 'CLOSED'; + return `${ref} ${word}${r.closedAt ? ` (${r.closedAt})` : ''}`; + } if (r.state === 'unresolved') return `${ref} UNRESOLVED${r.detail ? ` (${r.detail})` : ''}`; return `${ref} open`; }) @@ -2373,16 +2376,29 @@ function namedProseCandidates(rows) { * scanned, nothing anchored, and the row again says nothing about prose. Only * a non-empty array produces a clause. * + * ## A PR candidate is released by its MERGE, never by its close + * + * The resolver (`blockerResolutionFromRow`) answers a PR number `closed` + * whether it merged or was abandoned, and marks the abandoned one + * `unmerged: true`. This clause used to bucket every `closed` row as a + * satisfied condition and told the seat the card was due back in `pm:queue` — + * for a prose-named PR that closed WITHOUT merging, a release that never + * happened. An unmerged close is now its own bucket: it withholds, it is never + * counted among the expired, and the clause says so instead of "due back". + * * @param {{ key: string, repo: string, number: number, local: boolean, * state: 'open'|'closed'|'unresolved', closedAt?: string|null, - * detail?: string|null }[]|null|undefined} candidates — RESOLVED candidates. + * detail?: string|null, unmerged?: boolean }[]|null|undefined} candidates — + * RESOLVED candidates. * @param {number} [heldBack] — distinct candidates left unresolved by the cap. */ export function proseBlockerClause(candidates, heldBack = 0) { const rows = Array.isArray(candidates) ? candidates : []; if (rows.length === 0) return ''; - const closed = rows.filter((r) => r.state === 'closed'); + const closed = rows.filter((r) => r.state === 'closed' && r.unmerged !== true); + const abandoned = rows.filter((r) => r.state === 'closed' && r.unmerged === true); const unresolved = rows.filter((r) => r.state === 'unresolved'); + const open = rows.filter((r) => r.state === 'open'); const more = heldBack > 0 ? ` (+${heldBack} more candidate(s) named but NOT resolved — the per-card cap is ` + @@ -2407,10 +2423,22 @@ export function proseBlockerClause(candidates, heldBack = 0) { ' None of them could be resolved from this install — a cross-repo target is unjudgeable ' + 'per install (each token reads its own repo), so this clause says NOTHING about whether ' + 'the block still stands, and ⛔ no re-run resolves them.'; - } else { + } else if (abandoned.length === rows.length) { + verdict = + ' Every one of them is a PR that closed UNMERGED — an abandoned attempt releases nothing, ' + + 'so the condition this card states in words is NOT satisfied and the block still stands; ' + + 'the target wants re-pointing (to the PR\'s card, or to the queue with a note).'; + } else if (open.length === rows.length) { verdict = ' They all still read OPEN, so the block itself looks live — what is missing is only the ' + 'line that would let a machine see it when they close.'; + } else { + verdict = + ` None of them has released — ${open.length} still read${open.length === 1 ? 's' : ''} OPEN` + + (abandoned.length > 0 ? `, ${abandoned.length} closed UNMERGED (which withholds)` : '') + + (unresolved.length > 0 ? `, ${unresolved.length} could not be resolved from this install` : '') + + ' — so the block itself looks live, and what is missing is only the line that would ' + + 'let a machine see it when a target merges or closes.'; } return ( head + @@ -24992,6 +25020,21 @@ async function selfTest() { t('H4 prose: an OPEN upstream — the row says the block still looks live', proseRow(['nothing'], [cand(17438, 'open')]).includes('still read OPEN'), true); t('H4 prose: …and never claims an expiry', proseRow(['nothing'], [cand(17438, 'open')]).includes('ALREADY CLOSED'), false); t('H4 prose: a MIXED reading counts the closed half', proseRow(['nothing'], [cand(16293, 'closed'), cand(17438, 'open')]).includes('1 of 2 is ALREADY CLOSED'), true); + // A prose-named PR target: released by its MERGE, never by its close. The + // rows go through the sweep's own projection of the issues-API answer + // (`blockerResolutionFromRow`), the same shapes H19's battery pins. + const proseFromRow = (number, row) => blockerResolutionFromRow({ ...cand(number, 'unread'), local: true }, row); + const mergedPrRow = { state: 'closed', closed_at: '2026-10-30T03:29:36Z', labels: [], pull_request: { merged_at: '2026-10-30T03:29:36Z' } }; + const abandonedPrRow = { state: 'closed', closed_at: '2026-10-28T18:30:39Z', labels: [], pull_request: { merged_at: null } }; + t('H4 prose PR: a prose-named MERGED PR is released — the row says the block already expired', proseRow(['nothing'], [proseFromRow(27888, mergedPrRow)]).includes('ALREADY CLOSED'), true); + t('H4 prose PR: …and says where the card is due back', proseRow(['nothing'], [proseFromRow(27888, mergedPrRow)]).includes('due back in `pm:queue`'), true); + t('H4 prose PR: a prose-named PR that closed UNMERGED is NOT released', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow)]).includes('ALREADY CLOSED'), false); + t('H4 prose PR: …and the row never says the card is due back', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow)]).includes('due back'), false); + t('H4 prose PR: …and names the candidate with its unmerged mark', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow)]).includes('`#27296` CLOSED UNMERGED (2026-10-28T18:30:39Z)'), true); + t('H4 prose PR: …and says what to do with it: re-point, not release', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow)]).includes('releases nothing'), true); + t('H4 prose PR: an UNMERGED close beside an OPEN target is neither an expiry nor all-open', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow), cand(17438, 'open')]).includes('1 still reads OPEN, 1 closed UNMERGED (which withholds)'), true); + t('H4 prose PR: …and the mixed count never counts the unmerged close as expired', proseRow(['nothing'], [proseFromRow(27296, abandonedPrRow), cand(17438, 'open')]).includes('ALREADY CLOSED'), false); + t('H4 prose PR: a MERGED PR beside an UNMERGED one counts only the merge as expired', proseRow(['nothing'], [proseFromRow(27888, mergedPrRow), proseFromRow(27296, abandonedPrRow)]).includes('1 of 2 is ALREADY CLOSED'), true); t('H4 prose: an UNRESOLVED cross-repo candidate claims nothing about the block', proseRow(['nothing'], [{ ...blockerTargetKey({ repo: 'objectstack-ai/objectui', number: 4356 }, REPO4), state: 'unresolved', closedAt: null, detail: 'HTTP 404' }]).includes('says NOTHING about whether'), true); t('H4 prose: …and renders the cross-repo address, not a bare number', proseRow(['nothing'], [{ ...blockerTargetKey({ repo: 'objectstack-ai/objectui', number: 4356 }, REPO4), state: 'unresolved', closedAt: null, detail: 'HTTP 404' }]).includes('`objectstack-ai/objectui#4356` UNRESOLVED'), true); t('H4 prose: the cap is DECLARED, never silent', proseRow(['nothing'], [cand(2, 'closed')], 3).includes('+3 more candidate(s) named but NOT resolved'), true);