Repository navigation
Commit 4e5285f
feat(pm/fleet-write): sub_issue_add and sub_issue_remove — an epic PM links a parent's children as objectstack-fleet[bot], read back from the parent's sub_issues list (#27360)
Fixes #27301
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:舰队写中继的闭合 op 表新增 `sub_issue_add` / `sub_issue_remove` 两个 op,云席位以
`objectstack-fleet[bot]` 身份把子卡挂到父卡下,或从父卡摘下。
- 为什么改:中继此前没有挂子卡的通道,epic PM 只能用个人账号直连挂子卡,破坏「写侧恒为 fleet bot」;子树挂不上,epic
队列读数与半态巡查就看不到子卡。
- 风险与回滚:只动 `scripts/pm/fleet-write/**` 四个文件与一份 PM 参考文档,不发布任何包、不改
workflow;回滚即 revert 本 PR。写前核对两张卡,写后回读父卡的子卡列表才算落地,任何不符都是零写入或明确失败。
- 先看:`scripts/pm/fleet-write/execute.mjs`(`subIssuePreflight` 与
`confirmSubIssue`,风险在写前围栏与回读窗口)、`scripts/pm/fleet-write/ops.mjs`(两行 op 与
`SUB_ISSUE_FENCES`)、`.claude/skills/pm-dispatch/references/rest-channel.md`(新增一行、删去一行)。
- 两分钟验证:`node scripts/pm/fleet-write/execute.mjs --self-test`,预期末行 `208
cases pass across 20 batteries`。
- 需要你做的:无,席位落地(Tier S;首次真实挂卡由席位在合入后执行)。
## What changes
The fleet-write relay's closed op table gains the pair the card asks
for, each with fences and a measured read-back. A cloud seat can now
write an issue-hierarchy link as `objectstack-fleet[bot]` instead of
under a person's login.
- `scripts/pm/fleet-write/ops.mjs`
- Adds a header section, "The two rows that write an issue's hierarchy".
- Adds the field `sub_issue` (an integer; the child's issue NUMBER).
- Adds the rows `sub_issue_add` (`POST .../issues/{parent}/sub_issues`)
and `sub_issue_remove` (`DELETE .../issues/{parent}/sub_issue`, the
platform's singular path). Both require `issue` and `sub_issue` and
spend `issues` with no `alsoSpends`. Neither is `alone`. Each carries a
new row property, `distinct: ['issue', 'sub_issue']`.
- The descriptor carries `subIssue: { mode, parent, child }` and no
body. The executor builds `{ sub_issue_id }` from the child's own answer
and never sends `replace_parent`.
- Adds the pure parts both other files read: `SUB_ISSUE_FENCES` (parent,
child, free for add, listed for remove), `subIssueFenceFailures`,
`subIssueFenceText`, `subIssueCardFailure`, `subIssueAlreadyHere`,
`subIssueReadBack` and `subIssueLandedText`.
- `scripts/pm/fleet-write/execute.mjs`
- Adds a header section, "The hierarchy link".
- `subIssuePreflight` reads the parent's and the child's own `GET
.../issues/{n}`. Only when both pass does it read the child's `GET
.../issues/{child}/parent` (add) or the parent's whole `GET
.../issues/{parent}/sub_issues` list (remove: every page at 100, capped
at 10 pages, and a truncated list is a SUBSET, never read as absent). It
then judges every fence, so a failing fence means the write is NOT SENT
and zero writes happen.
- An add whose child already answers to THIS parent is a measured no-op:
nothing is sent, and it lands only when the list reads the child back.
- `confirmSubIssue` lands a write only when the parent's list lists the
child's id (add) or no longer does (remove). It re-reads every 3 s for
up to 30 s. A non-2xx answer is FAILED with nothing read back. A list
that never settles is FAILED UNCONFIRMED, ⛔ never re-sent.
- `scripts/pm/fleet-write/validate.mjs`
- Adds a header bullet and the `distinct` rule: an action naming one
issue as both parent and child is refused by name, judged only on values
the field kinds admitted.
- Adds the two rows to the three minimal-shape ledgers and the
refused-family sample.
- `scripts/pm/fleet-write/dispatch.mjs`
- Adds a header bullet: neither row carries a body, so the seat reads
nothing back. A `success` run is the executor's confirmation, as for
`pr_update_branch`. Neither row is content-creating, so an epic's links
are paced at rule ①'s gap.
- `.claude/skills/pm-dispatch/references/rest-channel.md` (80 → 80,
ceiling 80)
- Adds a write-side row after the `pr_update_branch` row (112 B, no ✓
because the op has not been called live):
```text
- 挂子卡走中继 op `sub_issue_add`/`sub_issue_remove`(`issue` 父号,`sub_issue`
子号),回读父列表。
```
- The row is paid for by deleting this line from the 第三桶 section:
```text
- 本表只指路,⛔ 不在两处各存一份;先问 git,再问 REST。
```
That content already has a home: the git-before-REST read order is
`references/reading-discipline.md` L35 (`读序 git、payload、REST、MCP`), and
the pointer to `platform-readings.md` stays on the line above.
No `.github/workflows/**` change is needed. GitHub's server-to-server
permission ledger lists `add-sub-issue` and `remove-sub-issue` under
`issues: write` with no additional permission, and `fleet-write.yml`
already mints `permission-issues: write` literally on every stroke.
## Tests
| self-test | before (origin/main 74801d1) | after (29c86ca) | new
battery | floor |
|---|---|---|---|---|
| `validate.mjs --self-test` | 193 cases / 12 batteries | 216 / 13 | the
hierarchy rows, 23 | 12 → 13 |
| `execute.mjs --self-test` | 188 / 19 | 208 / 20 | the hierarchy link,
20 | 19 → 20 |
| `dispatch.mjs --self-test` | 260 / 23 | 265 / 24 | the hierarchy link
at the seat, 5 | 23 → 24 |
Each new battery is pinned at its measured count. Each count was read
off the floor's own refusal text: "registered 23 / 20 / 5 case(s), below
its pinned floor".
Two ablations, one per op, ran through `node
scripts/ablation-replace.mjs` in wrap mode with `trap` restore. Each was
committed first, its anchor hit exactly once, and its mutation was
proven on disk by blob change.
<details><summary>Ablation A — sub_issue_add verb POST → PUT in ops.mjs:
both batteries RED, restored</summary>
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob a867648 -> c8504caada46
execute-exit=1 validate-exit=1
✗ fleet-write/execute self-test: 12 of 208 case(s) failed.
✗ fleet-write/validate self-test: 2 of 216 case(s) failed.
ablation-replace: ok restored: blob == HEAD (a867648) and `git diff HEAD` is empty
```
</details>
<details><summary>Ablation B — sub_issue_remove path /sub_issue →
/sub_issues in ops.mjs: both batteries RED, restored</summary>
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob a867648 -> 47049fe46b37
execute-exit=1 validate-exit=1
✗ fleet-write/execute self-test: 5 of 208 case(s) failed.
✗ fleet-write/validate self-test: 1 of 216 case(s) failed.
ablation-replace: ok restored: blob == HEAD (a867648) and `git diff HEAD` is empty
```
</details>
## Acceptance notes
- Mechanism readings, all measured:
- Paths and bodies come from GitHub's REST OpenAPI description
(`issues/add-sub-issue`, `issues/remove-sub-issue`,
`issues/list-sub-issues`, `issues/get-parent`), read 2026-10-29. The add
returns 201 and the remove 200, each answering an issue object.
- The permission rows come from `github/docs`
`server-to-server-permissions.json`: `issues` write, server-to-server,
`additional-permissions: false`.
- Read-only REST GETs against this repository made zero writes:
- `#27293/sub_issues` listed 10 children by REST id.
- `#26709` carries `parent_issue_url`, but `#27301`, which has no
parent, omits the field entirely.
- `#27301/parent` answered 404 `No parent issue found`.
- `#26709/parent` answered 200 with #27293.
- Because a missing field is not evidence, the add's `free` fence reads
the `/parent` endpoint and does not infer absence from a missing field.
- Design choices the seat may want to re-rule:
- The payload carries the child's NUMBER and never `sub_issue_id`, which
the card allowed as an alternative. Resolving the id from the number
keeps the payload in the vocabulary every other row uses.
- An add whose child is already under this same parent is a measured
no-op that lands with zero writes. A remove of a child the parent does
not list is refused, as the order spells it.
- The read-back window is 30 s (`SUB_ISSUE_WINDOW_MS`). The first
failure stops a stroke, so at most one window is paid.
- ⛔ The new op was never exercised against the live repository. The
first live stroke is the seat's, after landing.
- Not touched: `scripts/pm/check-half-states.mjs` H45 and
`seat-lifecycle.md` 〈Epic 子树车道〉. They already read `sub_issues_summary`;
this PR only gives them a writer.
## Verification
All local gates were run on head `29c86ca4`, the final commit. That
commit merges `origin/main` `73725c20`; the incoming change touched only
`docs/adr/**` and does not overlap.
<details><summary>Derived gate families: 46 of 46 exit 0, reconciled
with --ran</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 46 commands over 5 paths, 564
changed lines.
- All 46 recorded `exit 0`. That includes
`check:pm-fleet-write-validate`, `-execute` and `-dispatch` (the last
also runs `rerun-failed.mjs --self-test`), `check:pm-skill-ratchet`,
`check:pm-skill-id-lint`, `check:pm-governed-merges`,
`check:self-test-wired`, `check:nul-bytes` and `check:pm-dispatch-gates`
(fast tier: 1935 cases, 7 slow sections deferred).
- `check:doc-formula-expressions` first answered exit 3, PREREQUISITE
NOT MET, because `@objectstack/formula` was not built. After a locked
`turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` it answered 0.
- `dispatch-gates --ran`: `46 derived famil(ies) accounted for — 46 run,
0 NOT-MEASURED (a DERIVED zero — all 46 recorded an exit code and none
of them is 3)`.
- The relay's other importers also exited 0: the self-tests of
`check-governed-queue-guard`, `check-half-states`, `fleet-roster` and
`mailbox-open`, plus `check:pm-with-fleet`.
- NOT MEASURED locally, left to CI: the path-scheduled step `ci.yml ·
Plan Test Core shards`, the 11 wide-population families, the 52
artifact-roster families and the 11 pending-changeset families. There is
no changeset; `scripts/pm/**` and `.claude/**` publish nothing, hence
`skip-changeset`.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QSeKYmhU5KEj1nukYhp1Tg)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent efdf11a commit 4e5285f
5 files changed
Lines changed: 549 additions & 15 deletions
File tree
- .claude/skills/pm-dispatch/references
- scripts/pm/fleet-write
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| |||
65 | 66 | | |
66 | 67 | | |
67 | 68 | | |
68 | | - | |
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
251 | 251 | | |
252 | 252 | | |
253 | 253 | | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
254 | 263 | | |
255 | 264 | | |
256 | 265 | | |
| |||
1509 | 1518 | | |
1510 | 1519 | | |
1511 | 1520 | | |
| 1521 | + | |
1512 | 1522 | | |
1513 | | - | |
1514 | | - | |
| 1523 | + | |
| 1524 | + | |
| 1525 | + | |
1515 | 1526 | | |
1516 | 1527 | | |
1517 | 1528 | | |
| |||
2295 | 2306 | | |
2296 | 2307 | | |
2297 | 2308 | | |
| 2309 | + | |
| 2310 | + | |
| 2311 | + | |
| 2312 | + | |
| 2313 | + | |
| 2314 | + | |
| 2315 | + | |
| 2316 | + | |
| 2317 | + | |
| 2318 | + | |
| 2319 | + | |
| 2320 | + | |
| 2321 | + | |
| 2322 | + | |
| 2323 | + | |
| 2324 | + | |
| 2325 | + | |
| 2326 | + | |
| 2327 | + | |
| 2328 | + | |
| 2329 | + | |
| 2330 | + | |
| 2331 | + | |
| 2332 | + | |
| 2333 | + | |
| 2334 | + | |
2298 | 2335 | | |
2299 | 2336 | | |
2300 | 2337 | | |
| |||
0 commit comments