From 158742de5b774679540f63ffbaf3317cb1f23724 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 15:42:52 +0000 Subject: [PATCH 1/2] feat(pm): fold a claim or a verdict into one label-write relay stroke label-write --comment-file sends the stamped comment, the label diff and the assignee change as ONE fleet-write dispatch; --land adds pr_ready and automerge_enable for a verdict on a pull request. The comment is judged by post-stamped's judgeBody (signature first line, one-clock stamp, keyed lines), now the one judging path both senders call. Every part is read back and printed on its own line; a part that does not match exits 4 and is never repaired. No new relay op, no change to fleet-write/ops.mjs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQze2kJDxaxMsAW2kY --- .../references/execution-duties.md | 4 +- .../references/landing-operations.md | 2 +- scripts/pm/label-write.mjs | 308 +++++++++++++++++- scripts/pm/post-stamped.mjs | 99 ++++-- 4 files changed, 367 insertions(+), 46 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/execution-duties.md b/.claude/skills/pm-dispatch/references/execution-duties.md index a2d754dd78f..e561b78c092 100644 --- a/.claude/skills/pm-dispatch/references/execution-duties.md +++ b/.claude/skills/pm-dispatch/references/execution-duties.md @@ -67,9 +67,9 @@ - 归属 = session ID,⛔ 不认作者字段;`Account:` 派生自 assignee,不等即半态、持卡席下笔补正。 - 卡 assignee 归 PM,dev 恒不写;PR 派认领账号,交棒同笔改;在飞 PR = `head:claude/` 正文点名本卡。 -- 部分落地(PR 带 `Refs #N (item k)`,⛔ 不 `Fixes`)即释放:合入同笔回 `pm:queue` + 清 assignee。 +- 部分落地(PR 带 `Refs #N (item k)`,⛔ 不 `Fixes`)即一笔释放:`Release:`+回 `pm:queue`+清 assignee。 - `Release:` 行点名已落与余项;余项整落 `ui/**` 外他席树 ⇒ 点名其改标,拆分加 `pm:retriage`。 -- 派发前按序执行原子对:① Assign @me,并把 `pm:dispatched` 与摘 `pm:queue` 放进同一次标签写入。 +- 派发原子对一笔 `label-write`:① `--assign` @me 加 `pm:dispatched` 摘 `pm:queue`,`--comment-file` 带②。 - step ① 之后获得 assignee 的直接弃出本批。 - ② 认领评论照抄 SKILL.md 模板:`Branch:`、`Clause-②:`、`Metadata-protocol:` 各占一行且行首。 - 条款②入队闸门只读认领评论且只读行首键:句中提及与另发评论皆读不到,卡即不可读。 diff --git a/.claude/skills/pm-dispatch/references/landing-operations.md b/.claude/skills/pm-dispatch/references/landing-operations.md index 7579fa26c77..392fcb46361 100644 --- a/.claude/skills/pm-dispatch/references/landing-operations.md +++ b/.claude/skills/pm-dispatch/references/landing-operations.md @@ -48,7 +48,7 @@ - 车道 PM 的权责三件:验收(复核清单)、首次入队、确认 MERGED;落地执行在案判决,非自批。 - 首次入队:ACCEPT 后挂 6–9 分钟 flip 定点,到点核门禁 job 结论。 -- 绿即经中继 op `pr_ready` + `automerge_enable` 落地,拼写见 `rest-channel.md`;未绿按阶梯重挂定点。 +- 绿即落地评论与 `pr_ready`+`automerge_enable` 一笔中继:`label-write --land`;未绿按阶梯重挂定点。 - 写节流拒(exit 10) ⇒ 待 `node scripts/pm/write-pace.mjs --status` 现空槽再重试。 - CI success webhook 不可靠:⛔ 不坐等,也 ⛔ 不忙轮询;定点文本照定时器写法纪律。 - undraft 撞 429 ⇒ 落地入等 `resets at` 态:该刻记卡,到点一次重试;⛔ 不报阻塞、不自定节奏。 diff --git a/scripts/pm/label-write.mjs b/scripts/pm/label-write.mjs index ec6ce829a64..617cd6f714d 100644 --- a/scripts/pm/label-write.mjs +++ b/scripts/pm/label-write.mjs @@ -8,6 +8,8 @@ * node scripts/pm/label-write.mjs --issue N --assign os-project-manager * node scripts/pm/label-write.mjs --issue N --add skip-changeset --dry-run * node scripts/pm/label-write.mjs --issue N --release --recheck-comment ID --comment-file release.md --resume-from BRANCH@SHA + * node scripts/pm/label-write.mjs --issue N --add pm:dispatched --remove pm:queue --assign LOGIN --comment-file claim.md + * node scripts/pm/label-write.mjs --issue PR --comment-file verdict.md --land * node scripts/pm/label-write.mjs --self-test # offline, no network at all * * ## Why this file exists @@ -143,6 +145,32 @@ * `--remove`, `--assign`, `--unassign`, `--clear-assignees` and * `--allow-two-states` are refused beside it. * + * ## `--comment-file` — a claim, a release or a verdict as ONE relay stroke, RELAY-ONLY + * + * A seat's paired writes went out as two relay strokes because each tool sent its own: the `Claim:` or + * `Release:` comment through post-stamped, the labels and assignee through here; a verdict comment, then + * `pr_ready` + `automerge_enable` from a hand-written actions file. Each pair cost a second run and a second + * round trip the seat waited out. `--comment-file PATH` beside an ordinary plan folds the comment into the + * stroke this file already sends, in `--release`'s shape (`releaseActions`): the comment first, then the + * additive verbs. `--land` appends `pr_ready`, then `automerge_enable` (`rest-channel.md`'s order), to a + * stroke on a pull request; a stroke with `--land` may carry no label or assignee change at all. + * + * judged by post-stamped's `judgeBody`, the ONE path its own `--comment` takes: the stamp contract with + * `{{NOW}}` read by this act, the signature first line, a claim's keyed lines, a record's line, + * the decision facets. A refusal is exit 2 with zero writes, its text post-stamped's own. + * paced as every stroke: the dispatch POST behind `write-pace`, every write in it spaced by the executor. + * read back after a success run, each part on its own line: `④ labels` and `④ assignees` against the ② + * target; `④ comment` located on the board by its stored bytes (`pickRelayComment`) and judged by + * post-stamped's `readBackVerdict` (stamp drift, byte class); under `--land`, `④ pull` — `draft` + * false on the REST read, and the arming the executor's landed check on `automerge_enable`'s own + * answer (an `autoMergeRequest`, or `isInMergeQueue`): REST `auto_merge` is unstable and ⛔ never + * a reason to re-arm (`platform-readings.md`), so it is printed and not judged. A part that + * disagrees is exit 4 and is NOT repaired — under the relay a re-send is a second write. + * + * ⛔ RELAY-ONLY, as `--release`: a fold exists only where a stroke does, so a route that resolves to `direct` + * is exit 3 with zero writes — send the comment through post-stamped and the labels here, as before. A comment + * with nothing to fold beside it is refused (exit 2): that write is post-stamped's. + * * ## A retired lane alias is written as its lane * * An `--add` naming a retired lane spelling (`domain:engine`, `domain:services`, @@ -247,7 +275,7 @@ import { // reaches this file through `fleet-write/dispatch.mjs`, so a module-scope use // here would read a binding that has not been initialised yet when // post-stamped is the entrypoint. -import { PLATFORM_COMMENT_FOOTER, pickRelayComment, readBackVerdict as commentReadBackVerdict, relayAction, renderBody } from './post-stamped.mjs'; +import { PLATFORM_COMMENT_FOOTER, judgeBody, pickRelayComment, readBackVerdict as commentReadBackVerdict, relayAction, renderBody, substitutionSummary } from './post-stamped.mjs'; const SELF_PATH = fileURLToPath(import.meta.url); const REPO_ROOT = new URL('../../', import.meta.url); @@ -702,6 +730,17 @@ export function releaseActions({ issue, body, labels, assignees } = {}) { return [relayAction({ mode: 'comment', number: issue }, body), ...relayActions({ issue, labels, assignees })]; } +/** The landing ops `--land` appends, in `rest-channel.md`'s order: ready first — auto-merge on a draft stores nothing. */ +export const LAND_OPS = Object.freeze(['pr_ready', 'automerge_enable']); + +/** + * `--comment-file`'s ONE stroke (header): `--release`'s shape — the comment, then the additive verbs — and under + * `--land` the two landing ops after them. Pure. + */ +export function foldActions({ issue, body, labels, assignees, land = false } = {}) { + return [...releaseActions({ issue, body, labels, assignees }), ...(land ? LAND_OPS.map((op) => ({ op, pull: issue })) : [])]; +} + // --------------------------------------------------------------------------- // Transport — one shape for every call, so `classifyHttp` sees the same fields // whatever went wrong. ⛔ Nothing here throws on an HTTP status: a refusal is @@ -780,6 +819,7 @@ export function parseOptions(argv) { dryRun: false, json: false, release: false, + land: false, recheckComment: null, commentFile: null, resumeFrom: null, @@ -801,6 +841,7 @@ export function parseOptions(argv) { else if (flag === '--dry-run') opts.dryRun = true; else if (flag === '--json') opts.json = true; else if (flag === '--release') opts.release = true; + else if (flag === '--land') opts.land = true; else if (flag === '--recheck-comment') opts.recheckComment = value(); else if (flag === '--comment-file') opts.commentFile = value(); else if (flag === '--resume-from') opts.resumeFrom = value(); @@ -824,9 +865,10 @@ export function parseOptions(argv) { if (!/^[\w.-]+\/[\w.-]+$/.test(opts.repo)) return { ok: false, error: `--repo must be owner/name, got \`${opts.repo}\`` }; // ── `--release` owns its plan: the transition is fixed, so no label or assignee flag may ride beside it ── - const releaseOnly = ['--recheck-comment', '--comment-file', '--resume-from'].filter((f, k) => [opts.recheckComment, opts.commentFile, opts.resumeFrom][k] !== null); + const releaseOnly = ['--recheck-comment', '--resume-from'].filter((f, k) => [opts.recheckComment, opts.resumeFrom][k] !== null); if (!opts.release && releaseOnly.length) return { ok: false, error: `${releaseOnly.join(', ')} belong to --release and mean nothing without it` }; if (opts.release) { + if (opts.land) return { ok: false, error: '--land readies and arms a pull request; a --release moves an issue out of a claim — the two never share a stroke' }; if (opts.add.length || opts.remove.length || opts.assign.length || opts.unassign.length || opts.clearAssignees || opts.allowTwoStates) { return { ok: false, error: `--release writes exactly \`${RELEASE_FROM}\` → \`${RELEASE_TO}\` and clears every assignee; --add/--remove/--assign/--unassign/--clear-assignees/--allow-two-states are refused beside it` }; } @@ -850,7 +892,12 @@ export function parseOptions(argv) { if (opts.clearAssignees && (opts.assign.length || opts.unassign.length)) { return { ok: false, error: '--clear-assignees cannot be combined with --assign/--unassign' }; } - if (!opts.add.length && !opts.remove.length && !opts.assign.length && !opts.unassign.length && !opts.clearAssignees) { + if (opts.land && !opts.commentFile) return { ok: false, error: '--land folds the landing into a verdict comment\'s stroke and needs --comment-file PATH; a bare landing is the relay actions file `rest-channel.md` spells' }; + const planned = opts.add.length || opts.remove.length || opts.assign.length || opts.unassign.length || opts.clearAssignees; + if (opts.commentFile && !planned && !opts.land) { + return { ok: false, error: '--comment-file with nothing to fold beside it is a lone comment — post-stamped\'s write (`post-stamped.mjs --comment=N`); pass a label/assignee change or --land' }; + } + if (!planned && !opts.commentFile) { return { ok: false, error: 'nothing to write — pass at least one of --add / --remove / --assign / --unassign / --clear-assignees' }; } @@ -865,6 +912,7 @@ const USAGE = [ ' [--allow-two-states] [--dry-run] [--json]', ' node scripts/pm/label-write.mjs --issue N --release --recheck-comment ID --comment-file PATH', ` --resume-from BRANCH@SHA|${RESUME_FROM_NONE} [--repo OWNER/NAME] [--dry-run] [--json]`, + ' node scripts/pm/label-write.mjs --issue N [label/assignee flags] --comment-file PATH [--land] [--dry-run] [--json]', ' node scripts/pm/label-write.mjs --self-test', '', ` --release reclaims a SILENT claim: ${RELEASE_FROM} → ${RELEASE_TO}, every assignee cleared, behind the \`Release:\``, @@ -873,6 +921,10 @@ const USAGE = [ ' the SERVER clock; a younger knock prints how long remains. BRANCH@SHA is checked against the branch tip and', ` against open pulls on that head, and is written as \`${RESUME_FROM_KEY} BRANCH@SHA\` for the next claimant.`, '', + ' --comment-file PATH beside a label/assignee change folds the comment (a Claim:, a Release:, a verdict) into the', + ' SAME stroke, comment first, judged by post-stamped\'s own path (stamp, signature, keyed lines); --land appends', + ' pr_ready then automerge_enable on a pull request. RELAY-ONLY; every part is read back on its own ④ line.', + '', ' Additive `POST .../labels` and directed `DELETE .../labels/{name}` first — neither can strip a', ' label a concurrent seat wrote. A platform refusal falls back ONCE to `PATCH .../issues/{n}` with', ' the full target set AND the current assignees echoed, then reads back either way.', @@ -983,6 +1035,12 @@ export async function runLabelWrite(options, deps = {}) { } if (twoStates) record(`[${stamp()}] ② --allow-two-states — letting it through, declared: ${twoStates}`); + // ── `--comment-file`: the comment (and `--land`) folded into this ONE stroke, RELAY-ONLY (header) ── + if (options.commentFile) { + const readText = deps.readText ?? ((p) => readFileSync(p, 'utf8')); + return foldStroke({ options, repo, issue, base, labels, assignees, kind: targetKind.kind, route, send, clock, readText, record, stamp, result, doCall, httpCalls }); + } + const fallbackBody = patchFallbackBody({ labelTarget: labels.target, assigneeTarget: assignees.target }); // ── the transport, said out loud before anything leaves ─────────────────── @@ -1156,6 +1214,139 @@ export async function runLabelWrite(options, deps = {}) { return result(EXIT_OK, { labels, assignees, fallbackUsed, reAdded, verdict, assigneeVerdict, backLabels, backAssignees, transport: route.transport, relay }); } +/** + * `--comment-file`'s stroke, from step ② on (header): judge the comment on post-stamped's path, send the ONE + * dispatch, read every part back on its own line. `c` carries `runLabelWrite`'s steps ① and ② and its doors. + */ +async function foldStroke(c) { + const { options, repo, issue, base, labels, assignees, route, record, stamp, result, doCall } = c; + const nothing = () => record(`[${stamp()}] ⛔ NOTHING WAS WRITTEN.`); + const done = (exit, extra = {}) => result(exit, { mode: 'fold', labels, assignees, transport: route.transport, ...extra }); + const refuse = (why, extra = {}) => { + record(`[${stamp()}] ② REFUSED — ${why}`); + nothing(); + return done(EXIT_USAGE, { stoppedAt: 2, refusal: why, ...extra }); + }; + if (options.land && c.kind !== KIND_PULL) { + return refuse(`--land readies and arms a PULL REQUEST, and ${repo}#${issue} is ${c.kind === null ? 'of no kind step ① could read' : 'an issue'}`); + } + let text; + try { + text = c.readText(options.commentFile); + } catch (e) { + return refuse(`cannot read --comment-file ${options.commentFile}: ${e?.message ?? e}`); + } + const judged = judgeBody(text, { mode: 'comment' }, c.clock().getTime(), 'label-write'); + if (!judged.ok) { + record(judged.text); + nothing(); + return done(EXIT_USAGE, { stoppedAt: 2, refusal: 'comment' }); + } + const { rendered } = judged; + const body = rendered.body; + record(`[${stamp()}] ② comment — ${Buffer.byteLength(body, 'utf8')} byte(s) for ${repo}#${issue}, stamped \`${rendered.stamp}\` · ${substitutionSummary(rendered)}`); + + if (route.error) { + record(`[${stamp()}] ③ PREREQUISITE NOT MET — ${route.error}`); + nothing(); + return done(EXIT_PREREQUISITE, { stoppedAt: 3 }); + } + if (route.transport !== 'dispatch') { + record( + `[${stamp()}] ③ PREREQUISITE NOT MET — a folded stroke is RELAY-ONLY: it exists only as ONE repository_dispatch, and this route ` + + `resolved to \`${route.transport}\` (${route.reason}). Send the comment through post-stamped and the rest without --comment-file, as two writes.`, + ); + nothing(); + return done(EXIT_PREREQUISITE, { stoppedAt: 3, refusal: 'relay-only' }); + } + record(`[${stamp()}] ③ transport ${route.transport} — ${route.reason}`); + const actions = foldActions({ issue, body, labels, assignees, land: options.land }); + const packed = packRequest({ repo, session: route.session, actions, now: () => c.clock().getTime() }); + if (!packed.ok) { + record(relayRefusalText(packed.errors)); + nothing(); + return done(EXIT_USAGE, { stoppedAt: 3 }); + } + record(`[${stamp()}] ③ 写 (relay) — ONE dispatch, request ${packed.payload.request_id}: ${actions.map((a) => a.op).join(' → ')}`); + if (options.dryRun) { + record(`[${stamp()}] ③ DRY RUN — nothing sent. Payload: ${JSON.stringify(packed.payload)}`); + record(`[${stamp()}] ④ read-back SKIPPED — ⛔ a dry run proves the PLAN and never the board.`); + return done(EXIT_OK, { dryRun: true, payload: packed.payload }); + } + const sentAt = c.clock().getTime(); + const sent = await c.send(packed.payload, { token: TOKEN, log: (line) => record(` ${line}`) }); + c.httpCalls.push({ op: 'relay', call: `dispatch ${packed.payload.request_id}`, status: sent.status, verdict: sent.state, detail: sent.run?.url ?? sent.detail ?? '' }); + if (sent.state === 'no-run' || sent.state === 'timeout') { + // ⛔ Under EVERY transport request: the stroke's first action is a comment, so a replay is a second comment. + record(unconfirmedText(sent, 'label-write --comment-file')); + return done(EXIT_UNCONFIRMED, { stoppedAt: 3, relay: sent }); + } + if (!sent.ok) { + record( + `[${stamp()}] ③ relay ${sent.state === 'refused' ? 'REFUSED the dispatch' : 'run FAILED'} — ${sent.detail}${sent.run?.url ? ` ${sent.run.url}` : ''}. ` + + '⛔ Not retried and not fallen back: go READ the run and the card — the executor stops at its first failing action, so a failed run may have left the comment with nothing behind it.', + ); + return done(exitForResult(sent), { stoppedAt: 3, relay: sent }); + } + + // ── ④ 回读 — every part on its own line, the board's word and never the run's ── + const dispatchedAt = Number.isFinite(sent.dispatchedAt) ? sent.dispatchedAt : sentAt; + const runRef = sent.run?.url ?? sent.run?.id ?? ''; + const back = await doCall('card-read', base, {}); + const tailRead = back.verdict === 'ok' ? await doCall('comments-list', `${base}/comments?since=${encodeURIComponent(new Date(dispatchedAt - 60_000).toISOString())}&per_page=100`, {}) : null; + const pullRead = options.land && tailRead?.verdict === 'ok' ? await doCall('pull-read', `/repos/${repo}/pulls/${issue}`, {}) : null; + const unread = [back, tailRead, pullRead].filter((r) => r !== null).find((r) => r.verdict !== 'ok'); + if (unread) { + record( + `[${stamp()}] ④ read-back — COULD NOT READ (${unread.call} -> HTTP ${unread.status}). The relay run ${runRef} completed with success, and ⛔ that is NOT a read-back: ` + + 'the stroke is NOT MEASURED. Go READ the card; ⛔ do not re-run blind.', + ); + return done(EXIT_PREREQUISITE, { stoppedAt: 4, relay: sent }); + } + const backLabels = labelNamesOf(back.json?.labels); + const backAssignees = loginsOf(back.json?.assignees); + const labelVerdict = readBackVerdict({ target: labels.target, removeCalls: labels.removeCalls, readBack: backLabels }); + const assigneeVerdict = readBackVerdict({ target: assignees.target, removeCalls: assignees.removeCalls, readBack: backAssignees }); + const hit = pickRelayComment(Array.isArray(tailRead.json) ? tailRead.json : [], body, dispatchedAt); + const mark = (clean) => (clean ? '✓' : '✗'); + record(`[${stamp()}] ④ labels ${mark(verdictIsClean(labelVerdict))} — ${backLabels.length}: ${render(backLabels)} · target ${render(labels.target)}`); + if (labelVerdict.concurrentAdds.length) { + record(`[${stamp()}] ④ note — ${render(labelVerdict.concurrentAdds)} is on the card and was never in this write's target. ⛔ NOT a mismatch: another seat's additive write.`); + } + record(`[${stamp()}] ④ assignees ${mark(verdictIsClean(assigneeVerdict))} — ${backAssignees.length}: ${render(backAssignees)} · target ${render(assignees.target)}`); + record(`[${stamp()}] ④ comment ${mark(hit !== null)} — ${hit ? `${hit.id} stores the bytes this stroke sent` : 'NOT FOUND storing the body this run sent'}`); + if (hit) { + const said = commentReadBackVerdict({ + stamp: rendered.stamp, + writtenAt: hit.created_at, + sent: body, + stored: hit.body, + substituted: rendered.substituted, + quoted: rendered.quoted, + verbatim: rendered.verbatim, + verbatimTokens: rendered.verbatimTokens, + }); + for (const l of said.lines ?? []) record(` ${String(l).trim()}`); + } + const pullReady = pullRead === null ? true : pullRead.json?.draft === false; + if (pullRead !== null) { + const armed = pullRead.json?.auto_merge ? `auto_merge ${pullRead.json.auto_merge.merge_method ?? 'set'}` : 'auto_merge null on this read'; + record( + `[${stamp()}] ④ pull ${mark(pullReady)} — draft ${pullRead.json?.draft} · ${armed}, printed and ⛔ not judged (unstable; never a reason to re-arm) — ` + + `the arming is the executor's landed check on automerge_enable's own answer, which this success run passed`, + ); + } + if (!hit || !verdictIsClean(labelVerdict) || !verdictIsClean(assigneeVerdict) || !pullReady) { + record( + `[${stamp()}] ④ MISMATCH — the relay run ${runRef} reported success and the board does not show the stroke whole (the ✗ lines above). ` + + '⛔ Not repaired here — under the relay a re-send is a second write. Go READ the card and fix it deliberately.', + ); + return done(EXIT_READ_BACK_MISMATCH, { stoppedAt: 4, relay: sent, comment: hit?.id ?? null, labelVerdict, assigneeVerdict, pullReady }); + } + record(`[${stamp()}] ④ MATCHES — comment ${hit.id} · labels ${render(backLabels)} · assignees ${render(backAssignees)}${pullRead ? ' · pull ready' : ''} (via the relay run ${runRef}).`); + return done(EXIT_OK, { relay: sent, comment: hit.id, backLabels, backAssignees, labelVerdict, assigneeVerdict }); +} + /** * `--release`, end to end (header: `--release`). `deps` is how the self-test * drives every branch: `call` (the shape `rest` answers, `date` included), @@ -1454,6 +1645,7 @@ export function classifyOp(method, path) { if (/\/issues\/\d+\/comments(\?|$)/.test(path)) return 'comments-list'; if (/\/branches\//.test(path)) return 'branch-read'; if (/\/pulls(\?|$)/.test(path)) return 'pulls-list'; + if (/\/pulls\/\d+$/.test(path)) return 'pull-read'; if (/\/labels\/[^/]+$/.test(path) && method === 'DELETE') return 'label-delete'; if (/\/labels$/.test(path)) return 'label-add'; if (/\/assignees$/.test(path)) return method === 'DELETE' ? 'assignee-remove' : 'assignee-add'; @@ -1488,6 +1680,9 @@ export function fakeBoard(initial = {}) { serverNow: initial.serverNow ?? null, // 'issue' (default) | 'pull' | 'unreadable' — what step ①'s card object says the target is. kind: initial.kind ?? 'issue', + // A pull's two landing fields, as `GET /pulls/{n}` answers them (`--land`'s read-back). + draft: initial.draft ?? true, + autoMerge: initial.autoMerge ?? null, }; const calls = []; const bodies = []; @@ -1541,6 +1736,10 @@ export function fakeBoard(initial = {}) { return wrap(200, board.pulls.filter((p) => p.head?.ref === head && (p.state ?? 'open') === (query.get('state') ?? 'open'))); } + if (op === 'pull-read') { + if (board.kind !== 'pull') return wrap(404, { message: 'Not Found' }); + return wrap(200, { number: board.number, state: board.state, draft: board.draft, auto_merge: board.autoMerge }); + } if (op === 'card-read') { // The two members `readTargetKind` reads, shaped as GitHub's issues endpoint answers them. const kindMembers = @@ -1609,12 +1808,17 @@ function fakeRelay(board, outcome = 'success', sent = []) { if (outcome === 'timeout') return { ...base, state: 'timeout', ok: false, run: { ...RUN, status: 'in_progress', conclusion: null } }; if (outcome === 'failure') return { ...base, state: 'failure', ok: false, run: { ...RUN, conclusion: 'failure' }, detail: 'conclusion failure' }; if (outcome === 'refused') return { ...base, state: 'refused', ok: false, status: 404, verdict: 'refusal', run: null, detail: 'Not Found' }; - // `lie`: the run says success and applies nothing — the read-back must catch it. - for (const a of outcome === 'lie' ? [] : payload.actions) { + // `lie`: the run says success and applies nothing — the read-back must catch it. `lie-OP,OP`: it skips just + // those ops; `mangle-comment`: the comment is stored with a byte the sanitizer ate — both caught the same way. + const skipped = outcome.startsWith('lie-') ? outcome.slice(4).split(',') : []; + for (const a of outcome === 'lie' ? [] : payload.actions.filter((x) => !skipped.includes(x.op))) { if (a.op === 'comment') { const at = new Date(dispatchedAt).toISOString(); - board.comments.push({ id: ++nextCommentId, body: `${a.body}${PLATFORM_COMMENT_FOOTER}`, created_at: at, updated_at: at }); + const stored = outcome === 'mangle-comment' ? a.body.replace('Claim:', 'Claim') : a.body; + board.comments.push({ id: ++nextCommentId, body: `${stored}${PLATFORM_COMMENT_FOOTER}`, created_at: at, updated_at: at }); } + if (a.op === 'pr_ready') board.draft = false; + if (a.op === 'automerge_enable') board.autoMerge = { merge_method: 'squash', enabled_by: { login: 'objectstack-fleet[bot]' } }; if (a.op === 'labels_add') board.labels = dedupe([...board.labels, ...a.labels]); if (a.op === 'labels_remove') board.labels = board.labels.filter((l) => !a.labels.includes(l)); if (a.op === 'assign') board.assignees = dedupe([...board.assignees, ...a.assignees]); @@ -1631,7 +1835,12 @@ async function driveOffline(argv, boardInit = {}, extra = {}) { const out = []; const sent = []; const send = extra.send ?? (extra.relay ? fakeRelay(fake.board, extra.relay, sent) : undefined); - const res = await runLabelWrite(parsed.options, { call: fake.call, log: (l) => out.push(l), route: extra.route ?? DIRECT_ROUTE, send }); + const files = extra.files ?? {}; + const readText = (p) => { + if (p in files) return files[p]; + throw new Error(`ENOENT: no such file, open '${p}'`); + }; + const res = await runLabelWrite(parsed.options, { call: fake.call, log: (l) => out.push(l), route: extra.route ?? DIRECT_ROUTE, send, readText, now: extra.now }); return { parsed, exit: res.exit, res, fake, out, sent, text: out.join('\n') }; } @@ -1700,6 +1909,7 @@ export async function relayMissProbe({ route, send }) { // The battery ledger this self-test's floor is evaluated against. A battery // that stops registering cases is the bug; a pinned TOTAL would hide it the // moment a sibling battery grows (#13489, the shape post-stamped.mjs carries). +const FOLD_BATTERY = "the fold: a claim (comment + labels + assignee) and a verdict (comment + pr_ready + automerge_enable) as ONE relay stroke, the comment judged on post-stamped's path, every part read back on its own line, a part that fails read-back exit 4 and never repaired, RELAY-ONLY"; const KIND_BATTERY = 'the kind rule: no pm state added to a pull request, no `pm:awaiting-merge` added to an issue, a removal never refused, an unreadable kind refused — on both write paths, before the transport'; const SELF_TEST_BATTERIES = Object.freeze({ 'the re-exec guard: the name this tool sets, and the patrol name that must not silence it': 11, @@ -1715,8 +1925,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the CLI: what a typo must never be allowed to mean': 8, 'the relay transport: ONE dispatch carrying the whole stroke, the same read-back, and an accepted dispatch with no run UNCONFIRMED under auto too — never a direct write': 14, 'the release: a silent claim reclaimed in ONE relay stroke — Release: comment → pm:queue on → pm:dispatched off → unassign — on the knock\'s SERVER-clock age, RELAY-ONLY, never a direct write': 32, + [FOLD_BATTERY]: 24, }); -const SELF_TEST_BATTERY_FLOOR = 13; +const SELF_TEST_BATTERY_FLOOR = 14; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -2115,6 +2326,86 @@ export async function selfTest() { t('…and refuses --release without the knock, the file or the resume line', [parseOptions(RELEASE_ARGV.filter((a, i) => !(i >= 5 && i <= 6))).ok, parseOptions(RELEASE_ARGV.filter((a, i) => !(i >= 7 && i <= 8))).ok, parseOptions(RELEASE_ARGV.slice(0, -2)).ok], [false, false, false]); } + // ── the fold ───────────────────────────────────────────────────────────── + battery(FOLD_BATTERY); + { + const SESSION = 'session_01ABCDEFGHJKMNPQRSTVWXYZ'; + const route = (requested = 'auto') => ({ requested, transport: 'dispatch', reason: 'self-test: dispatch', error: null, session: SESSION }); + const SIG = `domain:skills#2 · os-justin · ${SESSION}`; + const CLAIM_FILE = '/tmp/self-test/claim.md'; + const VERDICT_FILE = '/tmp/self-test/verdict.md'; + const CLAIM = `${SIG}\nClaim: PM loop round 1 · {{NOW}}\nSession: \`${SESSION}\`\nBranch: \`claude/issue-7-fold\`\nSeat: \`domain:skills#2\`\nClause-②: no\n`; + const VERDICT = `${SIG}\nVerdict: ACCEPT at {{NOW}} — the landing rides this stroke.\n`; + const files = { [CLAIM_FILE]: CLAIM, [VERDICT_FILE]: VERDICT }; + const NOW = () => new Date(Date.UTC(2026, 10, 8, 15, 0, 0)); + const at = Date.UTC(2026, 10, 8, 15, 0, 0); + const claimArgv = ['--repo', 'objectstack-ai/objectstack', '--issue', '7', '--add', 'pm:dispatched', '--remove', 'pm:queue', '--assign', 'dev', '--comment-file', CLAIM_FILE]; + const verdictArgv = ['--repo', 'objectstack-ai/objectstack', '--issue', '7', '--comment-file', VERDICT_FILE, '--land']; + const queued = { labels: ['pm:queue', 'domain:skills'], assignees: [], serverNow: at }; + const pull = { kind: 'pull', labels: ['domain:skills'], serverNow: at }; + const fold = (argv, board, relay = 'success', extra = {}) => driveOffline(argv, board, { route: route(), relay, files, now: NOW, ...extra }); + const writesOf = (run) => run.fake.calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`); + + t('the stroke is --release\'s shape — comment first, then the additive verbs — and --land appends ready then arm', [ + foldActions({ issue: 7, body: 'b', labels: computeLabelTarget({ current: ['pm:queue'], add: ['pm:dispatched'], remove: ['pm:queue'] }), assignees: computeAssigneeTarget({ current: [], assign: ['dev'] }) }).map((a) => a.op), + foldActions({ issue: 7, body: 'b', labels: computeLabelTarget({}), assignees: computeAssigneeTarget({}), land: true }), + ], [['comment', 'labels_add', 'labels_remove', 'assign'], [{ op: 'comment', issue: 7, body: 'b' }, { op: 'pr_ready', pull: 7 }, { op: 'automerge_enable', pull: 7 }]]); + + const claim = await fold(claimArgv, queued); + t('⭐ a CLAIM folds into ONE dispatch: comment → labels_add → labels_remove → assign, and lands, exit 0', [claim.exit, claim.sent.length, claim.sent[0]?.actions.map((a) => a.op), claim.fake.board.labels, claim.fake.board.assignees], [EXIT_OK, 1, ['comment', 'labels_add', 'labels_remove', 'assign'], ['domain:skills', 'pm:dispatched'], ['dev']], claim.text.slice(-600)); + const stored = claim.fake.board.comments.at(-1)?.body ?? ''; + t('…the comment went out stamped by THIS act ({{NOW}} substituted) under its signature line', [stored.startsWith(`${SIG}\nClaim: PM loop round 1 · 2026-11-08T15:00Z`), stored.includes('{{NOW}}')], [true, false]); + t('⭐ …and every part is read back on its OWN line: labels, assignees, comment — the comment with its stamp drift and byte class', ['④ labels ✓', '④ assignees ✓', '④ comment ✓', 'stamp: `2026-11-08T15:00Z`', 'drift 0', 'read-back: clean', '④ MATCHES'].map((l) => claim.text.includes(l)), [true, true, true, true, true, true, true]); + t('…from reads alone: ZERO direct writes, the card and the tail read once each after the run', [writesOf(claim), claim.fake.calls.map((c) => c.op)], [[], ['card-read', 'card-read', 'comments-list']]); + + const verdict = await fold(verdictArgv, pull); + t('⭐ a VERDICT folds its landing: comment → pr_ready → automerge_enable in ONE dispatch, the pull ready and armed, exit 0', [verdict.exit, verdict.sent.length, verdict.sent[0]?.actions.map((a) => a.op), verdict.fake.board.draft, verdict.fake.board.autoMerge !== null], [EXIT_OK, 1, ['comment', 'pr_ready', 'automerge_enable'], false, true], verdict.text.slice(-600)); + t('…read back on its own lines: the comment, and `④ pull` from the pull\'s own read — draft judged, auto_merge printed and not judged', [verdict.text.includes('④ comment ✓'), verdict.text.includes('④ pull ✓ — draft false'), verdict.text.includes('not judged'), verdict.fake.calls.map((c) => c.op)], [true, true, true, ['card-read', 'card-read', 'comments-list', 'pull-read']]); + + // A part that fails read-back: exit 4, its own line ✗, nothing repaired. + const noLabels = await fold(claimArgv, queued, 'lie-labels_add,labels_remove'); + t('⭐ REFUSED at read-back: a run that skipped the labels is exit 4 — `④ labels ✗` beside `④ comment ✓` — and NOT repaired', [noLabels.exit, noLabels.text.includes('④ labels ✗'), noLabels.text.includes('④ comment ✓'), noLabels.text.includes('MISMATCH'), writesOf(noLabels), noLabels.sent.length], [EXIT_READ_BACK_MISMATCH, true, true, true, [], 1]); + const noAssign = await fold(claimArgv, queued, 'lie-assign'); + t('…a run that skipped the assignee: `④ assignees ✗`, exit 4', [noAssign.exit, noAssign.text.includes('④ assignees ✗'), noAssign.text.includes('④ labels ✓')], [EXIT_READ_BACK_MISMATCH, true, true]); + const mangled = await fold(claimArgv, queued, 'mangle-comment'); + t('…a comment stored with a byte the sanitizer ate is not found by its bytes: `④ comment ✗`, exit 4', [mangled.exit, mangled.text.includes('④ comment ✗'), mangled.text.includes('NOT FOUND')], [EXIT_READ_BACK_MISMATCH, true, true]); + const notReady = await fold(verdictArgv, pull, 'lie-pr_ready'); + t('…a pull still draft after a success run: `④ pull ✗`, exit 4, never a second pr_ready', [notReady.exit, notReady.text.includes('④ pull ✗'), notReady.sent.length], [EXIT_READ_BACK_MISMATCH, true, 1]); + + // The comment is judged on post-stamped's path, before anything is packed. + const unsigned = await fold(claimArgv, queued, 'success', { files: { [CLAIM_FILE]: CLAIM.slice(SIG.length + 1) } }); + t('⭐ an UNSIGNED comment is refused by post-stamped\'s own judge: exit 2, zero sends, zero writes, the sender named', [unsigned.exit, unsigned.sent.length, writesOf(unsigned), unsigned.text.includes('label-write: REFUSED'), unsigned.text.includes('opens with no signature line')], [EXIT_USAGE, 0, [], true, true]); + const typed = await fold(claimArgv, queued, 'success', { files: { [CLAIM_FILE]: CLAIM.replace('{{NOW}}', '2026-11-08T14:00Z') } }); + t('⭐ a stamp TYPED on the act\'s own line is refused — the one-clock rule rides the fold: exit 2, zero sends', [typed.exit, typed.sent.length], [EXIT_USAGE, 0]); + const badKey = await fold(claimArgv, queued, 'success', { files: { [CLAIM_FILE]: CLAIM.replace('Clause-②: no', 'Clause-②: YES') } }); + t('a claim whose keyed line its owner cannot read is refused the same way', [badKey.exit, badKey.sent.length, badKey.text.includes('Clause-②')], [EXIT_USAGE, 0, true]); + const missing = await fold(claimArgv, queued, 'success', { files: {} }); + t('a comment file that cannot be read is exit 2 before any send', [missing.exit, missing.sent.length], [EXIT_USAGE, 0]); + const landIssue = await fold(verdictArgv, { labels: [], serverNow: at }); + t('--land on an ISSUE is refused before the comment is even read: exit 2, zero sends', [landIssue.exit, landIssue.sent.length, landIssue.text.includes('PULL REQUEST')], [EXIT_USAGE, 0, true]); + const statePull = await fold(['--repo', 'objectstack-ai/objectstack', '--issue', '7', '--add', 'pm:dispatched', '--comment-file', CLAIM_FILE], pull); + t('the kind rule still runs first: a pm state onto a PULL is refused, zero sends', [statePull.exit, statePull.sent.length], [EXIT_USAGE, 0]); + + // RELAY-ONLY, and the UNCONFIRMED answer, as --release. + const direct = await fold(claimArgv, queued, 'success', { route: DIRECT_ROUTE, send: async () => { throw new Error('direct must not dispatch'); } }); + t('⛔ a route that resolves to DIRECT is exit 3 with ZERO writes — a fold is RELAY-ONLY', [direct.exit, direct.res.refusal, writesOf(direct), direct.text.includes('RELAY-ONLY')], [EXIT_PREREQUISITE, 'relay-only', [], true]); + const noRun = await fold(claimArgv, queued, 'no-run'); + t('⛔ an accepted dispatch with no run is exit 6 under auto — ONE dispatch, no read-back, no direct write', [noRun.exit, noRun.sent.length, noRun.fake.calls.map((c) => c.op), noRun.text.includes(unconfirmedText(noRun.res.relay, 'label-write --comment-file'))], [EXIT_UNCONFIRMED, 1, ['card-read'], true]); + const failed = await fold(claimArgv, queued, 'failure'); + t('⛔ a run that FAILED is exit 5, never fallen back from', [failed.exit, writesOf(failed)], [EXIT_PLATFORM_REFUSAL, []]); + const dry = await fold([...claimArgv, '--dry-run'], queued, 'success', { send: async () => { throw new Error('a dry run must not dispatch'); } }); + t('a dry run judges and packs the stroke, sends nothing, and says the board was never read back', [dry.exit, dry.res.dryRun, dry.res.payload?.actions.map((a) => a.op), dry.text.includes('never the board')], [EXIT_OK, true, ['comment', 'labels_add', 'labels_remove', 'assign'], true]); + + t('the CLI: a lone --comment-file is post-stamped\'s write; --land needs --comment-file; --land never rides a --release', [parseOptions(['--issue', '7', '--comment-file', 'c.md']).ok, parseOptions(['--issue', '7', '--land']).ok, parseOptions([...RELEASE_ARGV, '--land']).ok], [false, false, false]); + t('…while a claim fold, a verdict fold and a release all parse', [parseOptions(claimArgv).ok, parseOptions(verdictArgv).ok, parseOptions(RELEASE_ARGV).ok], [true, true, true]); + const own = readFileSync(SELF_PATH, 'utf8'); + const foldSource = own.slice(own.indexOf('async function foldStroke('), own.indexOf('export async function runRelease(')); + t('structural: the fold judges its comment with post-stamped\'s judgeBody and sends exactly ONE dispatch', [/judgeBody\(text, \{ mode: 'comment' \}/u.test(foldSource), (foldSource.match(/c\.send\(/gu) ?? []).length, /\bdoCall\('(label-add|label-delete|assignee-add|assignee-remove|card-patch)'/u.test(foldSource)], [true, 1, false]); + const duties = readFileSync(new URL('.claude/skills/pm-dispatch/references/execution-duties.md', REPO_ROOT), 'utf8'); + const landing = readFileSync(new URL('.claude/skills/pm-dispatch/references/landing-operations.md', REPO_ROOT), 'utf8'); + t('↔ the procedure text sends a claim and a landing through the fold, so a seat reads ONE stroke where it used to read two', [duties.includes('--comment-file'), landing.includes('--land')], [true, true]); + } + // ── the floor, BEFORE the verdict ──────────────────────────────────────── const declared = Object.keys(SELF_TEST_BATTERIES); const floor = []; @@ -2235,6 +2526,7 @@ export async function main(argv) { transport: result.transport ?? null, relay: result.relay ? { state: result.relay.state, run: result.relay.run ?? null, request_id: result.relay.requestId ?? null } : null, ...(result.mode === 'release' ? { refusal: result.refusal ?? null, remainingMs: result.remainingMs ?? null, comment: result.comment ?? null, resume: result.resume ?? null } : {}), + ...(result.mode === 'fold' ? { refusal: result.refusal ?? null, comment: result.comment ?? null, land: parsed.options.land } : {}), httpCalls: result.httpCalls, }, null, diff --git a/scripts/pm/post-stamped.mjs b/scripts/pm/post-stamped.mjs index c2ab537669d..cf6c221a5ac 100644 --- a/scripts/pm/post-stamped.mjs +++ b/scripts/pm/post-stamped.mjs @@ -3819,6 +3819,37 @@ function rearmThroughProxy(args) { return null; } +/** + * Every refusal a body meets before any route is resolved — the stamp contract, the signature, and per mode the + * claim/record keyed lines and decision facets (`comment`) or the vacant ledger (`body`) — as ONE function, so a + * sender that folds a comment into a wider stroke (`label-write.mjs --comment-file`) judges it on exactly the path + * `main` does, never a copy. `options.mode` is `comment` or `body`; `nowMs` is the clock the stamp substitutes; + * `tool` names the sender in the signature refusal. Answers `{ ok: true, rendered }`, or `{ ok: false, rendered, + * text }` — `text` is what the caller prints before it exits refused with nothing written. Pure. + */ +export function judgeBody(input, options = {}, nowMs = Date.now(), tool = 'post-stamped') { + const rendered = renderBody(input, nowMs); + // The signature (#27113), judged BESIDE the render rather than behind it, on both modes: one exit, and a body that + // breaks the stamp contract AND carries no signature is told both. An empty body is told it is empty, once. + const unsigned = rendered.kind === 'empty' ? null : signatureRefusal(input); + if (!rendered.ok || unsigned !== null) { + return { ok: false, rendered, text: [...(rendered.ok ? [] : [rendered.error]), ...(unsigned === null ? [] : [signatureRefusalText(unsigned, { tool })])].join('\n\n') }; + } + // ⛔ Comments only: a claim IS a comment, and a seat POST's body carries a `Seat:` line of its own that no claim reader judges. + const keyed = options.mode === 'comment' ? claimKeyedLineRefusals(rendered.body) : []; + if (keyed.length > 0) return { ok: false, rendered, text: keyedLineRefusalText(keyed) }; + // The record's line, on the same act and with the same scope: a record IS a comment. + const record = options.mode === 'comment' ? recordKeyedLineRefusals(rendered.body) : []; + if (record.length > 0) return { ok: false, rendered, text: recordLineRefusalText(record) }; + // ⛔ Comments only: a decision analysis is anchored on its card AS A COMMENT, and the handover check reads comments. + const facets = options.mode === 'comment' ? decisionFacetRefusals(rendered.body) : []; + if (facets.length > 0) return { ok: false, rendered, text: decisionFacetRefusalText(facets) }; + // ⛔ Bodies only: a VACANT seat post's ledger holds pointers, never obligations (#23396). + const vacant = options.mode === 'body' ? vacantLedgerRefusals(rendered.body) : []; + if (vacant.length > 0) return { ok: false, rendered, text: vacantLedgerRefusalText(vacant) }; + return { ok: true, rendered }; +} + const USAGE = [ 'post-stamped — write a seat artefact to GitHub with a stamp this act read, never one typed from memory.', '', @@ -3894,41 +3925,14 @@ async function main(argv) { return EXIT_USAGE; } - const rendered = renderBody(input); - // The signature (#27113), judged BESIDE the render rather than behind it, on both modes: one exit, and a body that - // breaks the stamp contract AND carries no signature is told both. An empty body is told it is empty, once. - const unsigned = rendered.kind === 'empty' ? null : signatureRefusal(input); - if (!rendered.ok || unsigned !== null) { - console.error([...(rendered.ok ? [] : [rendered.error]), ...(unsigned === null ? [] : [signatureRefusalText(unsigned, { tool: 'post-stamped' })])].join('\n\n')); - return EXIT_REFUSED; - } - - // ⛔ Comments only: a claim IS a comment, and a seat POST's body carries a `Seat:` line of its own that no claim reader judges. - const keyed = options.mode === 'comment' ? claimKeyedLineRefusals(rendered.body) : []; - if (keyed.length > 0) { - console.error(keyedLineRefusalText(keyed)); - return EXIT_REFUSED; - } - // The record's line, on the same act and with the same scope: a record IS a comment. - const record = options.mode === 'comment' ? recordKeyedLineRefusals(rendered.body) : []; - if (record.length > 0) { - console.error(recordLineRefusalText(record)); - return EXIT_REFUSED; - } - // ⛔ Comments only: a decision analysis is anchored on its card AS A COMMENT, and the handover check reads comments. - // Before the transport, so a dry run is refused exactly as a live write is. - const facets = options.mode === 'comment' ? decisionFacetRefusals(rendered.body) : []; - if (facets.length > 0) { - console.error(decisionFacetRefusalText(facets)); - return EXIT_REFUSED; - } - // ⛔ Bodies only: a VACANT seat post's ledger holds pointers, never obligations (#23396). Before the transport, - // so a dry run is refused exactly as a live write is. - const vacant = options.mode === 'body' ? vacantLedgerRefusals(rendered.body) : []; - if (vacant.length > 0) { - console.error(vacantLedgerRefusalText(vacant)); + // Every body refusal on ONE path (`judgeBody`, which `label-write.mjs --comment-file` calls too), before the + // transport, so a dry run is refused exactly as a live write is. + const judged = judgeBody(input, options); + if (!judged.ok) { + console.error(judged.text); return EXIT_REFUSED; } + const rendered = judged.rendered; // The transport, decided and SAID before anything leaves. A dry run makes no // request, so a route that cannot be resolved is a note there, not a refusal. @@ -4112,6 +4116,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the relay transport: the same act as one op, the comment found on the board, the exit register kept apart': 13, "the size route: over the relay's body cap under auto THIS write goes direct with one line naming bytes, cap and identity; at or under it the relay; explicit dispatch refuses naming the bytes; nothing else re-routes": 13, 'the signature: every body opens with LANE#N · ACCOUNT · SESSION or is refused, beside the stamp contract; one leading signature changes no reading': 11, + 'the one judge: every body refusal on ONE path — `main` and `label-write.mjs --comment-file` call `judgeBody`, never a copy': 8, }); const SELF_TEST_BATTERY_FLOOR = 18; const UNATTRIBUTED_BATTERY = '(unattributed)'; @@ -4493,7 +4498,7 @@ export function selfTest() { // Structural: a predicate nobody is told about is a predicate nobody can act // on, so the verdict reaches `$?`, the dry run, `--json` and the read-back. const stampSource = readFileSync(SELF_PATH, 'utf8'); - t('structural: a refused render — or an unsigned body, beside it — returns EXIT_REFUSED from the CLI, with nothing written', /if \(!rendered\.ok \|\| unsigned !== null\) \{\n\s+console\.error\(\[\.\.\.\(rendered\.ok \? \[\] : \[rendered\.error\]\)[^\n]*\n\s+return EXIT_REFUSED;/u.test(stampSource)); + t('structural: a refused render — or an unsigned body, beside it — returns EXIT_REFUSED from the CLI, with nothing written', /if \(!rendered\.ok \|\| unsigned !== null\) \{\n\s+return \{ ok: false, rendered, text: \[\.\.\.\(rendered\.ok \? \[\] : \[rendered\.error\]\)[^\n]*\n/u.test(stampSource) && /const judged = judgeBody\(input, options\);\n\s+if \(!judged\.ok\) \{\n\s+console\.error\(judged\.text\);\n\s+return EXIT_REFUSED;/u.test(stampSource)); t('structural: the DRY RUN line names the verdict, so a caller reads it BEFORE a write', /Stamp verdict: \$\{rendered\.verdict\}/u.test(stampSource)); t('structural: `--json` carries the verdict and the token count for a script to read', /stamp_verdict: rendered\.verdict,/u.test(stampSource) && /verbatim_tokens: rendered\.verbatimTokens,/u.test(stampSource)); t('structural: the read-back is HANDED the counts its line needs, never left to guess', /verbatimTokens: rendered\.verbatimTokens,/u.test(stampSource)); @@ -5623,10 +5628,34 @@ export function selfTest() { // The slice the size-route battery takes: main, up to the self-test's banner. const ownText = readFileSync(SELF_PATH, 'utf8'); const mainSource = ownText.slice(ownText.indexOf('async function main(argv)'), ownText.indexOf('// --self-test — offline')); - t('structural: the signature is judged on BOTH modes, before the keyed lines and before the route is resolved', /const unsigned = rendered\.kind === 'empty' \? null : signatureRefusal\(input\);/u.test(mainSource) && mainSource.indexOf('signatureRefusal(input)') < mainSource.indexOf('claimKeyedLineRefusals(') && mainSource.indexOf('signatureRefusal(input)') < mainSource.indexOf('await resolveRoute(')); + const judgeSource = ownText.slice(ownText.indexOf('export function judgeBody('), ownText.indexOf('const USAGE = [')); + t('structural: the signature is judged on BOTH modes, before the keyed lines and before the route is resolved', /const unsigned = rendered\.kind === 'empty' \? null : signatureRefusal\(input\);/u.test(judgeSource) && judgeSource.indexOf('signatureRefusal(input)') < judgeSource.indexOf('claimKeyedLineRefusals(') && mainSource.indexOf('judgeBody(input, options)') > 0 && mainSource.indexOf('judgeBody(input, options)') < mainSource.indexOf('await resolveRoute(')); t('the usage says it, with the shape', USAGE.includes('signature') && USAGE.includes(SEAT_SIGNATURE_SHAPE)); } + // ── the one judge: a sender that folds a comment into a wider stroke judges it HERE ── + battery('the one judge: every body refusal on ONE path — `main` and `label-write.mjs --comment-file` call `judgeBody`, never a copy'); + { + const SIG = 'domain:skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg'; + const ok = judgeBody(`${SIG}\nClaim: PM loop round 1 · {{NOW}}\nSeat: \`domain:skills#2\``, { mode: 'comment' }, NOW_MS); + t('⭐ a signed, stamped claim passes and comes back RENDERED — the clock this act read is in it', ok.ok && ok.rendered.substituted === 1 && ok.rendered.body.includes(ok.rendered.stamp) && !ok.rendered.body.includes(STAMP_TOKEN)); + const unsigned = judgeBody('Claim: PM loop round 1 · {{NOW}}', { mode: 'comment' }, NOW_MS, 'label-write'); + t('⭐ an unsigned comment is refused, and the refusal names the SENDER it was handed', !unsigned.ok && unsigned.text.startsWith('label-write: REFUSED') && unsigned.text.includes('opens with no signature line')); + const typed = judgeBody(`${SIG}\nClaim: PM loop round 1 · 2026-09-10T06:37Z`, { mode: 'comment' }, NOW_MS); + t('⭐ a typed stamp on the act\'s own line is refused — the one-clock rule rides the fold unchanged', !typed.ok && typed.text.includes('positional')); + const keyed = judgeBody(`${SIG}\nClaim: PM loop round 1 · {{NOW}}\nClause-②: YES`, { mode: 'comment' }, NOW_MS); + t('a claim whose keyed line its owner cannot read is refused with that owner\'s text', !keyed.ok && keyed.text === keyedLineRefusalText(claimKeyedLineRefusals(keyed.rendered.body))); + const ANALYSIS = [SIG, 'Decision record: who lands the gate {{NOW}}', '', '- **A**: the seat lands it', '- **B**: the maintainer lands it', '', 'This seat recommends **A**.'].join('\n'); + t('a decision analysis with no facet marker is refused as a COMMENT, and passes as a card BODY — the modes keep their scopes', !judgeBody(ANALYSIS, { mode: 'comment' }, NOW_MS).ok && judgeBody(ANALYSIS, { mode: 'body' }, NOW_MS).ok); + t('an empty body is told it is empty, once', !judgeBody(' \n', { mode: 'comment' }, NOW_MS).ok && !judgeBody(' \n', { mode: 'comment' }, NOW_MS).text.includes('signature')); + const own = readFileSync(SELF_PATH, 'utf8'); + const mainOnly = own.slice(own.indexOf('async function main(argv)'), own.indexOf('// --self-test — offline')); + t('structural: main reaches every body refusal through judgeBody alone, ⛔ no inline copy beside it', (mainOnly.match(/judgeBody\(/gu) ?? []).length === 1 && !/signatureRefusal\(|claimKeyedLineRefusals\(|decisionFacetRefusals\(|vacantLedgerRefusals\(/u.test(mainOnly)); + let lw = ''; + try { lw = readFileSync(new URL('./label-write.mjs', import.meta.url), 'utf8'); } catch { lw = ''; } + t('↔ structural: label-write imports judgeBody and judges its folded comment with it, ⛔ no reader of its own', /\bjudgeBody\b[^;]*from '\.\/post-stamped\.mjs'/u.test(lw) && /judgeBody\(text, \{ mode: 'comment' \}/u.test(lw) && !/function\s+(signatureRefusal|claimKeyedLineRefusals|decisionFacetRefusals)\b/u.test(lw)); + } + // The floor, evaluated last: a battery that stops running names itself here. const failed = cases.filter((c) => !c.ok); for (const c of cases) console.log(` ${c.ok ? '✓' : '✗'} ${c.name}`); From 137e74ac405997b9eb57f249c3e44a112e2f6daf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 16:24:57 +0000 Subject: [PATCH 2/2] docs(pm): name the one-stroke claim and the bare-op landing retry dispatch-runbook's claim write order now names the label-write --comment-file fold (comment first, the executor stops at its first failure), and the comment-stored-labels-missing repair re-sends labels only. The landing 429 retry names the bare pr_ready + automerge_enable ops in rest-channel.md, so a retry never posts a second comment. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQze2kJDxaxMsAW2kY --- .claude/skills/pm-dispatch/references/dispatch-runbook.md | 4 ++-- .claude/skills/pm-dispatch/references/landing-operations.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index c22ace2886d..027150d002f 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -33,8 +33,8 @@ - 全量 = 会话、分支、文件面、交付物、串行约束;成员卡各留一行指针指向锚卡。 - 标签翻转照旧逐卡执行:省的是评论正文写量,不是状态写入。 - 验收与解锁扫描的读侧不变,仍逐卡读。 -- 写序:标签翻转只在认领评论写成功之后,两笔不原子。 -- 评论失败而标签已翻即制造一记真半状态;顺序反了就带重试义务,重试未成即回滚标签。 +- 写序:认领一笔 `label-write --comment-file`,评论排首、标签随后;执行器首败即停,标签不先翻。 +- 评论存而标签未翻 ⇒ 非零退出;补标签用不带 `--comment-file` 的 `label-write`,⛔ 不重发评论。 ## 巡查常设项(每席巡查与总监席摘要) diff --git a/.claude/skills/pm-dispatch/references/landing-operations.md b/.claude/skills/pm-dispatch/references/landing-operations.md index 392fcb46361..ce53872df4a 100644 --- a/.claude/skills/pm-dispatch/references/landing-operations.md +++ b/.claude/skills/pm-dispatch/references/landing-operations.md @@ -51,7 +51,7 @@ - 绿即落地评论与 `pr_ready`+`automerge_enable` 一笔中继:`label-write --land`;未绿按阶梯重挂定点。 - 写节流拒(exit 10) ⇒ 待 `node scripts/pm/write-pace.mjs --status` 现空槽再重试。 - CI success webhook 不可靠:⛔ 不坐等,也 ⛔ 不忙轮询;定点文本照定时器写法纪律。 -- undraft 撞 429 ⇒ 落地入等 `resets at` 态:该刻记卡,到点一次重试;⛔ 不报阻塞、不自定节奏。 +- undraft 429 ⇒ 卡记 `resets at`,到点一次重试裸 op(`rest-channel.md`),⛔ 不重发评论、不报阻塞。 - 落地遭分类器拒 ⇒ 停手、报维护者、卡上记命令与拒因;⛔ 不换拼法、不退回 ccr 绕过。 - 落地评论 = 两个读数的探针表 + 一行状态变更(摘标/关卡/收口),⛔ 不复述 `## 席位核对`。 - 转 ready/入队前跑 `check-governed-merges.mjs --pr N`:受管面照两层;>3000 行(含生成物)照 Tier H。