diff --git a/.claude/skills/pm-dispatch/references/seat-post-protocol.md b/.claude/skills/pm-dispatch/references/seat-post-protocol.md index bb0b412a2ff..6ef57bf561c 100644 --- a/.claude/skills/pm-dispatch/references/seat-post-protocol.md +++ b/.claude/skills/pm-dispatch/references/seat-post-protocol.md @@ -25,9 +25,9 @@ - 活性惰性半边在接管冲突时评估一次:Routine 座位查调度器 `last_fired` 与 `next_run`。 - 会话座位查最近一条产出评论的时间戳;回收 = 改正文 + 审计评论。 -- 常设半边是总监席摘要的座位贴活性巡查,同一判据,细则见 `dispatch-runbook.md`。 - 可跑读法:本车道 `pm:dispatched` 卡按 `updated_at` 降序一搜,或读最近一条认领评论。 - 写侧刷新三时刻:派发、收班、暂停或交接前;时间戳恒取自 `scripts/pm/post-stamped.mjs`。 +- 帖与卡正文首行恒为署名 `LANE#N · ACCOUNT · SESSION`;缺即写工具拒发(`post-stamped.mjs` 头注)。 - 刷新前回执敲门;`--body` 拦截:评论晚于末次戳 ⇒ `--ack-through=ID`;空座欠账无卡号 ⇒ 拒写。 - 耐久状态只住正文或以 App 写入;正文守中继上限 60,000 字节,超大贴由半状态巡查浮出。 - 协议升级迁移状态、或巡检发现正文不符时,当场改正文 + 审计评论。 diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 99640972109..200c0e79475 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -1255,6 +1255,135 @@ export const CLAIM_COMMENT_MARKER = /^\s*>?\s*Claim(?:ed)?\s*:/mi; */ export const RELEASE_COMMENT_MARKER = /^\s*>?\s*Release\s*:/mi; +// --------------------------------------------------------------------------- +// The seat SIGNATURE line — who wrote a seat-authored body, on its first line (#27113). +// +// Every seat writes through the fleet relay, so every post lands under one bot +// login and the platform's author field cannot say which lane, seat or account +// wrote it. The maintainer's ruling (「让发帖脚本 post-stamped 强制每条帖子第一行带署名: +// 车道#席位号 · 账户 · 会话,缺署名就拒发」) puts all of it on the FIRST non-empty line: +// +// LANE#N · ACCOUNT · SESSION e.g. `domain:skills#2 · os-justin · session_01…` +// +// LANE a seat as the role table spells it: `domain:` or `repo:` plus a lowercase +// name (`domain:engine-core` carries a hyphen, measured on the label set), +// `epic:#` plus the parent card, `triage`, `director` or `coordinator` +// N the seat number, from 1 +// ACCOUNT the login a claim's `Account:` line carries — GitHub's own login rule +// SESSION the full id, in the relay envelope's shape (`SESSION_SHAPE`, fleet-write/ops.mjs, +// mirrored here because this module may not import that one; the record-recognisers +// self-test holds the two sources equal) — a container session is `session_` plus +// the `cse_` tail, so the 26-character desktop form is not the only real one +// +// The separator is exactly ` · ` — one space, U+00B7, one space — the doorbell keys' dot. +// +// TWO halves read this ONE spelling, and neither restates it: +// WRITE the seat-authored writers REFUSE a body whose first non-empty line is not one: +// `post-stamped.mjs` (both modes), `issue-create.mjs`, and the `comment_edit` / +// `issue_patch` bodies a seat hands `fleet-write/dispatch.mjs`. ⛔ No writer inserts +// one for the seat: a body without a signature is never posted. +// READ every recogniser that keys on a body's FIRST line reads it through +// `stripSignatureLine`, so one leading signature line changes no reading. A marker +// that already matches at any line start (the `m` flag) needs nothing. +// +// ⛔ Exactly ONE line is skipped, and only an exact match. A near miss stays the first line: +// the write half refuses it by name, and the read half does not quietly read past it. +// --------------------------------------------------------------------------- + +/** The four parts' patterns, unanchored, in order — the ONE spelling every reader and writer composes from. */ +const SIGNATURE_LANE_SOURCE = '(?:domain:[a-z][a-z0-9-]*|repo:[a-z][a-z0-9-]*|epic:#[1-9][0-9]*|triage|director|coordinator)'; +const SIGNATURE_ACCOUNT_SOURCE = '[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}'; +/** The relay envelope's session shape without its anchors — `SESSION_SHAPE` in `fleet-write/ops.mjs`. */ +export const SEAT_SIGNATURE_SESSION_SOURCE = 'session_[A-Za-z0-9]{6,}'; +/** The separator, exactly. */ +export const SEAT_SIGNATURE_SEPARATOR = ' · '; + +/** The signature line's pattern, unanchored and capture-free — what a marker composes when it admits one leading signature. */ +export const SEAT_SIGNATURE_SOURCE = + `${SIGNATURE_LANE_SOURCE}#[1-9][0-9]*${SEAT_SIGNATURE_SEPARATOR}${SIGNATURE_ACCOUNT_SOURCE}${SEAT_SIGNATURE_SEPARATOR}${SEAT_SIGNATURE_SESSION_SOURCE}`; + +/** ONE line, whitespace around it trimmed, is a signature — with the four parts captured. ⛔ No `g` or `m` flag. */ +export const SEAT_SIGNATURE_LINE = new RegExp( + `^(${SIGNATURE_LANE_SOURCE})#([1-9][0-9]*)${SEAT_SIGNATURE_SEPARATOR}(${SIGNATURE_ACCOUNT_SOURCE})${SEAT_SIGNATURE_SEPARATOR}(${SEAT_SIGNATURE_SESSION_SOURCE})$`, + 'u', +); + +/** The shape, as a refusal prints it — placeholders only, so it survives a GitHub body unchanged. */ +export const SEAT_SIGNATURE_SHAPE = 'LANE#N · ACCOUNT · SESSION — e.g. `domain:skills#2 · os-justin · session_01…`'; + +/** The index of a line list's first non-blank line, or -1. */ +function firstNonBlankIndex(lines) { + return lines.findIndex((line) => line.trim() !== ''); +} + +/** + * The signature a body opens with, read off its FIRST non-empty line, or null when that line is + * not one (an empty body included). Pure. + * + * @param {string} body + * @returns {{ lane: string, seat: number, account: string, session: string, line: string } | null} + */ +export function readSignatureLine(body) { + const lines = String(body ?? '').split(/\r?\n/); + const at = firstNonBlankIndex(lines); + if (at < 0) return null; + const line = lines[at].trim(); + const m = SEAT_SIGNATURE_LINE.exec(line); + return m ? { lane: m[1], seat: Number(m[2]), account: m[3], session: m[4], line } : null; +} + +/** + * The body a first-line recogniser reads: unchanged when its first non-empty line is not a + * signature, else everything from the next non-empty line on — ONE signature line and the blank + * lines around it removed, so "the first line" is the same line it was before the rule. Pure. + */ +export function stripSignatureLine(body) { + const text = String(body ?? ''); + if (readSignatureLine(text) === null) return text; + const lines = text.split(/\r?\n/); + const rest = lines.slice(firstNonBlankIndex(lines) + 1); + const from = firstNonBlankIndex(rest); + return from < 0 ? '' : rest.slice(from).join('\n'); +} + +/** + * Why a body about to be written by a seat-authored writer carries no signature, or null when its + * first non-empty line is one. The row every writer prints, so the refusal reads the same from + * each door. Pure. + * + * @returns {{ line: string, why: string } | null} + */ +export function signatureRefusal(body) { + if (readSignatureLine(body) !== null) return null; + const lines = String(body ?? '').split(/\r?\n/); + const at = firstNonBlankIndex(lines); + const line = at < 0 ? '(the body is empty)' : lines[at].trim(); + // A line naming a seat number AND a session id is a signature typed wrong — named as such, so the fix is the spelling. + const near = at >= 0 && /#[1-9]/u.test(line) && /\bsession_/u.test(line); + return { + line: line.length > 160 ? `${line.slice(0, 160)}…` : line, + why: near + ? 'the first non-empty line reads like a signature and is not one — check the lane, the `#N` seat number, the login, the full session id, and that each separator is exactly ` · `' + : 'the first non-empty line is not a signature — the reader cannot tell which lane, seat, account or session wrote this', + }; +} + +/** + * The refusal a seat-authored writer prints, from `signatureRefusal`'s row — ONE text, so the + * signature reads the same from every door. `tool` names the door; `where` names the body when + * the door carries more than one (an action of a stroke). + */ +export function signatureRefusalText(row, { tool, where = 'the body' } = {}) { + return ( + `${tool}: REFUSED — ${where} opens with no signature line. Nothing was written.\n` + + ` 1. [signature] ${row.why}\n` + + ` line: ${row.line}\n` + + ` write: ${SEAT_SIGNATURE_SHAPE} — as the FIRST non-empty line, alone\n\n` + + ' Every reply lands under one bot login, so this line is the only place a reader learns which lane, seat,\n' + + ' account and session wrote it. ⛔ No flag turns it off, and no writer inserts it for you.' + ); +} + // --------------------------------------------------------------------------- // Reading the two ownership markers — ONE place, decoration included (#18680). // @@ -5584,7 +5713,8 @@ export function yieldedClaimIndexes(commentRows) { a.stamp !== null && b.stamp !== null && a.stamp !== b.stamp ? a.stamp < b.stamp : a.index < b.index; const yields = []; for (const row of rows) { - const first = row.body.split(/\r?\n/).find((line) => line.trim() !== '') ?? ''; + // One leading signature line is not the yield's line (`stripSignatureLine`, #27113). + const first = stripSignatureLine(row.body).split(/\r?\n/).find((line) => line.trim() !== '') ?? ''; if (!markerMatches(CLAIM_YIELD_MARKER, first)) continue; const sessions = new Set(first.match(new RegExp(SEAT_SESSION_ID.source, 'g')) ?? []); if (sessions.size !== 1) continue; @@ -7979,7 +8109,8 @@ export const TRIAGE_RULING_ANCHORS = [ * H4 paid for (#10102) applies verbatim. */ export function isTriageRulingComment(body) { - const first = String(body ?? '') + // One leading signature line is not the ruling's opening (`stripSignatureLine`, #27113). + const first = stripSignatureLine(body) .split('\n') .map((l) => l.trim()) .find((l) => l.length > 0); @@ -12014,7 +12145,14 @@ export function h50ThreadReadMismatch(issue, commentRows, sinceMs = Date.parse(T * ⛔ No `g` flag — a shared regex carrying `lastIndex` between callers is a * state bug waiting for its second reader. */ -export const OS_DEV_REPORT_MARKER = /^[\s>]*(?:)?[ \t]*(?:\r?\n|$)/i; +// ⭐ One leading signature line (`SEAT_SIGNATURE_SOURCE`, #27113) may stand above the marker: composed from the one +// spelling, never restated, and still anchored at the body's start — a marker on any OTHER later line stays refused. +// The `i` the dialect needs folds case on that signature too: a read-side allowance only, since every writer refuses +// a signature spelt in any other case before it is posted. +export const OS_DEV_REPORT_MARKER = new RegExp( + `^(?:\\s*${SEAT_SIGNATURE_SOURCE}[ \\t]*\\r?\\n)?[\\s>]*(?:)?[ \\t]*(?:\\r?\\n|$)`, + 'i', +); /** How many question texts the finding sentence echoes before it degrades to a count. */ export const H52_QUESTION_ECHO_CAP = 2; @@ -12886,7 +13024,8 @@ export function stampDriftMinutes(stamp, createdAt, updatedAt = createdAt) { * than one stamp contributes nothing — see `h56AmbiguousLines`. */ export function h56StampedReadings(text) { - const lines = String(text ?? '').split('\n'); + // The OPENING line is the act's own line, so one leading signature line above it is skipped (#27113). + const lines = stripSignatureLine(text).split('\n'); const opening = lines.findIndex((line) => line.trim().length > 0); const out = []; for (let i = 0; i < lines.length; i++) { @@ -12902,7 +13041,7 @@ export function h56StampedReadings(text) { /** How many of those positions were HELD OUT for carrying more than one stamp. */ export function h56AmbiguousLines(text) { - const lines = String(text ?? '').split('\n'); + const lines = stripSignatureLine(text).split('\n'); const opening = lines.findIndex((line) => line.trim().length > 0); let held = 0; for (let i = 0; i < lines.length; i++) { @@ -15788,7 +15927,8 @@ export const H65_ROUND_ARTEFACT_SHAPES = Object.freeze([ * down (a report ABOUT a round, this card's own body) is not a round artefact. */ export function h65Headline(body) { - for (const line of h44StripFences(body).split('\n')) { + // One leading signature line is not the headline (`stripSignatureLine`, #27113). + for (const line of h44StripFences(stripSignatureLine(body)).split('\n')) { if (line.trim()) return line; } return ''; diff --git a/scripts/pm/check-prior-rulings.mjs b/scripts/pm/check-prior-rulings.mjs index eee02bcf772..dd3d165e4fc 100644 --- a/scripts/pm/check-prior-rulings.mjs +++ b/scripts/pm/check-prior-rulings.mjs @@ -215,7 +215,7 @@ import { isEntrypoint } from '../invoked-as.mjs'; // `check-half-states.mjs`, so this reader and the patrol cannot come to // disagree about which board is being read or whether this container's fetch // reaches it. -import { DEFAULT_SWEEP_REPO, resolveSweepRepo, proxyRearmPlan, PROXY_FLAG, PROXY_REARM_GUARD } from './check-half-states.mjs'; +import { DEFAULT_SWEEP_REPO, resolveSweepRepo, proxyRearmPlan, PROXY_FLAG, PROXY_REARM_GUARD, stripSignatureLine } from './check-half-states.mjs'; export const EXIT_OK = 0; export const EXIT_SELF_TEST_FAILED = 1; @@ -333,7 +333,8 @@ export const RULING_COMMENT_SHAPES = Object.freeze([ * ruling mid-body is about a ruling, not one. */ export function firstContentLine(body) { - const line = String(body ?? '').split('\n').find((l) => l.trim() !== ''); + // One leading signature line is not the ruling's line (`stripSignatureLine`, #27113). + const line = stripSignatureLine(body).split('\n').find((l) => l.trim() !== ''); if (line === undefined) return ''; return line.replace(/^[\s>]+/, '').replace(/^#{1,6}\s+/, '').replace(/^[*_`\s]+/, '').trim(); } diff --git a/scripts/pm/fleet-write/dispatch.mjs b/scripts/pm/fleet-write/dispatch.mjs index 815c5bf8958..f449739bc66 100644 --- a/scripts/pm/fleet-write/dispatch.mjs +++ b/scripts/pm/fleet-write/dispatch.mjs @@ -355,11 +355,31 @@ * `issue-create.mjs`. ⛔ No second read path beyond that fallback, and ⛔ not * a gate: an annotation only ever replaces a search for a number. * + * ## A seat's EDIT of its own artefact opens with its signature (#27113) + * + * The relay writes as one bot login, so the text is the only place a reader learns who wrote it. The + * seat-authored writers refuse a body whose first non-empty line is not the seat's signature, + * `LANE#N · ACCOUNT · SESSION` (`readSignatureLine`, `check-half-states.mjs`, whose banner carries the shape and + * the ruling). This door owns the half no other writer covers: a `comment_edit` or an `issue_patch` carrying a + * `body` in an actions file a seat hands it (`unsignedEditBodies`) — refused as usage (2) before anything is + * packed or sent, a dry run included. Three things it deliberately leaves alone: + * + * - a body a SCRIPT generates and rewrites whole, recognised by the marker line its generator writes first + * (`GENERATED_BODY_MARKERS`: the fleet roster's) — the ruling keeps generated cards out of scope; + * - `comment` and `issue_create`, whose seat doors (`post-stamped.mjs`, `issue-create.mjs`) refuse first, and an + * `issue_patch` that carries no body; + * - `pr_create`: a pull request body's first line is `Fixes #n`, a closing keyword the platform reads there, and + * the ruling is about posts, not pull request bodies. + * + * The library path (`packRequest`) is not judged here: its callers are the tools above, each of which judges + * its own body first. + * * ## Exit codes — capture them BEFORE any pipe * * 0 the run completed with conclusion `success`, every body it wrote * reads back as the bytes sent, and every run it started is listed. - * 2 usage, or the packed payload was refused by the validator (nothing sent). + * 2 usage, the packed payload refused by the validator, or a seat's edit body + * carrying no signature line (nothing sent). * 3 PREREQUISITE NOT MET — no token, no session in dispatch mode, the * dispatch route dead (401/407/0), or the run list unreadable. * 4 NOT STORED — the run succeeded and the read-back shows a body the board @@ -380,7 +400,9 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../../invoked-as.mjs'; -import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, proxyRoute } from '../check-half-states.mjs'; +import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, proxyRoute, signatureRefusal, signatureRefusalText } from '../check-half-states.mjs'; +// The roster's generator marker, IMPORTED so the one generated body this door exempts is named by its own writer. +import { ROSTER_MARKER } from '../fleet-roster.mjs'; import { classifyHttp } from '../label-write.mjs'; import { DEFAULT_BATCH_GAP_MS, DEFAULT_BATCH_THRESHOLD, EXIT_WRITE_PACE_REFUSED, fmtDuration, isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from '../write-pace.mjs'; import { CONTAINER_SESSION_ENV, CONTAINER_SESSION_SHAPE, FIELDS, MAX_REQUEST_ID_CHARS, OPS, OP_NAMES, RELAY_EVENT_TYPE, RELAY_REPO, SESSION_ENV, SESSION_SHAPE, TRANSPORTS, TRANSPORT_ENV } from './ops.mjs'; @@ -621,6 +643,30 @@ export function newRequestId(nowMs = Date.now(), random = () => randomBytes(3).t * (`strokePacing`) is refused here, before anything leaves, with the per-op doors named (the header's batch-gap * section). `pacing` rides along on both answers, for the caller that prints it. */ +/** The ops whose `body` is a seat's EDIT of a seat-authored artefact — the signature's half at this door (header). */ +export const SIGNED_EDIT_OPS = Object.freeze(['comment_edit', 'issue_patch']); + +/** The opening marker lines of the bodies a SCRIPT generates and rewrites whole — out of the signature's scope (header). */ +export const GENERATED_BODY_MARKERS = Object.freeze([ROSTER_MARKER]); + +/** + * The actions in a seat's stroke whose edit body opens with no signature line, each with `signatureRefusal`'s row — + * empty when every `comment_edit` / `issue_patch` body is signed, generated, or absent. Pure. + * + * @returns {{ action: number, op: string, line: string, why: string }[]} — `action` is 1-based, as the read-back rows count. + */ +export function unsignedEditBodies(actions) { + if (!Array.isArray(actions)) return []; + const rows = []; + actions.forEach((a, i) => { + if (a === null || typeof a !== 'object' || !SIGNED_EDIT_OPS.includes(a.op) || typeof a.body !== 'string') return; + if (GENERATED_BODY_MARKERS.some((marker) => a.body.trimStart().startsWith(marker))) return; + const row = signatureRefusal(a.body); + if (row !== null) rows.push({ action: i + 1, op: a.op, ...row }); + }); + return rows; +} + export function packRequest({ repo, session, actions, requestId = null, now = Date.now } = {}) { const payload = { request_id: requestId ?? newRequestId(now()), repo, session, actions }; const judged = validatePayload(payload); @@ -1461,10 +1507,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the started run: an action naming a workflow is read back as the newest workflow_dispatch run of that file created since the dispatch — the target repo's run list, read after the relay run completed and re-read within a bounded window; found is success naming id, url and actor; none in the window or an unreadable list is UNCONFIRMED (6) after ONE dispatch; a stroke naming no workflow reads none": 11, "the release.yml refresh at the seat: the stroke packs with inputs refresh_version_pr true and travels in ONE dispatch; its started run is read back from release.yml's own run list; force, false, absent or any other input is refused before anything is sent, exit 2": 6, "the batch gap at the seat: content creation read off the op table; a stroke at or above write-pace's threshold is batched through its last content-creating write and waited for exactly that much longer; a stroke whose pacing the relay job cannot hold is refused before anything is sent, naming the per-op doors; the job's timeout mirrors the workflow's": 13, + "the signature at the seat's door: a comment_edit or issue_patch body opens with the seat's signature line or the file is usage (2), nothing packed; generated, body-less and the other doors' ops are left alone": 13, "the run_rerun_failed stroke at the seat: it packs as ONE action carrying run_id alone and travels in ONE dispatch; beside any other action, or with a key that widens it, it is refused before anything is sent; it creates no content, carries no body and names no workflow, so nothing is read back here — a success run is the executor's confirmation; a run that failed at the relay's fence is exit 5 after ONE dispatch, never re-sent": 6, }); -// Raised 21 → 22 with the run_rerun_failed seat battery: one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 22; +// Raised 21 → 22 with the run_rerun_failed seat battery, 22 → 23 with the signature battery: one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 23; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -2092,7 +2139,10 @@ export async function selfTest() { t('a stroke carrying no body reads nothing back: success, read-back none, no request beyond the dispatch and the run', [labels.state, labels.readBack?.state, labels.seen.filter((s) => !s.call.includes('/actions/runs') && s.call !== DISPATCH).length], ['success', 'none', 0]); t("the callers' one vocabulary: failure + notStored → 4, failure → 5, unverified → 6", [exitForResult({ state: 'failure', notStored: true }), exitForResult({ state: 'failure' }), exitForResult({ state: 'unverified' })], [EXIT_NOT_STORED, EXIT_PLATFORM_REFUSAL, EXIT_UNCONFIRMED]); // The CLI, end to end: the exit code a seat actually reads, from the actions file the card's seat sent. - writeFileSync(join(dir, 'patch.json'), JSON.stringify([{ op: 'issue_patch', issue: 11041, body: B }]), 'utf8'); + // The seat's edit body opens with its signature (#27113), so the corrupted byte sits exactly that line's bytes further down. + const SIGNED = `domain:skills#2 · os-justin · ${SESSION}\n${B}`; + const SIGNED_AT = AT + Buffer.byteLength(SIGNED) - Buffer.byteLength(B); + writeFileSync(join(dir, 'patch.json'), JSON.stringify([{ op: 'issue_patch', issue: 11041, body: SIGNED }]), 'utf8'); const cli = async (stored) => { let nowMs = T0; const clock = { now: () => nowMs, elapsed: () => nowMs - T0 }; @@ -2110,9 +2160,9 @@ export async function selfTest() { [console.log, console.error] = [log, err]; } }; - const cliBad = await cli(SPLIT); - t("⭐ the CLI: the card's stroke with a deliberately corrupted read-back EXITS 4, and --json says not_stored with the first differing byte", [cliBad.code, cliBad.json?.not_stored, cliBad.json?.read_back?.[0]?.first_difference_byte], [EXIT_NOT_STORED, true, AT]); - const cliGood = await cli(`${B.replace(/\n+$/u, '')}${PLATFORM_COMMENT_FOOTER}`); + const cliBad = await cli(SIGNED.replace('全', '\uFFFD\uFFFD')); + t("⭐ the CLI: the card's stroke with a deliberately corrupted read-back EXITS 4, and --json says not_stored with the first differing byte", [cliBad.code, cliBad.json?.not_stored, cliBad.json?.read_back?.[0]?.first_difference_byte], [EXIT_NOT_STORED, true, SIGNED_AT]); + const cliGood = await cli(`${SIGNED.replace(/\n+$/u, '')}${PLATFORM_COMMENT_FOOTER}`); t('…and the same stroke read back intact exits 0, its row landed', [cliGood.code, cliGood.json?.read_back?.[0]?.verdict], [EXIT_OK, 'landed']); } @@ -2439,6 +2489,39 @@ export async function selfTest() { t(`⭐ --route behind a proxy that answers nothing: the process re-execs with ${PROXY_FLAG}, the read gets no answer, and the verdict is exit 3 with the error naming it — never direct`, [routeDeadProxy.status, deadOut?.transport ?? null, (deadOut?.error ?? '').includes('no answer'), routeDeadProxy.stderr.includes(PROXY_FLAG)], [EXIT_PREREQUISITE, null, true, true], routeDeadProxy.stderr.slice(-300)); } + // ── the signature at the seat's door ──────────────────────────────────── + battery("the signature at the seat's door: a comment_edit or issue_patch body opens with the seat's signature line or the file is usage (2), nothing packed; generated, body-less and the other doors' ops are left alone"); + { + const SIG = `domain:skills#2 · os-justin · ${SESSION}`; + const ROWS = (actions) => unsignedEditBodies(actions).map((r) => `${r.action}:${r.op}`); + t('an unsigned comment_edit body and an unsigned issue_patch body are both named, by 1-based action and op', ROWS([{ op: 'comment_edit', comment_id: 7, body: 'Edited.' }, { op: 'issue_patch', issue: 3, body: 'Seat post\n' }]), ['1:comment_edit', '2:issue_patch']); + t('…each row carries the line it read and why', unsignedEditBodies([{ op: 'comment_edit', comment_id: 7, body: '\n\nEdited.' }])[0]?.line, 'Edited.'); + t('signed bodies pass, blank lines above the signature included', ROWS([{ op: 'comment_edit', comment_id: 7, body: `${SIG}\nEdited.` }, { op: 'issue_patch', issue: 3, body: `\n${SIG}\n\nSeat post` }]), []); + t('⛔ a signature on a LATER line is not one: the first non-empty line decides', ROWS([{ op: 'issue_patch', issue: 3, body: `Seat post\n${SIG}` }]), ['1:issue_patch']); + t('an issue_patch that carries no body (a close) is not judged', ROWS([{ op: 'issue_patch', issue: 3, state: 'closed', state_reason: 'completed' }]), []); + t("the other doors' ops are not judged here — comment and issue_create refuse at post-stamped and issue-create, pr_create's first line is `Fixes #n`", ROWS([{ op: 'comment', issue: 1, body: 'x' }, { op: 'issue_create', title: 't', body: 'x' }, { op: 'pr_create', title: 't', head: 'h', base: 'main', body: 'Fixes #1' }]), []); + t('the roster body is generated, and exempt by the marker its generator writes — imported, never restated', [GENERATED_BODY_MARKERS.includes(ROSTER_MARKER), ROWS([{ op: 'issue_patch', issue: 9, body: `${ROSTER_MARKER}\n\n| seat | holder |` }])], [true, []]); + t('…and a body that only MENTIONS the marker below its first line is not generated', ROWS([{ op: 'issue_patch', issue: 9, body: `Notes\n${ROSTER_MARKER}` }]), ['1:issue_patch']); + t('the judged ops are exactly the two edit ops, and both are in the table with a body', [[...SIGNED_EDIT_OPS], SIGNED_EDIT_OPS.every((op) => OPS[op] && [...OPS[op].required, ...OPS[op].optional].includes('body'))], [['comment_edit', 'issue_patch'], true]); + writeFileSync(join(dir, 'sig-unsigned.json'), JSON.stringify([{ op: 'labels_add', issue: 3, labels: ['a'] }, { op: 'issue_patch', issue: 3, body: 'Seat post\n' }]), 'utf8'); + writeFileSync(join(dir, 'sig-signed.json'), JSON.stringify([{ op: 'issue_patch', issue: 3, body: `${SIG}\nSeat post\n` }]), 'utf8'); + const env = (extra) => ({ ...process.env, GITHUB_TOKEN: TOKEN, GH_TOKEN: '', HTTPS_PROXY: '', https_proxy: '', OS_PM_WRITE_PACE_FILE: join(dir, 'sig-pace.jsonl'), [SESSION_ENV]: SESSION, [CONTAINER_SESSION_ENV]: '', ...extra }); + const spawn = (args, extra = {}) => spawnSync(process.execPath, [SELF_PATH, ...args], { encoding: 'utf8', env: env(extra) }); + const refused = spawn(['--repo', 'objectstack-ai/objectstack', '--actions-file', join(dir, 'sig-unsigned.json'), '--dry-run']); + t('⭐ the CLI refuses the unsigned file on a DRY RUN: usage (2), the refusal names action 2 and the shape, and no payload is printed', [refused.status, refused.stderr.includes("action 2 (issue_patch)'s body opens with no signature line") && refused.stderr.includes('LANE#N · ACCOUNT · SESSION'), refused.stdout.includes('"event_type"')], [EXIT_USAGE, true, false], refused.stderr.slice(-300)); + const noSession = spawn(['--repo', 'objectstack-ai/objectstack', '--actions-file', join(dir, 'sig-unsigned.json'), '--dry-run'], { [SESSION_ENV]: '' }); + t('…judged on the file as handed, before the session: the same refusal with no session in the environment', [noSession.status, noSession.stderr.includes('no signature line')], [EXIT_USAGE, true]); + const signed = spawn(['--repo', 'objectstack-ai/objectstack', '--actions-file', join(dir, 'sig-signed.json'), '--dry-run']); + t('the signed file packs as before: exit 0, the payload printed, the body sent exactly as written', [signed.status, signed.status === EXIT_OK ? JSON.parse(signed.stdout).client_payload.actions[0].body.split('\n')[0] : null], [EXIT_OK, SIG], signed.stderr.slice(-300)); + t('structural: the check runs in main right after the file is parsed, before the session and the packer', (() => { + const src = readReal(SELF_PATH, 'utf8'); + // lastIndexOf: this very case spells the needle above `main`, so the first hit is the test, not the function. + const body = src.slice(src.lastIndexOf('export async function main(')); + const at = body.indexOf('unsignedEditBodies(actions)'); + return at > body.indexOf('JSON.parse(readFileSync(opts.actionsFile') && at < body.indexOf('const session = opts.session') && at < body.indexOf('packRequest('); + })()); + } + // ── the no-run conformance ────────────────────────────────────────────── battery('the no-run conformance: every sender — discovered from the source — answers an accepted dispatch with no run, or none completed, UNCONFIRMED (6) after ONE dispatch with ZERO direct writes, under auto and explicit dispatch alike; under direct its write is seen'); { @@ -2648,6 +2731,8 @@ const USAGE = [ ` ${CONTAINER_SESSION_ENV} (cse_ → session_); ${SESSION_ENV} overrides the container (a local checkout, a test).`, ` ⛔ Never as a prefix on the command line: the seats' allow rules are literal command prefixes. The dispatch always`, ` goes to ${RELAY_REPO}; --repo names the target.`, + ' A comment_edit or issue_patch body opens with the seat\'s signature line, LANE#N · ACCOUNT · SESSION, or the file is', + ' refused (2) before anything is packed; the fleet roster\'s generated body is the one exemption.', ` Exits: ${EXIT_OK} run succeeded, every body reads back as sent and every started run is listed · ${EXIT_USAGE} usage / payload refused · ${EXIT_PREREQUISITE} prerequisite ·`, ` ${EXIT_NOT_STORED} NOT STORED (the run succeeded, a body reads back otherwise — go READ, never retry) · ${EXIT_PLATFORM_REFUSAL} dispatch refused or run failed ·`, ` ${EXIT_UNCONFIRMED} UNCONFIRMED (no run, no completion within the ceiling, a body not read back, or a started run not listed) · ${EXIT_WRITE_PACE_REFUSED} throttle refused`, @@ -2710,6 +2795,14 @@ export async function main(argv, deps = {}) { console.error(`fleet-write/dispatch: --actions-file ${opts.actionsFile} cannot be read as JSON (${e?.message ?? 'unreadable'}). Nothing was sent.`); return EXIT_USAGE; } + // A seat's edit body opens with its signature (#27113) — judged on the file as handed, before the session, the + // packer or a dry run, so the refusal does not depend on the environment. + const unsigned = unsignedEditBodies(actions); + if (unsigned.length > 0) { + for (const row of unsigned) console.error(signatureRefusalText(row, { tool: 'fleet-write/dispatch', where: `action ${row.action} (${row.op})'s body` })); + console.error('fleet-write/dispatch: nothing was sent.'); + return EXIT_USAGE; + } const session = opts.session ?? sessionFrom(env); if (!session || !SESSION_SHAPE.test(session)) { console.error( diff --git a/scripts/pm/issue-create.mjs b/scripts/pm/issue-create.mjs index 7468c270364..2b7815ce22d 100644 --- a/scripts/pm/issue-create.mjs +++ b/scripts/pm/issue-create.mjs @@ -34,7 +34,13 @@ * - a repo not shaped `owner/name` (the default is the board the sweep * tooling resolves, so a card never lands on a repo nobody named); * - a body carrying `{{NOW}}` / `{{WAS:…}}` — those are `post-stamped.mjs`'s - * contract for comments and body EDITS; a new card is dated by the platform. + * contract for comments and body EDITS; a new card is dated by the platform; + * - a body whose first non-empty line is not the seat's signature, + * `LANE#N · ACCOUNT · SESSION` (#27113) — read by `signatureRefusal` + * (`check-half-states.mjs`, whose banner carries the shape and the ruling), + * listed beside the token refusal when both apply. Every card this door + * creates is seat-authored: the workflow-generated cards are written by + * their workflows, never through here. * * ## The transport — `OS_FLEET_TRANSPORT` direct | dispatch | auto * @@ -87,7 +93,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; -import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, resolveSweepRepo } from './check-half-states.mjs'; +import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, SEAT_SIGNATURE_SHAPE, proxyRearmPlan, resolveSweepRepo, signatureRefusal } from './check-half-states.mjs'; import { EXIT_UNCONFIRMED, exitForResult, matchRunAnnotations, packRequest, parseRelayAnnotation, relayAnnotationMessage, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; import { refusalText as relayRefusalText } from './fleet-write/validate.mjs'; import { classifyHttp } from './label-write.mjs'; @@ -200,7 +206,12 @@ export function planFrom(opts, { read = (p) => readFileSync(p, 'utf8'), env = pr if (body !== null) { body = String(body).replace(/\r\n/g, '\n').trim(); if (!body) errors.push('the body is empty — a card names its evidence'); - else if (STAMP_TOKEN.test(body)) errors.push('the body carries a {{NOW}} / {{WAS:…}} token — those are post-stamped.mjs\'s contract for comments; a new card is dated by the platform'); + else { + if (STAMP_TOKEN.test(body)) errors.push('the body carries a {{NOW}} / {{WAS:…}} token — those are post-stamped.mjs\'s contract for comments; a new card is dated by the platform'); + // The signature (#27113), judged beside the token rule so a body breaking both hears both. + const unsigned = signatureRefusal(body); + if (unsigned !== null) errors.push(`the body carries no signature line — ${unsigned.why}; its first line reads \`${unsigned.line}\`. Write ${SEAT_SIGNATURE_SHAPE} as the FIRST line; nothing inserts it for you`); + } } const labels = dedupe(opts.labels); @@ -421,13 +432,14 @@ export async function relayMissProbe({ route, send, pace }) { const SELF_TEST_BATTERIES = Object.freeze({ 'the arguments: files not argv for prose, exactly one title source, a repo shape': 10, 'the payload: title trimmed to one line, labels and assignees de-duplicated, nothing invented': 5, + "the signature: a card body opens with the seat's signature line, or the plan is refused before any request": 8, 'the transport: a fake platform, and what each answer does to the exit code': 9, 'dry-run: no request leaves, and the plan is printed': 3, 'the wiring: both halves around the one POST, on the write verb only': 4, 'the relay transport: ONE dispatch carrying the create, the card found by title since the dispatch, and an accepted dispatch with no run UNCONFIRMED under auto too — never the direct POST': 9, "the relay annotation: the number the relay run's annotation carries is read first — the card read back at it with NO list; absent, the title finds it as before; the read-back still judges the annotated card": 5, }); -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 8; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -450,7 +462,18 @@ export async function selfTest() { const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); - const files = { 'title.txt': ' A card with `backticks` and $HOME in it \n', 'body.md': 'Evidence: a repro.\n\n- step one\n', 'empty.md': ' \n', 'stamped.md': 'Reading time {{NOW}}\n' }; + // Every body a seat hands this door opens with the seat's signature (#27113), so the fixtures that must plan do too. + const SIG = 'domain:skills#2 · os-justin · session_01ABCDEFGHJKMNPQRSTVWXYZ'; + const files = { + 'title.txt': ' A card with `backticks` and $HOME in it \n', + 'body.md': `${SIG}\nEvidence: a repro.\n\n- step one\n`, + 'empty.md': ' \n', + 'stamped.md': `${SIG}\nReading time {{NOW}}\n`, + 'unsigned.md': 'Evidence: a repro.\n\n- step one\n', + 'unsigned-stamped.md': 'Reading time {{NOW}}\n', + 'near-miss.md': 'domain:skills#2 - os-justin - session_01ABCDEFGHJKMNPQRSTVWXYZ\nEvidence.\n', + 'second-line.md': 'Evidence: a repro.\n\ndomain:skills#2 · os-justin · session_01ABCDEFGHJKMNPQRSTVWXYZ\n', + }; const read = (p) => { if (!(p in files)) throw new Error('ENOENT'); return files[p]; @@ -486,6 +509,32 @@ export async function selfTest() { t('a body carrying a stamp token is refused — that is post-stamped\'s contract', planFrom(parseArgs(['--title', 'x', '--body-file', 'stamped.md']), { read, env }).errors.some((e) => e.includes('{{NOW}}'))); } + // ── the signature ───────────────────────────────────────────────────────── + battery('the signature: a card body opens with the seat\'s signature line, or the plan is refused before any request'); + { + const plan = (file) => planFrom(parseArgs(['--title', 'x', '--body-file', file]), { read, env }); + const unsigned = plan('unsigned.md'); + t('an unsigned body is refused — the plan is not ok', unsigned.ok, false); + t('…and the refusal names the shape to write and the line it read', unsigned.errors.some((e) => e.includes('no signature line') && e.includes('LANE#N · ACCOUNT · SESSION') && e.includes('`Evidence: a repro.`'))); + t('a signed body plans, and the signature is SENT as written — nothing stripped, nothing inserted', [plan('body.md').ok, plan('body.md').payload.body.split('\n')[0]], [true, SIG]); + t('a near miss (hyphens for the ` · ` separator) is refused, and named as one', plan('near-miss.md').errors.some((e) => e.includes('reads like a signature and is not one'))); + t('⛔ a signature on a LATER line is not a signature: the first non-empty line decides', plan('second-line.md').ok, false); + const both = plan('unsigned-stamped.md').errors; + t('a body breaking BOTH rules hears both, in one refusal', [both.some((e) => e.includes('{{NOW}}')), both.some((e) => e.includes('no signature line'))], [true, true]); + t('an empty body is told it is empty, once — not also unsigned', plan('empty.md').errors.filter((e) => e.includes('body is empty') || e.includes('signature')).length, 1); + const dir2 = mkdtempSync(join(tmpdir(), 'issue-create-sig-')); + try { + writeFileSync(join(dir2, 'u.md'), 'A body.\n', 'utf8'); + const cli = spawnSync(process.execPath, [SELF_PATH, '--dry-run', '--repo', 'o/r', '--title', 'Dry', '--body-file', join(dir2, 'u.md')], { + encoding: 'utf8', + env: { ...process.env, OS_PM_WRITE_PACE_FILE: join(dir2, 'pace.jsonl'), GITHUB_TOKEN: 'ghs_FixtureTokenNotRealAtAll0000000000000', GH_TOKEN: '', HTTPS_PROXY: '', https_proxy: '' }, + }); + t('⛔ the CLI refuses an unsigned body on a DRY RUN too — usage, nothing printed as sent, nothing paced', [cli.status, cli.stdout.includes('NOTHING was sent'), existsSync(join(dir2, 'pace.jsonl'))], [EXIT_USAGE, false, false], cli.stderr.slice(-300)); + } finally { + rmSync(dir2, { recursive: true, force: true }); + } + } + const dir = mkdtempSync(join(tmpdir(), 'issue-create-')); try { // One pace file PER CASE: a stop marker one answer writes must not refuse @@ -556,7 +605,7 @@ export async function selfTest() { // ── dry-run ───────────────────────────────────────────────────────────── battery('dry-run: no request leaves, and the plan is printed'); { - writeFileSync(join(dir, 'b.md'), 'A body.\n', 'utf8'); + writeFileSync(join(dir, 'b.md'), `${SIG}\nA body.\n`, 'utf8'); const spawned = spawnSync(process.execPath, [SELF_PATH, '--dry-run', '--repo', 'o/r', '--title', 'Dry', '--body-file', join(dir, 'b.md')], { encoding: 'utf8', env: { ...process.env, OS_PM_WRITE_PACE_FILE: join(dir, 'dry-pace.jsonl'), GITHUB_TOKEN: TOKEN_FIXTURE, GH_TOKEN: '', HTTPS_PROXY: '', https_proxy: '' }, diff --git a/scripts/pm/label-write.mjs b/scripts/pm/label-write.mjs index a90f9bf7a9a..995ef990e5a 100644 --- a/scripts/pm/label-write.mjs +++ b/scripts/pm/label-write.mjs @@ -201,6 +201,7 @@ import { markerMatches, proxyRearmPlan, resolveSweepRepo, + stripSignatureLine, } from './check-half-states.mjs'; // The comment half of a release, borrowed exactly as close-cards borrows it: // the one `comment` op, the stamp render, and the board-side read-back of the @@ -477,9 +478,9 @@ const RESUME_FROM_SHAPE = /^([^\s@]+)@([0-9a-fA-F]{7,40})$/; /** Pages of 100 comments the silence read may walk before it refuses to judge an unexhausted thread. */ export const RELEASE_COMMENT_PAGE_CAP = 30; -/** The first line of a body with its markdown heading marks and surrounding whitespace removed. Pure. */ +/** The first line of a body — after one leading signature line (`stripSignatureLine`) — with its markdown heading marks and surrounding whitespace removed. Pure. */ export function firstLineKeyText(body) { - const first = String(body ?? '').split(/\r?\n/)[0] ?? ''; + const first = stripSignatureLine(body).split(/\r?\n/)[0] ?? ''; return first.replace(/^\s*#+\s*/, '').trim(); } @@ -542,7 +543,8 @@ export function commentsAfter(comments, knock) { export function releaseTextRefusal(text) { const raw = String(text ?? ''); if (raw.trim().length === 0) return 'the release comment is empty'; - const first = raw.split(/\r?\n/)[0]; + // One leading signature line stands above the `Release:` line, never in its place (`stripSignatureLine`, #27113). + const first = stripSignatureLine(raw).split(/\r?\n/)[0]; if (!markerMatches(RELEASE_COMMENT_MARKER, first)) { return `the release comment's FIRST line must be the \`Release:\` line (session / reason / destination) — the state model's line-start key — got \`${first.slice(0, 80)}\``; } @@ -1561,7 +1563,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the remaining exits: refusal, mismatch, the ONE-OF block and the dry run': 5, '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': 30, + '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, }); const SELF_TEST_BATTERY_FLOOR = 11; const UNATTRIBUTED_BATTERY = '(unattributed)'; @@ -1817,6 +1819,10 @@ export async function selfTest() { { op: 'unassign', issue: 7, assignees: ['os-tesla'] }, ]); t('the release text is judged by the owning `Release:` reader on its FIRST line, and may not pre-write the resume line', [releaseTextRefusal(RELEASE_TEXT), typeof releaseTextRefusal('Released: x'), typeof releaseTextRefusal('note\nRelease: x'), typeof releaseTextRefusal('Release: x\nResume-from: a@b'), typeof releaseTextRefusal('')], [null, 'string', 'string', 'string', 'string']); + // One leading signature line (#27113) stands ABOVE the first-line key, never in its place. + const SIG = 'domain:skills#2 · os-justin · session_01ABCDEFGHJKMNPQRSTVWXYZ'; + t('a signed knock reads by the key on the line after its signature; a key on a later line, or under a near-miss signature, still does not', [isRecheckComment(`${SIG}\nOwned-24h re-check: x`), isRecheckComment(`${SIG}\n\n## Owned-24h re-check: x`), isRecheckComment(`${SIG}\nTriage\nOwned-24h re-check: x`), isRecheckComment('domain:skills#2 - os-justin - session_01ABCDEFGHJKMNPQRSTVWXYZ\nOwned-24h re-check: x')], [true, true, false, false]); + t('…and a signed release text is judged by its `Release:` line: accepted signed as unsigned, refused when no `Release:` line follows the signature', [releaseTextRefusal(`${SIG}\n${RELEASE_TEXT}`) === releaseTextRefusal(RELEASE_TEXT), typeof releaseTextRefusal(`${SIG}\nnote\nRelease: x`)], [true, 'string']); // The fixture card the protocol names, younger than the clock. const young = await driveRelease(RELEASE_ARGV, silentCard(5)); diff --git a/scripts/pm/post-stamped.mjs b/scripts/pm/post-stamped.mjs index dbdd62185e8..65a2c531202 100644 --- a/scripts/pm/post-stamped.mjs +++ b/scripts/pm/post-stamped.mjs @@ -465,6 +465,17 @@ * ceiling tier with nothing behind it, the measurement behind the fourth). `claimKeyedLineRefusals` carries the four * decisions that keep it a mirror and not a fifth dialect. * + * ## The first line is the seat's signature, or the body is refused (#27113) + * + * Every write here lands under one bot login, so the TEXT is the only place a reader learns who wrote it. The first + * non-empty line of every body this tool writes — `--comment` and `--body` alike — is `LANE#N · ACCOUNT · SESSION`, + * read by `readSignatureLine` (`check-half-states.mjs`, whose banner carries the shape and the ruling); a body whose + * first line is not one is `EXIT_REFUSED` before any request, a dry run included. It is judged beside the stamp + * contract, not behind it, so a body missing both hears both. ⛔ Nothing inserts the line: a `--sign-from` that + * fills it from the seat's roster entry is the ruling's optional half and is not built — the refusal prints the + * shape, and the seat types one line. Every first-line recogniser skips one leading signature line + * (`stripSignatureLine`), so a signed record reads exactly as the unsigned one did. + * * ## A decision analysis carries its four-facet marker, or the comment is refused (#23420) * * A comment whose decision frame, options and recommendation make it a decision analysis is `EXIT_REFUSED` before any @@ -921,7 +932,9 @@ import process from 'node:process'; import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; import { @@ -941,9 +954,13 @@ import { protocolStamps, proxyRearmPlan, resolveSweepRepo, + signatureRefusal, + signatureRefusalText, + SEAT_SIGNATURE_SHAPE, stampDriftMinutes, stampSpan, stripMarkdownCode, + stripSignatureLine, threadReadField, } from './check-half-states.mjs'; import { readClause2Line } from './clause2-line.mjs'; @@ -2393,7 +2410,9 @@ function signalsIn(lines) { */ export function decisionAnalysisSignals(body) { const code = stripMarkdownCode(String(body ?? '')); - const lines = code.split(/\r?\n/u); + // The opening line a frame may sit on is the one AFTER a leading signature line (`stripSignatureLine`, #27113); the + // marker below keeps reading the whole of `code`, the composition `carriesFacetMarker` reads. + const lines = stripSignatureLine(code).split(/\r?\n/u); const level = lines.map((line) => /^ {0,3}(#{1,6})[ \t]/u.exec(line)?.[1].length ?? 0); const nextHeading = (from, atOrAbove) => { for (let j = from + 1; j < lines.length; j++) if (level[j] > 0 && level[j] <= atOrAbove) return j; @@ -3782,6 +3801,8 @@ const USAGE = [ ' with no time behind it. Quote the token AND stamp the artefact — a `{{WAS:…}}` outside the quotation', ' is a stamp, so a body that quotes the contract beside a declaration is accepted. A body that spells', ' no token at all is unchanged.', + ` The FIRST non-empty line of every body, comment or card body, is the seat's signature — ${SEAT_SIGNATURE_SHAPE} —`, + ' and a body without one is REFUSED, the stamp contract\'s refusals listed beside it; nothing inserts it for you.', ' A body refresh is REFUSED while comments newer than the body\'s last write stamp exist and', ' --ack-through=ID does not name the newest of them — a refresh must not void an unread knock.', ' A `Claim:` comment\'s `Seat:`, `Thread-read:`, `Clause-②:` and `Container & model:` lines are read here by the', @@ -3832,8 +3853,11 @@ async function main(argv) { } const rendered = renderBody(input); - if (!rendered.ok) { - console.error(rendered.error); + // 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; } @@ -4045,8 +4069,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the decision facets: a decision analysis carries its marker, and a delta, a record or a quotation is not one': 22, '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, }); -const SELF_TEST_BATTERY_FLOOR = 17; +const SELF_TEST_BATTERY_FLOOR = 18; const UNATTRIBUTED_BATTERY = '(unattributed)'; let selfTestReachedVerdict = false; @@ -4426,7 +4451,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 returns EXIT_REFUSED from the CLI, with nothing written', /if \(!rendered\.ok\) \{\n\s+console\.error\(rendered\.error\);\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+console\.error\(\[\.\.\.\(rendered\.ok \? \[\] : \[rendered\.error\]\)[^\n]*\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)); @@ -5499,6 +5524,40 @@ export function selfTest() { t('⛔ the proxy re-exec at the entrypoint is UNCONDITIONAL — a dry run resolves the route, which is a read, so it re-execs too', entrySource.includes('rearmThroughProxy(process.argv.slice(2))') && /--dry-run'\)\s*\?\s*null\s*:\s*rearmThroughProxy/.test(entrySource) === false); } + // ── the signature (#27113) ───────────────────────────────────────────────── + battery('the signature: every body opens with LANE#N · ACCOUNT · SESSION or is refused, beside the stamp contract; one leading signature changes no reading'); + { + const SIG = 'domain:skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg'; + t('a signed body has no refusal row; an unsigned one has one naming the line it read', signatureRefusal(`${SIG}\nbody`) === null && signatureRefusal('Hello\nbody')?.line === 'Hello'); + t('the refusal text prints the shape to write and says nothing was written', signatureRefusalText(signatureRefusal('Hello'), { tool: 'post-stamped' }).includes('LANE#N · ACCOUNT · SESSION') && signatureRefusalText(signatureRefusal('Hello'), { tool: 'post-stamped' }).includes('Nothing was written')); + t('⛔ the positional stamp rule still reads the ACT\'s line: a bare stamp on the claim line under a signature is refused', kinds(`${SIG}\nClaim: seat 2026-09-10T06:37Z`, NOW_MS).join() === 'positional'); + t('…and `{{NOW}}` there is accepted, the signature sent as written above it', renderBody(`${SIG}\nClaim: seat {{NOW}}`, NOW_MS).ok && renderBody(`${SIG}\nClaim: seat {{NOW}}`, NOW_MS).body.split('\n')[0] === SIG); + const PLAIN = ['Decision record: who lands the gate', '', '- **A**: the seat lands it', '- **B**: the maintainer lands it', '', 'This seat recommends **A**.'].join('\n'); + t('a decision analysis framed on its plain OPENING line is refused unsigned and signed alike — the frame is the line after the signature', decisionFacetRefusals(PLAIN).length === 1 && decisionFacetRefusals(`${SIG}\n${PLAIN}`).length === 1); + const dir = mkdtempSync(join(tmpdir(), 'post-stamped-sig-')); + try { + const run = (args, body) => { + writeFileSync(join(dir, 'b.md'), body, 'utf8'); + return spawnSync(process.execPath, [SELF_PATH, ...args, `--file=${join(dir, 'b.md')}`], { encoding: 'utf8', env: { ...process.env, HTTPS_PROXY: '', https_proxy: '', GITHUB_TOKEN: '', GH_TOKEN: '' } }); + }; + const comment = run(['--dry-run', '--comment=1', '--repo=o/r'], 'Hello {{NOW}}\n'); + t('⭐ the CLI refuses an unsigned COMMENT on a dry run: exit 2, the signature refusal on stderr, nothing on stdout', comment.status === EXIT_REFUSED && comment.stderr.includes('opens with no signature line') && comment.stdout === ''); + const body = run(['--dry-run', '--body=1', '--repo=o/r'], '**This post is the single authoritative registration.**\n'); + t('⭐ …and an unsigned card BODY too — both modes, the seat post above all', body.status === EXIT_REFUSED && body.stderr.includes('opens with no signature line')); + const both = run(['--dry-run', '--comment=1', '--repo=o/r'], 'Claim: seat 2026-09-10T06:37Z\n'); + t('a body breaking the stamp contract AND unsigned hears BOTH, under one exit', both.status === EXIT_REFUSED && both.stderr.includes('stamp-contract problem') && both.stderr.includes('opens with no signature line')); + const empty = run(['--dry-run', '--comment=1', '--repo=o/r'], ' \n'); + t('an empty body is told it is empty, once — not also unsigned', empty.status === EXIT_REFUSED && empty.stderr.includes('the body is empty') && !empty.stderr.includes('signature')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + // 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(')); + t('the usage says it, with the shape', USAGE.includes('signature') && USAGE.includes(SEAT_SIGNATURE_SHAPE)); + } + // 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}`); diff --git a/scripts/pm/record-recognisers.mjs b/scripts/pm/record-recognisers.mjs index 206a1ac12c1..ac892b8cdbf 100644 --- a/scripts/pm/record-recognisers.mjs +++ b/scripts/pm/record-recognisers.mjs @@ -91,6 +91,7 @@ import { deliveryEvidenceNote, latestMarkedComment, partOfTargets, + stripSignatureLine, } from './check-half-states.mjs'; // The tier `isModelIdentifierToken` refuses by VALUE. IMPORTED, never restated: // `dispatch-gates.mjs` is the one line in `scripts/pm/**` where the model id is @@ -1056,7 +1057,9 @@ const MAILBOX_FIELD_READERS = Object.freeze({ * | { state: 'read', id: string, kind: string, key: string, numbers: number[] } & Record} */ export function readMailboxLine(text) { - const lines = String(text ?? '').split(/\r?\n/); + // The key is the first line AFTER one leading signature line (`stripSignatureLine`, #27113): the + // signature names who rang, and is neither the key nor a second line of content. + const lines = stripSignatureLine(text).split(/\r?\n/); const at = lines.findIndex((l) => l.trim() !== ''); if (at < 0) return { state: 'refused', reason: 'an empty comment' }; const first = lines[at]; @@ -1744,6 +1747,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ letter B: a declared NARROWING is a face of its own at CONTRACT_REVIEW_TIER — keyed on the `Clause-②:` line, exact': 17, '⭐ the `Contract-review-handoff:` line — names the current head ⇒ triage\'s review; head moved ⇒ stale; absent ⇒ the seat\'s own': 13, '⭐ the mailbox doorbell: the seven first-line keys read — kind, numbers, sha, login, time — and anything else refused': 21, + '⭐ the signature line: the reader reads LANE#N · ACCOUNT · SESSION exactly, and one leading signature changes no first-line reading': 22, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1759,7 +1763,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ // Raised 12 → 13 with the hand-off battery (the maintainer's exhausted-tier rule): one battery, one row. // Raised 13 → 14 with the mailbox doorbell battery (the maintainer's per-seat mailbox and its closed // vocabulary): one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 14; +// Raised 14 → 15 with the signature-line battery (the maintainer's signed-post rule): one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 15; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -2187,6 +2192,75 @@ export async function selfTest() { t('⛔ no gate reads it: the queue guard\'s record reading is untouched — a doorbell comment beside a record on this head still reads FOUND', (() => { const r = locateReviewOfRecord(pairOf({ prComments: [RECORD_ON_9AF9, DB_ROW(DB.nc, 5104)] })); return r.state === 'found' && r.id === 3301; })()); t('the roster: seven keys, unique ids, each of the two kinds, the byte cap the text states', MAILBOX_KEYS.length === 7 && new Set(MAILBOX_KEYS.map((k) => k.id)).size === 7 && MAILBOX_KEYS.every((k) => ['Request', 'Notify'].includes(k.kind) && k.template.startsWith(`${k.kind}: ${k.key}`)) && MAILBOX_LINE_MAX_BYTES === 200); + // -- the signature line (#27113) ---------------------------------------------- + // + // Every seat-authored body now opens with `LANE#N · ACCOUNT · SESSION`, so every recogniser that keys + // on a body's FIRST line must read the same thing with that line above it. These cases read the + // reader's own shape, pin its session half to the relay envelope's, and then drive each recogniser + // the card names twice — the fixture as it is, and the same fixture signed — asserting the reading + // is positive AND identical. Readers that live in other modules are imported here, in the self-test + // body, so the module the queue guard loads lazily gains no edge. + battery('⭐ the signature line: the reader reads LANE#N · ACCOUNT · SESSION exactly, and one leading signature changes no first-line reading'); + { + const hs = await import('./check-half-states.mjs'); + const { SESSION_SHAPE } = await import('./fleet-write/ops.mjs'); + const { SEAT_RECORD_MARKER } = await import('./seat-record.mjs'); + const { classifyRulingComment } = await import('./check-prior-rulings.mjs'); + const SIG = 'domain:skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg'; + const signed = (body) => `${SIG}\n${body}`; + const J = (v) => JSON.stringify(v, (k, x) => (x instanceof Set ? [...x] : x)); + const same = (read, body) => J(read(body)) === J(read(signed(body))); + + // The reader's own shape. + t('the four parts read: lane, seat number, account, session', J(hs.readSignatureLine(SIG)) === J({ lane: 'domain:skills', seat: 2, account: 'os-justin', session: 'session_01QSeKYmhU5KEj1nukYhp1Tg', line: SIG })); + t('every lane the role table names reads — a hyphenated lane, a repo seat, an epic, triage, director, coordinator', ['domain:engine-core#1', 'repo:cloud#2', 'epic:#26000#1', 'triage#1', 'director#1', 'coordinator#3'].every((lane) => hs.readSignatureLine(`${lane} · os-zhuang · session_01JQwxbZeBv2y91towxasSUT`)?.lane === lane.replace(/#[0-9]+$/u, ''))); + t('a container-derived session (`cse_` tail) reads: the relay envelope\'s shape, not only the desktop one', hs.readSignatureLine('triage#1 · huangyiirene · session_01ABCDEFGHJKMNPQRSTVWXYZ')?.session === 'session_01ABCDEFGHJKMNPQRSTVWXYZ'); + t('⛔ the near misses are NOT signatures: no seat number, seat 0, a bare lane, a capital, hyphens or doubled spaces for the dot, a short session, bold wrapping, trailing words', [ + 'triage · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg', + 'triage#0 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg', + 'skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg', + 'domain:Skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg', + 'domain:skills#2 - os-justin - session_01QSeKYmhU5KEj1nukYhp1Tg', + 'domain:skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg', + 'domain:skills#2 · os-justin · session_x', + '**domain:skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg**', + 'domain:skills#2 · os-justin · session_01QSeKYmhU5KEj1nukYhp1Tg · round 3', + ].every((line) => hs.readSignatureLine(line) === null)); + t('the FIRST non-empty line decides: blank lines above it and a CRLF read; a signature on line two does not', hs.readSignatureLine(`\n\n${SIG}\r\nbody`) !== null && hs.readSignatureLine(`body\n${SIG}`) === null && hs.readSignatureLine('') === null); + t('⭐ the session half IS the relay envelope\'s `SESSION_SHAPE` (fleet-write/ops.mjs) — mirrored, held equal here', SESSION_SHAPE.source === `^${hs.SEAT_SIGNATURE_SESSION_SOURCE}$`); + t('the separator is the doorbell\'s dot, exactly: one space, U+00B7, one space', hs.SEAT_SIGNATURE_SEPARATOR === ' · ' && hs.SEAT_SIGNATURE_SEPARATOR.split(MAILBOX_SEPARATOR).length === 2); + t('`stripSignatureLine` leaves an unsigned body byte-identical, leading blank lines and all', ['', '\n\nClaim: x\n', 'os-dev-report\n{}', `body\n${SIG}`].every((b) => hs.stripSignatureLine(b) === b)); + t('…removes ONE signature line and the blank lines after it, and only one', hs.stripSignatureLine(`${SIG}\n\n\nClaim: x`) === 'Claim: x' && hs.stripSignatureLine(`${SIG}\n${SIG}\nClaim: x`) === `${SIG}\nClaim: x` && hs.stripSignatureLine(SIG) === ''); + + // Each named recogniser, unsigned and signed. + const rec = RECORD_ON_9AF9; + const recSigned = { ...rec, body: signed(rec.body) }; + const found = locateReviewOfRecord(pairOf({ prComments: [recSigned] })); + t('`## Contract review`: the signed record on this head reads FOUND with the same id and head span; its `Served-tier:` and `Local-runs:` read the same', found.state === 'found' && J([found.id, found.sha]) === J([onPr.id, onPr.sha]) && same(readServedTier, rec.body) && same(readLocalRuns, `${rec.body}\n- **Local-runs:** none`)); + const report = 'os-dev-report\n```json\n{"issue": 1}\n```'; + t('`os-dev-report`: the marker reads with and without, and the newest report is found on a signed thread', hs.OS_DEV_REPORT_MARKER.test(report) && hs.OS_DEV_REPORT_MARKER.test(signed(report)) && hs.latestMarkedComment([{ body: signed(report), created_at: '2026-10-28T09:00:00Z' }], hs.OS_DEV_REPORT_MARKER) !== null); + t('⛔ …while a marker on a LATER line, below anything but one signature, is still no report', hs.OS_DEV_REPORT_MARKER.test(`${SIG}\n## PM review\n\nos-dev-report\n`) === false && hs.OS_DEV_REPORT_MARKER.test(`${SIG}\n${SIG}\nos-dev-report\n`) === false); + t('`Contract-review-handoff:` reads the same newest-line verdict on a signed thread', (() => { const a = contractReviewHandoff([HO_ROW(HO_LINE())], HO_HEAD); const b = contractReviewHandoff([{ ...HO_ROW(HO_LINE()), body: signed(HO_ROW(HO_LINE()).body) }], HO_HEAD); return a.state === 'triage' && J(a) === J(b); })()); + const claim = 'Claim: PM loop round 1 · 2026-10-28T12:44Z\nSession: `session_01QSeKYmhU5KEj1nukYhp1Tg`\nSeat: `domain:skills#2`\nBranch: `claude/issue-1-x`'; + t('claim lines: the `Claim:` marker and the seat number read the same signed', hs.markerMatches(hs.CLAIM_COMMENT_MARKER, signed(claim)) && same(hs.claimSeatNumber, claim) && hs.claimSeatNumber(signed(claim)) === 2); + t('release lines: the `Release:` marker reads the same signed', hs.markerMatches(hs.RELEASE_COMMENT_MARKER, 'Release: session_x · reason · back to pm:queue') && hs.markerMatches(hs.RELEASE_COMMENT_MARKER, signed('Release: session_x · reason · back to pm:queue'))); + const YL = 'session_01TsiNYHjuek413vNgFHqcAm'; + const yRows = (yieldBody) => [ + { id: 1, created_at: '2026-10-25T09:47:34Z', body: 'Claim: a · 2026-10-25T09:47Z\nSession: `session_014FZ3TJucS1L7BdMe5DAzXC`' }, + { id: 2, created_at: '2026-10-25T09:54:58Z', body: `Claim: b · 2026-10-25T09:54Z\nSession: \`${YL}\`` }, + { id: 3, created_at: '2026-10-25T10:01:06Z', body: yieldBody }, + ]; + const YIELD = `already claimed — yielding · \`domain:skills#2\` · \`${YL}\` · 2026-10-25T10:00Z\n\nThe earlier claim decides.`; + t('a claim\'s yield line: the yielded claim is the same one under a signed yield', J(hs.yieldedClaimIndexes(yRows(YIELD))) === J([1]) && J(hs.yieldedClaimIndexes(yRows(signed(YIELD)))) === J([1])); + const ROUNDS = ['**Round-open marker** · triage seat · `session_01x` · **R+236** · fire 2026-09-15T14:51Z', 'R+12 open · triage', '分诊轮收尾 · R+234 · `session_01x`', '# 🔻 收班简报 · 分诊席 · `session_01x` · **R+220 → R+234**']; + t('round records: every round-artefact headline is the same line signed, and its shape still matches', ROUNDS.every((r) => same(hs.h65Headline, `${r}\n\nbody`) && hs.H65_ROUND_ARTEFACT_SHAPES.some((shape) => shape.re.test(hs.h65Headline(signed(`${r}\n\nbody`)))))); + t('`## 席位核对`: the seat-record heading reads the same signed', SEAT_RECORD_MARKER.test('## 席位核对\n- ① ok') && SEAT_RECORD_MARKER.test(signed('## 席位核对\n- ① ok'))); + t('⭐ the mailbox doorbell: all seven keys read IDENTICALLY signed — the signature is neither the key nor a second line', Object.values(DB).every((line) => readMailboxLine(line).state === 'read' && same(readMailboxLine, line)) && readMailboxLine(signed(`${DB.ut}\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_`)).state === 'read'); + t('⛔ …a signature with no key under it is refused, and so is a key under a near-miss signature or under two', readMailboxLine(SIG).state === 'refused' && readMailboxLine(`domain:skills#2 - os-justin - session_01QSeKYmhU5KEj1nukYhp1Tg\n${DB.ut}`).state === 'refused' && readMailboxLine(`${SIG}\n${SIG}\n${DB.ut}`).state === 'refused'); + t('triage rulings and director rulings: the first-line shape reads the same signed', ['Triage: lands in domain:skills', '**Grading (skills seat):** p2'].every((r) => hs.isTriageRulingComment(r) && same(hs.isTriageRulingComment, r)) && classifyRulingComment('Ruling: batch #9 item 2 · letter A') !== null && same(classifyRulingComment, 'Ruling: batch #9 item 2 · letter A')); + t('the act\'s OPENING-line stamp (H56, which post-stamped\'s positional refusal imports) is the same reading under a signature', hs.h56StampedReadings('Claim: x · 2026-10-28T12:44Z\nbody').length === 1 && same(hs.h56StampedReadings, 'Claim: x · 2026-10-28T12:44Z\nbody')); + } + // -- #18042: the copyable record TEMPLATE ---------------------------------- // // `references/contract-review.md` DESCRIBES the record in prose and once diff --git a/scripts/pm/with-fleet.sh b/scripts/pm/with-fleet.sh index 3da4d6cac5e..8ccd917fd61 100755 --- a/scripts/pm/with-fleet.sh +++ b/scripts/pm/with-fleet.sh @@ -206,7 +206,8 @@ EOF run -- sh -c 'exit 7' || rc=$? st_case "a failing command's own exit code passes through, never rewritten" "$rc" 7 # A scripts/pm tool on the write-pace roster gates itself: identity only. - printf 'A body.\n' > "$dir/b.md" + # The body opens with the seat's signature line, which issue-create refuses a card body without. + printf 'domain:skills#2 · os-justin · session_01ABCDEFGHJKMNPQRSTVWXYZ\nA body.\n' > "$dir/b.md" : > "$err.tool" env -u GIT_DIR OS_FLEET_TOKEN_CACHE_FILE="$cache" OS_FLEET_APP_ID=1 OS_FLEET_INSTALLATION_ID=2 OS_PM_WRITE_PACE_FILE="$pace" OS_PM_WRITE_MIN_GAP_MS=0 HTTPS_PROXY= https_proxy= \ CCR_AGENT_PROXY_ENABLED= bash "$SELF" -- node "$HERE/issue-create.mjs" --dry-run --repo o/r --title x --body-file "$dir/b.md" > /dev/null 2> "$err.tool"