Skip to content

Commit f9e63d6

Browse files
committed
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 <noreply@anthropic.com>
1 parent bd4276a commit f9e63d6

4 files changed

Lines changed: 128 additions & 72 deletions

File tree

‎.claude/agents/os-dev.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ model: opus
5858
- 写工具自读所需变量;⛔ 永不打印环境变量的值,转录是 AGENTS.md 禁落令牌的日志行。
5959
- 拼法不限于 `env`、`printenv`、`set`、`export -p`、`echo $VAR`、`cat /proc/*/environ`。
6060
- 判存在只用不印值形式 `[ -n "${X+x}" ] && echo set`;已印出 ⇒ `deviations` 只点名变量名。
61-
- 写预算四笔:`git push`、`pr_create`(draft,含 assignee)、`label-write`(仅标签)、`os-dev-report` 评论。
61+
- 写预算三笔:`git push`、`pr_create`(draft,含 assignee 与 labels)、`os-dev-report` 评论。
6262
- 卡与线程只走 payload 档(公开仓单卡网页内嵌 JSON,拼写住 platform-readings)或单卡 REST 读。
6363
- 三类发现进报告交席位代立,不 `POST /issues`、⛔ 不静默弃报;PM 去重读数当既有事实用。
6464
- PR 正文 dev 只写一次,在开 PR 那一笔,⛔ 不 `PATCH`;事后要改的报告点名改法,席位代写。
@@ -300,13 +300,13 @@ model: opus
300300
- 快速通道:`docs/adr/**` · `.claude/**` · `scripts/pm/**` · 仓根配置 · 私有包 · 注释,不发布。
301301
- 其余实测:构建后 grep `files[]` 所列路径找符号,带正控;符号零命中、正控命中 ⇒ 不发布。
302302
- PR 改到已有的 `.changeset/*.md`:⛔ 永不打 `skip-changeset`,门禁红着是对的,确认走 PR 文字。
303-
- objectstack:标签是真实机制,打标签是你的默认步骤,PR 一开出就打;派发词可收窄或禁写。
303+
- objectstack:`skip-changeset` 以 `pr_create` 的 `labels` 同笔申报;后发现的标签才走 `label-write.mjs`。
304304
- 范围 = 派发词点名的标签 + 上文判据下的 `skip-changeset`;禁写或交集为空 ⇒ 零写并报告。
305-
- 写恒经 `scripts/pm/label-write.mjs`:取现集、加法 POST、回读比 union、缺者重挂一次并报告。
305+
- 该腿的回读是中继回报的行:答案须列出每个送出的标签,缺者该笔 FAILED、不发后续动作。
306306
- 读回只检测剥除防不了(size-labeler 整组 PUT),门标签被剥恰成绿灯;加法写同样必要不充分。
307307
- 关此步骤的是读回不是写入;读回只验写落了,验不出有门在读:幻影门标签读回照样成功。
308308
- 被拒 ⇒ 报端点与状态码、席位代挂,⛔ 不报 `blocked`、不走 MCP;收尾再读一次。
309-
- 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位代做;⛔ 不换路重发(curl/MCP/手工)。
309+
- 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位只转维护者定夺;⛔ 不换路重发。
310310
- objectui:路径标签归 `labeler.yml`,逐 push 同步;无门禁读你打的标签 ⇒ 派发词未点名即零写。
311311
- objectui 的 `skip-changeset` 零读者;空 frontmatter changeset 即声明,门禁判定行为准,⛔ 永不施加。
312312
- 报告在本地验证走完时交付,CI 收敛等待归 PM 不归你:⛔ 不为等 CI 结论推迟报告。

‎scripts/pm/fleet-write/execute.mjs‎

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -95,23 +95,30 @@
9595
* Any other status is FAILED as every REST 4xx is. The PUT is paced like
9696
* every other write verb.
9797
*
98-
* ## The assignees leg — `pr_create` with `assignees`, the one request that reads an earlier answer
98+
* ## The assignees and labels legs — `pr_create` with `assignees` / `labels`, the requests that read an earlier answer
9999
*
100-
* The op table spells that leg's path with a placeholder and `fromAnswer:
100+
* The op table spells each leg's path with a placeholder and `fromAnswer:
101101
* 'number'` (`ops.mjs`, its header). Within one action the requests run in
102-
* order and each answer is kept; a descriptor carrying `fromAnswer` is
103-
* filled from the PREVIOUS request's answer — `fillFromAnswer`: the field
104-
* must be a positive integer, and the path is the placeholder replaced by
105-
* it, so the pull the assignee lands on is the one the platform answered,
106-
* ⛔ never one the request named. An answer without it is a FAILED action
107-
* with the leg NOT SENT — its row says so, status `not sent` — and the
108-
* stroke stops there as on any failure. The leg's landing is judged by its
109-
* answer's `assignees` against `assigned`, the logins sent (`requestLanded`):
110-
* GitHub documents a login the caller cannot apply as "silently ignored",
111-
* the 201 still answered, so an answer listing fewer than sent is a FAILED
112-
* action naming the missing login, never a quiet partial — and the row
113-
* prints the assignees as the platform lists them (`resultOf`). The leg is
114-
* paced like every other write verb.
102+
* order and the FIRST answer — the create's — is kept; a descriptor carrying
103+
* `fromAnswer` is filled from that answer, ⛔ never from a later leg's
104+
* (the assignee POST answers the issue object, which happens to carry a
105+
* number, and the label POST answers an array, which carries none; neither
106+
* is the create) — `fillFromAnswer`: the field must be a positive integer,
107+
* and the path is the placeholder replaced by it, so the pull the assignee
108+
* or label lands on is the one the platform answered, ⛔ never one the
109+
* request named. An answer without it is a FAILED action with the leg NOT
110+
* SENT — its row says so, status `not sent` — and the stroke stops there as
111+
* on any failure. Each leg's landing is judged by its answer against what
112+
* was sent (`requestLanded`): the assignee leg's `assignees` against
113+
* `assigned` — GitHub documents a login the caller cannot apply as "silently
114+
* ignored", the 201 still answered; the label leg's answer, the pull's whole
115+
* label set, against `labelled` — a name sent and not listed did not land.
116+
* An answer listing fewer than sent is a FAILED action naming the missing
117+
* login or name, never a quiet partial; MORE than sent is not judged (the
118+
* Auto Label workflow writes `documentation` and `size/*` on open, additively,
119+
* so both sets land). The row prints the assignees, or the labels, as the
120+
* platform lists them (`resultOf`). Both legs are paced like every other
121+
* write verb.
115122
*
116123
* ## The step summary — what a person reads back
117124
*
@@ -246,6 +253,14 @@ export function requestLanded(req, { status, json } = {}) {
246253
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)` };
247254
}
248255
}
256+
// The labels leg (header): the answer is the pull's whole label set; a name sent and not listed did not land.
257+
if (Array.isArray(req.labelled)) {
258+
const listed = labelNames(json);
259+
const missing = req.labelled.filter((name) => !listed.some((l) => l.toLowerCase() === name.toLowerCase()));
260+
if (missing.length) {
261+
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)` };
262+
}
263+
}
249264
return { ok: true, why: '' };
250265
}
251266
if (status === 404 && req.idempotent404) return { ok: true, why: 'already absent', idempotent: true };
@@ -257,11 +272,16 @@ export function assigneeLogins(json) {
257272
return Array.isArray(json?.assignees) ? json.assignees.map((u) => (typeof u?.login === 'string' ? u.login : null)).filter(Boolean) : [];
258273
}
259274

275+
/** The names a labels answer (an array of label objects) lists, in the platform's order; `[]` when it lists none. Pure. */
276+
export function labelNames(json) {
277+
return Array.isArray(json) ? json.map((l) => (typeof l?.name === 'string' ? l.name : null)).filter(Boolean) : [];
278+
}
279+
260280
/**
261-
* A descriptor carrying `fromAnswer` filled from the PREVIOUS request's answer
262-
* (header): `{ req }` with the placeholder replaced by the positive integer
263-
* that answer carries at the field, or `{ fail }` — the sentence the row
264-
* prints — when no answer carries it. Pure; never throws.
281+
* A descriptor carrying `fromAnswer` filled from its action's FIRST request's
282+
* answer — the create's (header): `{ req }` with the placeholder replaced by
283+
* the positive integer that answer carries at the field, or `{ fail }` — the
284+
* sentence the row prints — when no answer carries it. Pure; never throws.
265285
*/
266286
export function fillFromAnswer(req, prev) {
267287
const field = req.fromAnswer;
@@ -350,7 +370,8 @@ export function resultOf(req, json, status = null) {
350370
if (!pr) return 'ok';
351371
return pullStateText(pr);
352372
}
353-
if (Array.isArray(json)) return `${json.length} label(s) now on the issue`;
373+
// The labels leg's row prints the labels as the platform lists them (header); a plain labels_add is counted.
374+
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`;
354375
if (!json || typeof json !== 'object') return 'ok';
355376
const bits = [];
356377
if (Number.isInteger(json.number)) bits.push(`#${json.number}`);
@@ -555,10 +576,10 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF
555576
for (let i = 0; i < payload.actions.length && stoppedAt === null; i++) {
556577
const action = payload.actions[i];
557578
const requests = OPS[action.op].requests(action, payload.repo);
558-
let prev = null;
579+
let opener = null;
559580
for (const spelled of requests) {
560-
// A leg that reads an earlier answer (header) is filled from the previous request's answer, or NOT SENT.
561-
const filled = spelled.fromAnswer === undefined ? { req: spelled } : fillFromAnswer(spelled, prev);
581+
// A leg that reads an earlier answer (header) is filled from the action's FIRST answer — the create's — or NOT SENT.
582+
const filled = spelled.fromAnswer === undefined ? { req: spelled } : fillFromAnswer(spelled, opener);
562583
if (filled.fail) {
563584
const why = scrub(filled.fail, [token]);
564585
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
581602
}
582603
// The GraphQL leg is spelled with its literal verb: every mutation is a POST to one path.
583604
const r = req.graphql ? await rest(api, '/graphql', { method: 'POST', body }, t) : await rest(api, req.path, { method: req.verb, body }, t);
584-
prev = r;
605+
if (opener === null) opener = r;
585606
// The base sync is confirmed by MEASURE (header): the head polled off the expected sha, or a 422 judged by the compare.
586607
const landed = req.updateBranch ? await confirmUpdateBranch(req, r, payload, api, t, ub) : requestLanded(req, r);
587608
const call = req.graphql ? `POST /graphql ${req.graphql.mutation}` : r.call;
@@ -624,7 +645,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
624645
'the GraphQL ops: the node id first, then the mutation; an errors array is a failure': 5,
625646
'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,
626647
"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,
627-
"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,
648+
"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,
628649
'the summary: request, sender and role, session, target, one row per request': 5,
629650
'redaction: the token reaches no summary line, log line or error': 3,
630651
'the wiring: both halves around every write verb, on the roster': 4,
@@ -890,7 +911,7 @@ export async function selfTest() {
890911
}
891912

892913
// ── the assignees leg ───────────────────────────────────────────────────
893-
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");
914+
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");
894915
{
895916
const PULLS = `POST /repos/${REPO}/pulls`;
896917
const ASSIGN = (n) => `POST /repos/${REPO}/issues/${n}/assignees`;
@@ -916,6 +937,25 @@ export async function selfTest() {
916937
const refused = await run(create(['marchtian']), { ...allowed, [PULLS]: opened, [ASSIGN(22700)]: { status: 403, json: { message: 'Resource not accessible by integration' } } });
917938
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]);
918939
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]);
940+
941+
// The labels leg — the same shape, a dev's `skip-changeset` riding the create.
942+
const LABEL = (n) => `POST /repos/${REPO}/issues/${n}/labels`;
943+
const createWith = (fields, extra = []) => base([{ op: 'pr_create', title: 'T', head: 'claude/issue-1-x', base: 'main', body: 'B', ...fields }, ...extra]);
944+
const labelsOn = (names) => ({ status: 200, json: names.map((name) => ({ name })) });
945+
const both = OPS.pr_create.requests({ op: 'pr_create', title: 'T', head: 'h', base: 'main', assignees: ['marchtian'], labels: ['skip-changeset'] }, REPO);
946+
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']]]);
947+
// The assignee answer here carries a DIFFERENT number: the label leg must still land on the pull the CREATE answered.
948+
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 });
949+
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'] }]]]);
950+
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' });
951+
const labelsOnly = await run(createWith({ labels: ['skip-changeset'] }), { ...allowed, [PULLS]: opened, [LABEL(22700)]: labelsOn(['skip-changeset']) }, { file: paceFile });
952+
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)]]);
953+
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 } } });
954+
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);
955+
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);
956+
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']);
957+
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 } } });
958+
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]]);
919959
}
920960

921961
// ── the transfer ────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)