From f9e63d6c36657622f8e564a5a5346bc0eecdbe49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:24:25 +0000 Subject: [PATCH 1/2] feat(fleet-write): pr_create carries labels in the create's stroke; os-dev declares skip-changeset there The relay's `pr_create` op gains an optional `labels` list, the shape its `assignees` leg already has: a later POST to the created pull's issue-labels endpoint at the number the create answered, judged against `labelled` so a name the answer lacks is a FAILED action, never a quiet partial. Both legs now fill from the action's FIRST answer (the create's), never a later leg's. The validator reuses the typed `labels` list `labels_add` takes. `.claude/agents/os-dev.md`: three writes instead of four; `skip-changeset` is declared in the `pr_create` stroke and `label-write.mjs` serves only a label found after the PR exists; a classifier refusal routes through the seat to the maintainer's word instead of the seat substituting. Claude-Session: https://claude.ai/code/session_012PxNHU3dfov2hbpg9ycZ8q Co-authored-by: Claude --- .claude/agents/os-dev.md | 8 +-- scripts/pm/fleet-write/execute.mjs | 92 +++++++++++++++++++++-------- scripts/pm/fleet-write/ops.mjs | 70 ++++++++++++---------- scripts/pm/fleet-write/validate.mjs | 30 ++++++---- 4 files changed, 128 insertions(+), 72 deletions(-) diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index f91f9b52cb2..1eeaf4fdbc4 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -58,7 +58,7 @@ model: opus - 写工具自读所需变量;⛔ 永不打印环境变量的值,转录是 AGENTS.md 禁落令牌的日志行。 - 拼法不限于 `env`、`printenv`、`set`、`export -p`、`echo $VAR`、`cat /proc/*/environ`。 - 判存在只用不印值形式 `[ -n "${X+x}" ] && echo set`;已印出 ⇒ `deviations` 只点名变量名。 - - 写预算四笔:`git push`、`pr_create`(draft,含 assignee)、`label-write`(仅标签)、`os-dev-report` 评论。 + - 写预算三笔:`git push`、`pr_create`(draft,含 assignee 与 labels)、`os-dev-report` 评论。 - 卡与线程只走 payload 档(公开仓单卡网页内嵌 JSON,拼写住 platform-readings)或单卡 REST 读。 - 三类发现进报告交席位代立,不 `POST /issues`、⛔ 不静默弃报;PM 去重读数当既有事实用。 - PR 正文 dev 只写一次,在开 PR 那一笔,⛔ 不 `PATCH`;事后要改的报告点名改法,席位代写。 @@ -300,13 +300,13 @@ model: opus - 快速通道:`docs/adr/**` · `.claude/**` · `scripts/pm/**` · 仓根配置 · 私有包 · 注释,不发布。 - 其余实测:构建后 grep `files[]` 所列路径找符号,带正控;符号零命中、正控命中 ⇒ 不发布。 - PR 改到已有的 `.changeset/*.md`:⛔ 永不打 `skip-changeset`,门禁红着是对的,确认走 PR 文字。 -- objectstack:标签是真实机制,打标签是你的默认步骤,PR 一开出就打;派发词可收窄或禁写。 +- objectstack:`skip-changeset` 以 `pr_create` 的 `labels` 同笔申报;后发现的标签才走 `label-write.mjs`。 - 范围 = 派发词点名的标签 + 上文判据下的 `skip-changeset`;禁写或交集为空 ⇒ 零写并报告。 -- 写恒经 `scripts/pm/label-write.mjs`:取现集、加法 POST、回读比 union、缺者重挂一次并报告。 +- 该腿的回读是中继回报的行:答案须列出每个送出的标签,缺者该笔 FAILED、不发后续动作。 - 读回只检测剥除防不了(size-labeler 整组 PUT),门标签被剥恰成绿灯;加法写同样必要不充分。 - 关此步骤的是读回不是写入;读回只验写落了,验不出有门在读:幻影门标签读回照样成功。 - 被拒 ⇒ 报端点与状态码、席位代挂,⛔ 不报 `blocked`、不走 MCP;收尾再读一次。 -- 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位代做;⛔ 不换路重发(curl/MCP/手工)。 +- 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位只转维护者定夺;⛔ 不换路重发。 - objectui:路径标签归 `labeler.yml`,逐 push 同步;无门禁读你打的标签 ⇒ 派发词未点名即零写。 - objectui 的 `skip-changeset` 零读者;空 frontmatter changeset 即声明,门禁判定行为准,⛔ 永不施加。 - 报告在本地验证走完时交付,CI 收敛等待归 PM 不归你:⛔ 不为等 CI 结论推迟报告。 diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index ffb9fb284e8..f24649821c2 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -95,23 +95,30 @@ * Any other status is FAILED as every REST 4xx is. The PUT is paced like * every other write verb. * - * ## The assignees leg — `pr_create` with `assignees`, the one request that reads an earlier answer + * ## The assignees and labels legs — `pr_create` with `assignees` / `labels`, the requests that read an earlier answer * - * The op table spells that leg's path with a placeholder and `fromAnswer: + * The op table spells each leg's path with a placeholder and `fromAnswer: * 'number'` (`ops.mjs`, its header). Within one action the requests run in - * order and each answer is kept; a descriptor carrying `fromAnswer` is - * filled from the PREVIOUS request's answer — `fillFromAnswer`: the field - * must be a positive integer, and the path is the placeholder replaced by - * it, so the pull the assignee lands on is the one the platform answered, - * ⛔ never one the request named. An answer without it is a FAILED action - * with the leg NOT SENT — its row says so, status `not sent` — and the - * stroke stops there as on any failure. The leg's landing is judged by its - * answer's `assignees` against `assigned`, the logins sent (`requestLanded`): - * GitHub documents a login the caller cannot apply as "silently ignored", - * the 201 still answered, so an answer listing fewer than sent is a FAILED - * action naming the missing login, never a quiet partial — and the row - * prints the assignees as the platform lists them (`resultOf`). The leg is - * paced like every other write verb. + * order and the FIRST answer — the create's — is kept; a descriptor carrying + * `fromAnswer` is filled from that answer, ⛔ never from a later leg's + * (the assignee POST answers the issue object, which happens to carry a + * number, and the label POST answers an array, which carries none; neither + * is the create) — `fillFromAnswer`: the field must be a positive integer, + * and the path is the placeholder replaced by it, so the pull the assignee + * or label lands on is the one the platform answered, ⛔ never one the + * request named. An answer without it is a FAILED action with the leg NOT + * SENT — its row says so, status `not sent` — and the stroke stops there as + * on any failure. Each leg's landing is judged by its answer against what + * was sent (`requestLanded`): the assignee leg's `assignees` against + * `assigned` — GitHub documents a login the caller cannot apply as "silently + * ignored", the 201 still answered; the label leg's answer, the pull's whole + * label set, against `labelled` — a name sent and not listed did not land. + * An answer listing fewer than sent is a FAILED action naming the missing + * login or name, never a quiet partial; MORE than sent is not judged (the + * Auto Label workflow writes `documentation` and `size/*` on open, additively, + * so both sets land). The row prints the assignees, or the labels, as the + * platform lists them (`resultOf`). Both legs are paced like every other + * write verb. * * ## The step summary — what a person reads back * @@ -246,6 +253,14 @@ export function requestLanded(req, { status, json } = {}) { return { ok: false, why: `HTTP ${status}, but the answer lists ${listed.length ? listed.join(', ') : 'no assignees'} — ${missing.join(', ')} not assigned (GitHub ignores a login the repository cannot assign without a word: it must be a collaborator the repository can assign)` }; } } + // The labels leg (header): the answer is the pull's whole label set; a name sent and not listed did not land. + if (Array.isArray(req.labelled)) { + const listed = labelNames(json); + const missing = req.labelled.filter((name) => !listed.some((l) => l.toLowerCase() === name.toLowerCase())); + if (missing.length) { + return { ok: false, why: `HTTP ${status}, but the answer lists ${listed.length ? listed.join(', ') : 'no labels'} — ${missing.join(', ')} not applied (the labels endpoint answers the pull's whole label set, so a name it lacks was not written; read the pull on the board before anything else)` }; + } + } return { ok: true, why: '' }; } if (status === 404 && req.idempotent404) return { ok: true, why: 'already absent', idempotent: true }; @@ -257,11 +272,16 @@ export function assigneeLogins(json) { return Array.isArray(json?.assignees) ? json.assignees.map((u) => (typeof u?.login === 'string' ? u.login : null)).filter(Boolean) : []; } +/** The names a labels answer (an array of label objects) lists, in the platform's order; `[]` when it lists none. Pure. */ +export function labelNames(json) { + return Array.isArray(json) ? json.map((l) => (typeof l?.name === 'string' ? l.name : null)).filter(Boolean) : []; +} + /** - * A descriptor carrying `fromAnswer` filled from the PREVIOUS request's answer - * (header): `{ req }` with the placeholder replaced by the positive integer - * that answer carries at the field, or `{ fail }` — the sentence the row - * prints — when no answer carries it. Pure; never throws. + * A descriptor carrying `fromAnswer` filled from its action's FIRST request's + * answer — the create's (header): `{ req }` with the placeholder replaced by + * the positive integer that answer carries at the field, or `{ fail }` — the + * sentence the row prints — when no answer carries it. Pure; never throws. */ export function fillFromAnswer(req, prev) { const field = req.fromAnswer; @@ -350,7 +370,8 @@ export function resultOf(req, json, status = null) { if (!pr) return 'ok'; return pullStateText(pr); } - if (Array.isArray(json)) return `${json.length} label(s) now on the issue`; + // The labels leg's row prints the labels as the platform lists them (header); a plain labels_add is counted. + if (Array.isArray(json)) return Array.isArray(req.labelled) ? `${json.length} label(s) now on the pull · labels ${labelNames(json).join(', ') || 'none'}` : `${json.length} label(s) now on the issue`; if (!json || typeof json !== 'object') return 'ok'; const bits = []; if (Number.isInteger(json.number)) bits.push(`#${json.number}`); @@ -555,10 +576,10 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF for (let i = 0; i < payload.actions.length && stoppedAt === null; i++) { const action = payload.actions[i]; const requests = OPS[action.op].requests(action, payload.repo); - let prev = null; + let opener = null; for (const spelled of requests) { - // A leg that reads an earlier answer (header) is filled from the previous request's answer, or NOT SENT. - const filled = spelled.fromAnswer === undefined ? { req: spelled } : fillFromAnswer(spelled, prev); + // A leg that reads an earlier answer (header) is filled from the action's FIRST answer — the create's — or NOT SENT. + const filled = spelled.fromAnswer === undefined ? { req: spelled } : fillFromAnswer(spelled, opener); if (filled.fail) { const why = scrub(filled.fail, [token]); rows.push({ action: i + 1, op: action.op, call: `${spelled.verb} ${spelled.path}`, status: 'not sent', result: `FAILED: ${why}` }); @@ -581,7 +602,7 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF } // The GraphQL leg is spelled with its literal verb: every mutation is a POST to one path. const r = req.graphql ? await rest(api, '/graphql', { method: 'POST', body }, t) : await rest(api, req.path, { method: req.verb, body }, t); - prev = r; + if (opener === null) opener = r; // The base sync is confirmed by MEASURE (header): the head polled off the expected sha, or a 422 judged by the compare. const landed = req.updateBranch ? await confirmUpdateBranch(req, r, payload, api, t, ub) : requestLanded(req, r); const call = req.graphql ? `POST /graphql ${req.graphql.mutation}` : r.call; @@ -624,7 +645,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the GraphQL ops: the node id first, then the mutation; an errors array is a failure': 5, 'the PR-state landing: a 200 lands only when the answer SHOWS the state the op asked for — armed or queued for auto-merge, off for its disable, ready and draft for the flips': 12, "the base sync: the PUT carries the expected head; a 202 is confirmed by polling the head off that sha inside the window and reading the merge commit's author; a 422 is a no-op only when the compare measures the head not behind its base; a moved head, a head still behind, an unreadable measure or an expired window is FAILED with nothing after it attempted": 10, - "the assignees leg: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent": 11, + "the assignees and labels legs: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent; with labels, ONE POST to the issue-labels endpoint at that same number — the create's, never the assignee answer's — whose answer must list every name sent": 19, 'the summary: request, sender and role, session, target, one row per request': 5, 'redaction: the token reaches no summary line, log line or error': 3, 'the wiring: both halves around every write verb, on the roster': 4, @@ -890,7 +911,7 @@ export async function selfTest() { } // ── the assignees leg ─────────────────────────────────────────────────── - battery("the assignees leg: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent"); + battery("the assignees and labels legs: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent; with labels, ONE POST to the issue-labels endpoint at that same number — the create's, never the assignee answer's — whose answer must list every name sent"); { const PULLS = `POST /repos/${REPO}/pulls`; const ASSIGN = (n) => `POST /repos/${REPO}/issues/${n}/assignees`; @@ -916,6 +937,25 @@ export async function selfTest() { const refused = await run(create(['marchtian']), { ...allowed, [PULLS]: opened, [ASSIGN(22700)]: { status: 403, json: { message: 'Resource not accessible by integration' } } }); t("a refused assignee POST is a FAILED action with the platform's sentence, the pull already open", [refused.exit, refused.rows.map((r) => r.status), refused.rows[1]?.result.includes('Resource not accessible by integration')], [EXIT_ACTION_FAILED, [201, 403], true]); t('no annotation is emitted for it — pr_create is not an annotated op, assignees or not', [ok.annotations, ok.logs.some((l) => l.startsWith('::notice'))], [[], false]); + + // The labels leg — the same shape, a dev's `skip-changeset` riding the create. + const LABEL = (n) => `POST /repos/${REPO}/issues/${n}/labels`; + const createWith = (fields, extra = []) => base([{ op: 'pr_create', title: 'T', head: 'claude/issue-1-x', base: 'main', body: 'B', ...fields }, ...extra]); + const labelsOn = (names) => ({ status: 200, json: names.map((name) => ({ name })) }); + const both = OPS.pr_create.requests({ op: 'pr_create', title: 'T', head: 'h', base: 'main', assignees: ['marchtian'], labels: ['skip-changeset'] }, REPO); + t('the table half: with labels the row carries a POST to the issue-labels path at the placeholder, fromAnswer number, the names as labelled — after the assignees leg when both are sent', both.map((r) => [r.verb, r.path, r.fromAnswer ?? null, r.assigned ?? null, r.labelled ?? null]), [['POST', `/repos/${REPO}/pulls`, null, null, null], ['POST', `/repos/${REPO}/issues/${answerPlaceholder('number')}/assignees`, 'number', ['marchtian'], null], ['POST', `/repos/${REPO}/issues/${answerPlaceholder('number')}/labels`, 'number', null, ['skip-changeset']]]); + // The assignee answer here carries a DIFFERENT number: the label leg must still land on the pull the CREATE answered. + const labelled = await run(createWith({ assignees: ['marchtian'], labels: ['skip-changeset'] }), { ...allowed, [PULLS]: opened, [ASSIGN(22700)]: assignedTo(['marchtian'], 99999), [LABEL(22700)]: labelsOn(['skip-changeset', 'documentation']) }, { file: paceFile }); + t("the run: the create, the assignee POST, then the label POST — at the number the CREATE answered, not the assignee answer's — exit 0", [labelled.exit, writes(labelled.seen).map((s) => [s.call, s.body])], [EXIT_OK, [[PULLS, { title: 'T', head: 'claude/issue-1-x', base: 'main', body: 'B', draft: true }], [ASSIGN(22700), { assignees: ['marchtian'] }], [LABEL(22700), { labels: ['skip-changeset'] }]]]); + t("…and the leg's row prints the labels as the platform lists them — more than sent (the Auto Label workflow's additive writes) is a landing, never a failure", labelled.rows[2], { action: 1, op: 'pr_create', call: LABEL(22700), status: 200, result: '2 label(s) now on the pull · labels skip-changeset, documentation' }); + const labelsOnly = await run(createWith({ labels: ['skip-changeset'] }), { ...allowed, [PULLS]: opened, [LABEL(22700)]: labelsOn(['skip-changeset']) }, { file: paceFile }); + t('labels without assignees: the create, then the label POST alone', [labelsOnly.exit, writes(labelsOnly.seen).map((s) => s.call)], [EXIT_OK, [PULLS, LABEL(22700)]]); + const unlabelled = await run(createWith({ labels: ['skip-changeset', 'no-such-label'] }, [{ op: 'comment', issue: 1, body: 'after' }]), { ...allowed, [PULLS]: opened, [LABEL(22700)]: labelsOn(['skip-changeset']), [`POST /repos/${REPO}/issues/1/comments`]: { status: 201, json: { id: 1 } } }); + t('⛔ a 200 whose answer lacks a name sent is a FAILED action naming it — never a quiet partial — and the stroke stops there', [unlabelled.exit, unlabelled.rows[1]?.result.includes('no-such-label not applied'), unlabelled.rows[1]?.result.includes('lists skip-changeset'), writes(unlabelled.seen).length, unlabelled.summary.includes('1 later action(s) NOT attempted')], [EXIT_ACTION_FAILED, true, true, 2, true], unlabelled.rows[1]?.result); + t('…a name is matched as GitHub matches one, case-insensitively', requestLanded({ verb: 'POST', path: '/x', labelled: ['Skip-Changeset'] }, { status: 200, json: [{ name: 'skip-changeset' }] }).ok, true); + t("…and a plain labels_add answer is counted, not judged — labelled is the leg's alone", [requestLanded({ verb: 'POST', path: '/x' }, { status: 200, json: [] }).ok, resultOf({ verb: 'POST', path: '/x' }, [{ name: 'a' }], 200)], [true, '1 label(s) now on the issue']); + const blankLabel = await run(createWith({ labels: ['skip-changeset'] }, [{ op: 'comment', issue: 1, body: 'after' }]), { ...allowed, [PULLS]: { status: 201, json: { html_url: 'https://github.test/x' } }, [`POST /repos/${REPO}/issues/1/comments`]: { status: 201, json: { id: 1 } } }); + t('a create whose answer carries no number: the label leg is NOT SENT, exit 5, the later action never attempted', [blankLabel.exit, blankLabel.rows.map((r) => r.status), writes(blankLabel.seen).map((s) => s.call)], [EXIT_ACTION_FAILED, [201, 'not sent'], [PULLS]]); } // ── the transfer ──────────────────────────────────────────────────────── diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index 6d91b90d98c..6ad2cb66976 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -54,30 +54,35 @@ * 422. How the executor tells that 422 from the "nothing to merge" one, and * how it confirms a 202, is `execute.mjs`'s (`confirmUpdateBranch`). * - * ## The one row whose SECOND request reads the FIRST's answer — `pr_create` with `assignees` + * ## The rows whose LATER requests read the FIRST's answer — `pr_create` with `assignees` or `labels` * - * A pull request's assignees are written on its issue endpoint, - * `POST /repos/{o}/{r}/issues/{n}/assignees`, and `{n}` exists only once the - * create has answered. So a `pr_create` carrying `assignees` is TWO requests - * in one action: the draft create, then the assignee POST, whose path is - * spelled with the placeholder `answerPlaceholder('number')` and which - * carries `fromAnswer: 'number'` — the executor fills the placeholder from - * the create's ANSWER (a positive integer at that field; ⛔ never from the - * request) and refuses to send the leg at all when the answer carries none, - * a FAILED action that says the leg was NOT SENT. The leg also carries - * `assigned`, the logins its answer must list: GitHub's documentation of the - * endpoint says an assignee the caller cannot apply is "silently ignored", - * with the 201 still answered, so a 2xx whose `assignees` lacks a login sent - * is a FAILED action naming it, never a quiet partial. The leg is - * `issues: write` (`alsoSpends`), a grant every stroke's token carries as a - * literal mint input, so the row stays `pull-requests` and the mint widens - * nothing. Why the assignee rides the create's stroke rather than a second + * A pull request's assignees and labels are written on its issue endpoints, + * `POST /repos/{o}/{r}/issues/{n}/assignees` and `POST /repos/{o}/{r}/issues/{n}/labels`, + * and `{n}` exists only once the create has answered. So a `pr_create` + * carrying `assignees` or `labels` is TWO or THREE requests in one action: + * the draft create, then the assignee POST, then the label POST — each later + * leg's path spelled with the placeholder `answerPlaceholder('number')` and + * carrying `fromAnswer: 'number'`. The executor fills the placeholder from + * the CREATE's answer, the action's first (a positive integer at that field; + * ⛔ never from the request, never from a later leg's answer) and refuses to + * send the leg at all when that answer carries none, a FAILED action that + * says the leg was NOT SENT. Each leg also carries what its answer must + * list: `assigned`, the logins — GitHub's documentation of the endpoint says + * an assignee the caller cannot apply is "silently ignored", with the 201 + * still answered; `labelled`, the names — the labels endpoint answers the + * pull's whole label set, so a name sent and not listed is a write that did + * not land. Either way a 2xx whose answer lacks one sent is a FAILED action + * naming it, never a quiet partial. Both legs are `issues: write` + * (`alsoSpends`), a grant every stroke's token carries as a literal mint + * input, so the row stays `pull-requests` and the mint widens nothing. Why + * the assignee and the label ride the create's stroke rather than a second * write: a seat's second write is a second permission verdict in its own - * session, measured to vary on an identical command, and the assignee is - * part of opening the pull, not a later act. `validate.mjs --self-test` pins - * that this is the ONE descriptor carrying `fromAnswer` and that it is never - * the first request of its action; `execute.mjs --self-test` pins the fill, - * the refusal to send and the landing. + * session, measured to vary on an identical command, and both are part of + * opening the pull, not a later act — a dev's `skip-changeset` is a + * declaration about the diff the pull opens with. `validate.mjs --self-test` + * pins that these are the TWO descriptors carrying `fromAnswer` and that + * neither is the first request of its action; `execute.mjs --self-test` pins + * the fill, the refusal to send and the landing. * * ## The permissions the relay's token is narrowed to * @@ -488,10 +493,10 @@ export function transferRemedy(source, target) { * `alsoSpends` (optional) grants the row spends beside `permission` — * each a literal mint input, never a stroke-scoped one * (`validate.mjs --self-test` pins it): `pr_create`'s - * assignees leg, a LATER request, and `automerge_enable` on + * assignees and labels legs, LATER requests, and `automerge_enable` on * a pull that changes `.github/workflows/**` (header above); * `requests` the request descriptors the executor issues, in order: - * `{ verb, path, body?, idempotent404?, graphql?, updateBranch?, fromAnswer?, assigned? }` + * `{ verb, path, body?, idempotent404?, graphql?, updateBranch?, fromAnswer?, assigned?, labelled? }` * — a `graphql` descriptor names the mutation and the pull (or * the issue and target) whose node ids it needs; a pull's * descriptor also carries `landed`, its `PR_LANDED_STATE` @@ -499,11 +504,12 @@ export function transferRemedy(source, target) { * bare 200; an `updateBranch` descriptor carries the pull and * the expected head `confirmUpdateBranch` (execute.mjs) judges * the answer against; a `fromAnswer` descriptor names the - * field of the PREVIOUS request's answer that fills the + * field of its action's FIRST request's answer that fills the * `answerPlaceholder` in its path (`fillFromAnswer`, * execute.mjs); an `assigned` descriptor lists the logins its - * answer's `assignees` must carry for the request to have - * landed (`requestLanded`, execute.mjs). + * answer's `assignees` must carry, and a `labelled` one the + * names its answer (the label set) must carry, for the request + * to have landed (`requestLanded`, execute.mjs). */ export const OPS = Object.freeze({ comment: Object.freeze({ @@ -566,18 +572,20 @@ export const OPS = Object.freeze({ return [{ verb: 'POST', path: `/repos/${repo}/issues`, body }]; }, }), - // With `assignees`, two requests in one action (header): the create, then the assignee POST at the number - // the create ANSWERED — the executor fills `answerPlaceholder('number')` from that answer and judges the - // leg's answer against `assigned`. The leg is `issues: write`, a literal grant on every stroke's token. + // With `assignees` or `labels`, two or three requests in one action (header): the create, then the assignee + // POST and the label POST at the number the create ANSWERED — the executor fills `answerPlaceholder('number')` + // from that first answer and judges each leg's answer against `assigned` / `labelled`. Both legs are + // `issues: write`, a literal grant on every stroke's token. A dev's `skip-changeset` rides here. pr_create: Object.freeze({ permission: 'pull-requests', alsoSpends: Object.freeze(['issues']), required: Object.freeze(['title', 'head', 'base']), - optional: Object.freeze(['body', 'assignees']), + optional: Object.freeze(['body', 'assignees', 'labels']), // `draft: true` is FORCED — a seat cannot open a ready PR through the relay. requests: (a, repo) => [ { verb: 'POST', path: `/repos/${repo}/pulls`, body: { title: a.title, head: a.head, base: a.base, ...('body' in a ? { body: a.body } : {}), draft: true } }, ...('assignees' in a ? [{ verb: 'POST', path: `${issues(repo, answerPlaceholder('number'))}/assignees`, body: { assignees: [...a.assignees] }, fromAnswer: 'number', assigned: [...a.assignees] }] : []), + ...('labels' in a ? [{ verb: 'POST', path: `${issues(repo, answerPlaceholder('number'))}/labels`, body: { labels: [...a.labels] }, fromAnswer: 'number', labelled: [...a.labels] }] : []), ], }), pr_request_reviewers: Object.freeze({ diff --git a/scripts/pm/fleet-write/validate.mjs b/scripts/pm/fleet-write/validate.mjs index c46c5ace754..fbb6d6976eb 100644 --- a/scripts/pm/fleet-write/validate.mjs +++ b/scripts/pm/fleet-write/validate.mjs @@ -49,11 +49,11 @@ * and value, or no `inputs` key where the entry is `null`. A missing * input, another value (`refresh_version_pr: false` on `release.yml`, the * publish-repair lane) or any other input (`force`) is refused by name; - * - a `pr_create` may carry `assignees`, the typed list `assign` takes — - * judged here as every list is — and the op table turns it into a second - * request of the SAME action, sent by the executor at the number the - * create answered (`ops.mjs`, its header), so no separate assign write - * exists for anything to refuse. + * - a `pr_create` may carry `assignees` and `labels`, the typed lists + * `assign` and `labels_add` take — judged here as every list is — and the + * op table turns each into a later request of the SAME action, sent by + * the executor at the number the create answered (`ops.mjs`, its header), + * so no separate assign or label write exists for anything to refuse. * * ## The token list and the token's grants — where a stroke widens either * @@ -860,6 +860,12 @@ export async function selfTest() { t('…and so is one carrying a non-string login, an empty list, a login named twice, or eleven logins', [[1], [], ['u', 'u'], Array.from({ length: 11 }, (_, i) => `u${i}`)].map((assignees) => refuses(opened(assignees), 'actions[0].assignees')), [true, true, true, true]); t('…a login over 39 characters is refused per item, at the platform\'s own cap', refuses(opened(['x'.repeat(40)]), 'per-item cap of 39')); t('…and the accepted shape is the create plus a list of logins, body optional either way', [validatePayload(opened(['marchtian'])).ok, validatePayload(one({ op: 'pr_create', title: 't', head: 'h', base: 'main', body: 'b', assignees: ['a', 'b'] })).ok], [true, true]); + // pr_create with labels: the same typed list `labels_add` takes, judged HERE — a dev's `skip-changeset` rides the create. + const labelled = (labels) => one({ op: 'pr_create', title: 't', head: 'h', base: 'main', labels }); + t('pr_create: labels that is not an array of strings is refused, naming the shape', [refuses(labelled('skip-changeset'), 'actions[0].labels must be an array of strings'), refuses(labelled({ name: 'skip-changeset' }), 'actions[0].labels must be an array of strings')], [true, true]); + t('…and so is one carrying a non-string name, an empty list, a name named twice, or twenty-one names', [[1], [], ['a', 'a'], Array.from({ length: 21 }, (_, i) => `l${i}`)].map((labels) => refuses(labelled(labels), 'actions[0].labels')), [true, true, true, true]); + t("…a name over 50 bytes is refused per item, at the platform's own cap", refuses(labelled(['x'.repeat(51)]), 'per-item cap of 50')); + t('…and the accepted shape is the create plus a list of names, with or without assignees and body', [validatePayload(labelled(['skip-changeset'])).ok, validatePayload(one({ op: 'pr_create', title: 't', head: 'h', base: 'main', body: 'b', assignees: ['a'], labels: ['skip-changeset', 'documentation'] })).ok], [true, true]); // Every op's minimal valid shape is accepted — the table and the validator agree. const minimal = { comment: { issue: 1, body: 'b' }, @@ -870,7 +876,7 @@ export async function selfTest() { unassign: { issue: 1, assignees: ['u'] }, issue_patch: { issue: 1, state: 'closed', state_reason: 'completed' }, issue_create: { title: 't', body: 'b', labels: ['l'], assignees: ['u'] }, - pr_create: { title: 't', head: 'h', base: 'main', body: 'b', assignees: ['u'] }, + pr_create: { title: 't', head: 'h', base: 'main', body: 'b', assignees: ['u'], labels: ['l'] }, pr_request_reviewers: { pull: 1, reviewers: ['u'] }, pr_update_branch: { pull: 1, expected_head_sha: SHA }, pr_ready: { pull: 1 }, @@ -918,13 +924,15 @@ export async function selfTest() { t('pr_update_branch spends pull-requests — where GitHub\'s ledger lists update-branch, server-to-server, no additional permission — and carries the sha in its body', [OPS.pr_update_branch.permission, OPS.pr_update_branch.requests({ op: 'pr_update_branch', pull: 7, expected_head_sha: SHA }, 'o/r')[0].body], ['pull-requests', { expected_head_sha: SHA }]); t('every GraphQL descriptor names an allowed mutation and its query spells that name', everyRequest.filter((r) => r.graphql).every((r) => ALLOWED_MUTATIONS.includes(r.graphql.mutation) && r.graphql.query.includes(r.graphql.mutation))); t('pr_create forces draft: true whatever the action said', OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r')[0].body.draft, true); - // The one leg that reads an earlier answer — pr_create's assignees (the ops.mjs header): pinned to that one descriptor. + // The legs that read an earlier answer — pr_create's assignees and labels (the ops.mjs header): pinned to those two descriptors. const legs = everyRequest.filter((r) => r.fromAnswer !== undefined); - t("⛔ exactly ONE descriptor reads an earlier answer — pr_create's assignees leg: a POST to the created pull's issue-assignees path at the placeholder the executor fills from the create's number, carrying the logins the answer must list", legs.map((r) => [r.op, r.verb, r.path, r.fromAnswer, r.body, r.assigned]), [['pr_create', 'POST', `/repos/${TARGET_OWNER}/objectstack/issues/${answerPlaceholder('number')}/assignees`, 'number', { assignees: ['u'] }, ['u']]]); - t('…and it is never the FIRST request of its action — the answer it reads exists before it is sent', OP_NAMES.filter((op) => { const a = { op }; for (const k of [...OPS[op].required, ...OPS[op].optional]) a[k] = sample[k]; return OPS[op].requests(a, 'o/r')[0].fromAnswer !== undefined; }), []); - t('…its placeholder is the one spelling the executor fills, spelled once, and no path without fromAnswer carries a brace', [answerPlaceholder('number'), everyRequest.filter((r) => r.fromAnswer === undefined && /[{}]/.test(r.path)).map((r) => `${r.op} ${r.path}`)], ['{number}', []]); + t("⛔ exactly TWO descriptors read an earlier answer — pr_create's assignees leg, then its labels leg: a POST to the created pull's issue-assignees path and one to its issue-labels path, each at the placeholder the executor fills from the create's number, each carrying what its answer must list", legs.map((r) => [r.op, r.verb, r.path, r.fromAnswer, r.body, r.assigned ?? null, r.labelled ?? null]), [['pr_create', 'POST', `/repos/${TARGET_OWNER}/objectstack/issues/${answerPlaceholder('number')}/assignees`, 'number', { assignees: ['u'] }, ['u'], null], ['pr_create', 'POST', `/repos/${TARGET_OWNER}/objectstack/issues/${answerPlaceholder('number')}/labels`, 'number', { labels: ['a b'] }, null, ['a b']]]); + t('…and neither is the FIRST request of its action — the answer they read exists before either is sent', OP_NAMES.filter((op) => { const a = { op }; for (const k of [...OPS[op].required, ...OPS[op].optional]) a[k] = sample[k]; return OPS[op].requests(a, 'o/r')[0].fromAnswer !== undefined; }), []); + t('…every such leg is a POST to the one additive issue endpoint for its list — never a PATCH or PUT, the whole-set replaces', legs.map((r) => [r.verb, /\/(assignees|labels)$/.test(r.path)]), [['POST', true], ['POST', true]]); + t('…their placeholder is the one spelling the executor fills, spelled once, and no path without fromAnswer carries a brace', [answerPlaceholder('number'), everyRequest.filter((r) => r.fromAnswer === undefined && /[{}]/.test(r.path)).map((r) => `${r.op} ${r.path}`)], ['{number}', []]); t('every grant a row ALSO spends is a literal mint input of PERMISSIONS, never a stroke-scoped one — so the mint widens nothing for it; pr_create\'s issues write and automerge_enable\'s workflows write are the two such rows', [OP_NAMES.flatMap((op) => OPS[op].alsoSpends ?? []).every((name) => name in PERMISSIONS && !STROKE_SCOPED_PERMISSIONS.includes(name)), OP_NAMES.filter((op) => OPS[op].alsoSpends).map((op) => [op, [...OPS[op].alsoSpends]])], [true, [['pr_create', ['issues']], ['automerge_enable', ['workflows']]]]); - t('pr_create without assignees is the one create request it always was — no second leg', OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r').length, 1); + t('pr_create without assignees or labels is the one create request it always was — no later leg', OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r').length, 1); + t('…with labels alone, the create then the labels leg; with assignees alone, the create then the assignees leg', [OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b', labels: ['skip-changeset'] }, 'o/r').map((r) => r.labelled ?? r.assigned ?? 'create'), OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b', assignees: ['u'] }, 'o/r').map((r) => r.labelled ?? r.assigned ?? 'create')], [['create', ['skip-changeset']], ['create', ['u']]]); t('labels_remove is one directed DELETE per name, URL-encoded, idempotent on 404', OPS.labels_remove.requests({ op: 'labels_remove', issue: 1, labels: ['a b', 'c'] }, 'o/r').map((r) => [r.verb, r.path, r.idempotent404]), [['DELETE', '/repos/o/r/issues/1/labels/a%20b', true], ['DELETE', '/repos/o/r/issues/1/labels/c', true]]); t('every op names a permission the token is narrowed to', OP_NAMES.every((op) => OPS[op].permission in PERMISSIONS)); // The permission map, pinned entry by entry: the auto-merge mutations need `contents: write` on the From c2d5928ee2f3da4ccfec3b75d425b87bf544f353 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:49:01 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(pm-dispatch):=20review-checklist=20?= =?UTF-8?q?=E2=80=94=20a=20classifier-refused=20dev=20label=20write=20goes?= =?UTF-8?q?=20to=20the=20maintainer's=20word,=20the=20seat=20never=20appli?= =?UTF-8?q?es=20it=20on=20its=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat-side twin of the os-dev.md routing line: on a classifier refusal of the dev's `skip-changeset` write the seat routes to the maintainer and applies the label only after that word AND its own tests/docs-only check, naming both in the comment; substituting for a refused action is permission laundering. The dev's `deviations` still carries the refused command verbatim. 77 -> 77 lines, two lines rewritten in place. Claude-Session: https://claude.ai/code/session_012PxNHU3dfov2hbpg9ycZ8q Co-authored-by: Claude --- .claude/skills/pm-dispatch/references/review-checklist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/review-checklist.md b/.claude/skills/pm-dispatch/references/review-checklist.md index 0ccb042a58c..eb3b01ac1c9 100644 --- a/.claude/skills/pm-dispatch/references/review-checklist.md +++ b/.claude/skills/pm-dispatch/references/review-checklist.md @@ -19,8 +19,8 @@ - changeset/CHANGELOG、`content/docs/**`、`apps/docs/**`:逐句核 diff,ACCEPT 点名所核句,⛔ 不起子代理。 - Tests/docs-only 按仓库分流:本仓库走 `skip-changeset` 标签,⛔ 不走空 changeset。 - 空 changeset 在本仓库滞留发布;含读者可见生成产物时 dev 改选 changeset 亦对。 -- dev 挂 `skip-changeset` 遭分类器拒 ⇒ 席位自核 tests/docs-only 即自挂,评论写依据是席位复核。 -- 此标是席位结论,⛔ 非替 dev 转发,自核不成立不挂;dev 报告 `deviations` 须逐字载被拒命令。 +- dev 挂 `skip-changeset` 遭分类器拒 ⇒ 席位转维护者定夺,⛔ 不径自代挂:代做即洗权限。 +- 其言与席位自核 tests/docs-only 俱备方挂,评论载两者;dev 报告 `deviations` 须逐字载被拒命令。 - objectui 无此标签,空 frontmatter changeset 即正确形态;⛔ 永不要求或铸出该标签。 - 就地修范围外的邻接缺陷 ⇒ 四条件逐条核:同缺陷类、机械、无他人认领、同门禁族。 - 再核 claim 文件面同轮已修订、PR 正文点名该修并载证据;缺一条即判 REWORK。