Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/pm-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 卡跳过派发 |
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/pm-dispatch/references/platform-readings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不等于类型不可用。
Expand Down
51 changes: 47 additions & 4 deletions scripts/pm/check-half-states.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
})
Expand All @@ -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 ` +
Expand All @@ -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 +
Expand Down Expand Up @@ -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);
Expand Down
Loading