Repository navigation
feat(delivery): read more than one repository on the delivery board (#1265 step 4) - #1297
Conversation
…1265 step 4) `delivery.repos` names a list of repositories; the older `delivery.repo` string still works and is read as a list of one. Every name resolves to its own `{ repo, source }`, so two repositories never share one source line, and the host keeps one snapshot per repository. The board draws one block per repository, each with its own state, and a repository that cannot be read leaves the others on a `partial` body instead of emptying the page. One repository keeps today's DOM: no block wrapper, the header still names it. The visible columns now come from a single entry point, `stagesOf(view)`, so a snapshot that names its own columns decides what is drawn (#1272 can change that source without touching the renderer). Refs #1265, fixes #1293
|
Verdict: INC at e9ad1c6 What holds at this tipThe five knives are real, each cut by me on this tree and restored with
The 11 suites are 131/131 here; Blocking: on a
|
`combineRepos` merged `metrics` with a filter that deleted every repository which had not read, so the surviving metrics were the readable half's and the header printed them under "2 repositories" — a number that looks like the board's total but counted one repository. Hand the whole entry list to `mergeMetrics` so a missing entry makes the board's `metrics` `null`; the view already prints `—` for an unknown number. Pin that, and pin the related "unknown open count is `—`, a real zero is `0`" rule so it cannot drift back to `0`. Part of #1265 (step 4, multi-repository). Fixes #1293.
|
Verdict: PASS at e3ded8e The INC at e9ad1c6 (6416122171) is closed at this tip
What I ran at this tip (myself, not from the body)
Not run here
Non-blocking, follow-ups only
Ran: |
…hinkFlowLab#1252) (ThinkFlowLab#1289) fix(board): archive finished task cards and complete the overall card (ThinkFlowLab#1252) Written-by: Dev-1 Owner: Dev-1 Reviewer: Reviewer Fixes ThinkFlowLab#1252 Tip SHA: `5b832b91f8ebdb1b7de782e87d40f35b24f6549d` ## What changed - `core/src/host/tasks.js`: the `tasks` read lists active work only — a card that is `accepted`, `cancelled` or `failed` leaves the list a board polls. `{ archive: true }` is the same read including those rows; `{ archive: true, limit: N }` returns the newest N **finished** rows by `updatedAt` (tiebreak `createdAt`) and no active rows, so the board's 15 s poll carries the slice and not the history while the default read keeps carrying the active work. `limit` is a number or nothing: a string, boolean or object is refused (`bot/invalid`) instead of coerced. A read **without** `limit` still returns every settled row on purpose, because it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row) — an id outside the newest page must stay readable. - The same file: new `settleOverall(child)` completes an overall group card when every child has ended, through the same `transition`/`applyColumn` write a person's move uses. It completes `accepted` (with `progress: 100`) when each child ended `accepted` or `cancelled`, and `failed` when any child ended `failed` — a failed part is not hidden behind a green overall card. It is called where a move lands on `accepted` (`applyColumn`), from `task-accept-result`, from `task-cancel`, and on the two other doors a child ends through — `taskBotDeleted` (deleting a Bot fails its open cards) and `taskDecided` (an owner's reject cancels the card that waited on the approval) — so neither path can leave the overall card `working` behind a child that already ended. An overall card already `blocked` or `result-ready` is left to its owner. - `core/src/client/kanban.js`: the board keeps an `archive` list from its second read and sorts it newest first by `updatedAt` (tiebreak `createdAt`) **before** cutting, renders `boardTasksFor(tasks, archive)` — active work plus the twenty newest finished cards — and moves a finished card off the active list at once in `replace`, before the next poll. A **Bot** board names its Bot in that read (`tasks { botId, archive: true, limit: ARCHIVE_SHOWN }`): the host spends the page's budget before the board can filter by `ownerBotId`, so an unfiltered page lets another Bot's newer cards empty this Done column. The default read stays unfiltered on purpose — it is active work, a superset the board filters locally without losing a card. Ordering by `createdAt` would drop exactly the card a member just finished, which has an old `createdAt` and a fresh `updatedAt`. - `core/src/client/inbox.js`: ThinkFlowLab#1252 made the default read active-only, but the Inbox is a second consumer that classifies finished and failed cards (`inboxModel`: `failed` → Needs you, `accepted`/`cancelled` → Done) and counts them in `needsCount`. The one shared `useTasks` hook behind the Inbox row and the dialog now asks `tasks { archive: true }` — the uncapped list — and publishes that, so those cards reach the badge and both groups again. The board's bounded second read is untouched. - `docs/subsystems/core-client.md`: the `kanban.js` row said the cap twice in one line (once for the read, once for the column); that is now one sentence that also says a Bot board names its Bot, and the archive read without `limit` staying uncapped is said once. ## Red first All of them were written before the fix and run red on the unfixed tree (`node --import ./test/harness-resolve.mjs --test --test-name-pattern="1252" test/bot-tasks.test.js`). First round, the original ThinkFlowLab#1252 behaviour: ``` a done card leaves the room board by default true !== false expected: false actual: true ``` ``` the overall card completes with its last child 'open' !== 'accepted' expected: 'accepted' actual: 'open' ``` Second round, after Security's INC on the previous tip: the failed child, the unbounded payload, and the oldest-first slice. ``` a failed child makes the overall card fail, not pass + 'accepted' - 'failed' ``` ``` the requested limit bounds the payload 200 !== 20 expected: 20 actual: 200 ``` ``` the column starts at the newest finished card + 'task-done-19' - 'task-done-36' ``` Third round, Security's second INC: a busy room spent the whole budget on active rows. ``` the bounded archive carries finished rows only false !== true expected: true actual: false ``` Fourth round, Reviewer2's INC on the previous tip: the two other doors a child ends through, and the Bot-scoped archive page. Each knife was cut on the `5385491f` tree, the file restored from a copy, and the pair re-run green. Knife 1 — delete `settleOverall(task)` from `taskBotDeleted` (`core/src/host/tasks.js`), which reddens `ThinkFlowLab#1252: deleting a child's Bot fails the overall card, read back from the archive`: ``` a failed child closes the overall card as failed 'open' !== 'failed' expected: 'failed' actual: 'open' ``` Knife 2 — put the Bot board's archive read back to the unfiltered `query` (`core/src/client/kanban.js`), which reddens `ThinkFlowLab#1252: a Bot board asks the host for that Bot's archive, not the whole scope`: ``` the archive read names the Bot + actual - expected { archive: true, - botId: 'a', limit: 20 } ``` Restored: `bot-tasks.test.js` + `kanban.test.js` `20/20` green again. Fifth round, Reviewer2's INC at `5385491f`: the Inbox is a second consumer of `tasks`, and the active-only default cut its finished and failed cards off. The drive posts `{ archive: true }` and asserts the settled rows reach the badge and the two groups; on the unfixed tree the read posted **no body at all**, so the fake host answered the active-only default and every assertion fell over: ``` the Inbox read asks for the settled rows expected: 1 actual: 0 ``` The same run, measured before the assertions: `posted null`, badge `"4"` (the base count, unchanged), Done card `null`, Needs-you card `null`. ## Reviewer finding `6415648677` (`Verdict: INC at 86e9795`) — answered at this tip - **Item 1 — two terminal paths ended a child without settling the overall card.** `settleOverall(task)` now runs directly after the terminal `transition` in both `rt.taskDecided` branches (the waiting-input branch and the blocked branch) and in `rt.taskBotDeleted`, with the `continue` / `return save()` kept and no second gate added. Proven by `ThinkFlowLab#1252: deleting a child's Bot fails the overall card, read back from the archive` — child `failed`, parent `failed`, `columnOf` done, and the progress the card already carried (the failed branch only calls `transition`; `progress: 100` is set on the accepted branch alone) — and by `ThinkFlowLab#1252: a rejected approval cancels the child and completes the overall card, read back` — child `cancelled`, parent `accepted`, `progress 100`, `columnOf` done. Both read the parent back through `tasks { archive: true }` / `{ roomId, archive: true }`, not through a return value. - **Item 2 — the Bot-scoped page was cut before the `botId` filter.** A Bot board now sends `archiveQuery = { botId: scope.botId }` with the archive read: `{ botId, archive: true, limit: ARCHIVE_SHOWN }`; the group board keeps `roomId`, and the default read stays unfiltered. Proven by `ThinkFlowLab#1252: a Bot-scoped archive page is cut after the Bot filter` (the Bot-scoped host page carries its own card while the unfiltered page does not) and by the client drive that captures the posted body as exactly `{ botId: 'a', archive: true, limit: ARCHIVE_SHOWN }`. - **Non-blocking, docs.** The duplicated cap sentence in `docs/subsystems/core-client.md` is collapsed to the sentence that states the behaviour; the literal call syntax is gone. ## Reviewer finding `6416140014` (`Verdict: INC at 5385491`) — answered at this tip - **Blocking — the Inbox stopped receiving finished and failed cards.** The chain at `5385491f`: `core/src/client/inbox.js` `useTasks` called `actions?.tasks?.()` with no body, `core/src/client/index.js` sent it as `{}`, and `core/src/host/tasks.js` answered the active-only default (`archived ? rows : rows.filter(openTask)`), while `inboxModel` classifies `failed` → Needs you and `accepted`/`cancelled` → Done and `needsCount` counts them. Fixed in the one shared hook: `useTasks` now sends `{ archive: true }` and publishes the list it gets; the board's bounded second read is unchanged. Red at `5385491f` is quoted under *Red first*, fifth round (`expected: 1, actual: 0` — no body posted; badge unchanged at the snapshot count; both card lookups `null`). Green at this tip: the same test passes, `core/test/inbox.test.js` **27/27**, and the assertion is the behaviour — `failed` lands in Needs you, `accepted` in Done, and the row badge counts the failed card. - **Non-blocking — the bounded page is `settled`, the Done column keeps only `accepted`.** Recorded as a decided residual under *Decisions recorded, not omissions* below: newer `cancelled`/`failed` rows can spend a scope's page budget and push an older Done card out of the twenty. Not changed at this tip, and the reason is written down there. - **Non-blocking — the Bot board's default read is still unfiltered client-side.** That read is the **active** list: unfiltered is a superset of the Bot's own rows and the board filters it in memory, so no card can be lost — a host-side cut is what made the archive read need `botId`, and that one sends it. - **Non-blocking — the stale base line.** The old sentence naming a `main` base that was only true at claim time is gone; the base history below is the real path to this tip. Advisory rounds: none at this tip yet — Chief freezes before a PASS request. Superseded as evidence and kept as record: `6415907830` (Test) and `6415965572` (Security) at `5385491f`; `6415574602` and `6415627981` at `86e9795`; `6415419164` at `bdf867b2`; `6415205741` and `6415207539` at `150aeaa`. ## Why these files, and the base `main` moved several times while this was built: the branch was cut at `99b1fab`, hand-re-applied onto ThinkFlowLab#1276's new card model (`taskCard` / `BoardCardFrame`), then rebased onto `b4e1faa` (ThinkFlowLab#1284, which rewrote `docs/subsystems/core-host.md`, a file in this diff), `63f81f0` (ThinkFlowLab#1278), `ef5dae42` (ThinkFlowLab#1291) and now **`e37f1f83`** (ThinkFlowLab#1296 and ThinkFlowLab#1297, both landed after `ef5dae42`). The tip is therefore the merged tree: current `main` + this diff. `ThinkFlowLab#1297`'s delivery-board test in `core/test/kanban.test.js` was kept alongside this board's tests — the conflict was resolved by keeping both sides — and the board text still sits in `### Bot task board (ThinkFlowLab#1058)`, with no contradiction of ThinkFlowLab#1267's mid-turn-line subsection. At this tip the pull request is **19 files, +1007/−104, one commit** on base `e37f1f83`. - `core/src/host/tasks.js` — the read contract, the bounded archive read, the overall-card completion, and the settle on every door a child ends through. - `core/src/client/kanban.js` — the archive read (Bot-scoped on a Bot board), newest-first ordering, the twenty-card column. - `core/src/client/inbox.js` — the one shared task load asks for the archive, so the Inbox keeps its Done and Needs-you cards. - `core/client.js` — generated by `npm run build` in `core/`, never edited by hand. - `core/test/bot-tasks.test.js` — the eight ThinkFlowLab#1252 host cases (active-only board and explicit archive; last child completes the overall card; a failed child fails it; the requested archive bound with the unbounded read still seeing every row; a busy room's bounded read carrying finished rows only, with the `limit` type refusals; a deleted Bot failing the overall card; a rejected approval cancelling the child and completing the overall card; a Bot-scoped page cut after the Bot filter) plus one compat read: the post-restart read of a finished card now asks for the archive. This is where the active-only/archive case lives, not `core/test/tasks.test.js`. - `core/test/inbox.test.js` — the Inbox drive: the read posts `{ archive: true }`, a `failed` card lands in Needs you, an `accepted` card lands in Done, and the row badge counts the failed card. The shared harness keeps its no-op-effect stub for every other case; only this drive runs its effect. - `core/test/tasks.test.js` — only the `statusOf` helper changes: it reads `{ archive: true }` (no limit) because it looks tasks up by id and some are finished. - `core/test/kanban.test.js` — the client fixture: 37 finished cards give a literal twenty-card column starting at the newest, the oldest is outside the slice, a card with an old `createdAt` and a fresh `updatedAt` leads the column, and a driven load asserts the posted body (Bot board `botId`, group `roomId`, default read unfiltered). - `docs/subsystems/core-host.md` — the read contract with and without `limit`, the bot/room filter before the cut, and when an overall card completes accepted or failed (owned page for `tasks.js`). - `docs/subsystems/core-client.md` — the board's two reads, the newest-first cut, a Bot board naming its Bot, and the uncapped id-lookup path; the duplicated cap sentence is merged (owned page for `kanban.js`). - `changelog.d/1252-board-archive.changed.md` — user-visible board and Inbox change. - `patches/ds-bot/0008-client-bundle.patch` and the `patches/ds-bot/files/` mirrors — written by `node scripts/ds-bot-patches.mjs generate` from that same `core/` tree; the generator's context-only rewrite of `0005`, `0012` and `0014` was reverted, and `check` re-run green. ## Decisions recorded, not omissions - The board's archive read is capped at the same limit as the column (`{ ...query, archive: true, limit: ARCHIVE_SHOWN }`, room-scoped for a group, `{ botId }` for a Bot board, newest-settled first, finished rows only — the default read carries the active ones), so the poll carries the slice and not the history. The archive read **without** `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row, and now the Inbox), not what the board polls. - The `counts` loop still runs over `[...own, ...archive]` unchanged, and no counts assertion moved: the finished-only page is what stops a card appearing in both lists and being counted twice. - A child's requester can complete the room's overall card: the settle path runs the same transition with no actor, so the card's status moves and the requester gate is not consulted. It is status-only — nothing is deleted, and the card's actors and history are kept. - The bounded page is `settled`, while the Done column keeps only `accepted`: twenty newer `cancelled`/`failed` rows in one scope can spend the page's budget and push an older Done card out of the column. Left as it is at this tip, deliberately: the page is the recent-activity slice ordered by `updatedAt`, and narrowing it to `accepted` would make a "newest finished" read lie about cancelled/failed work other callers read. An older card is still readable without `limit`, which stays uncapped on purpose. ## What I ran At `5b832b91` (tree `f14bf9fd`, base `e37f1f83`). - `TZ=UTC npm run verify` — exit 0: core **1784 tests, 1784 pass, 0 fail**, plugins/social 525, plugins/tree 125, plugins/timeline 125, `.github/scripts` 509, interop 37, live-e2e 24; every suite green. - the ThinkFlowLab#1252 suites on their own at the same tree: `core/test/bot-tasks.test.js` 14/14, `core/test/kanban.test.js` 7/7, `core/test/inbox.test.js` 27/27. - `npm run build` in `core/` before `generate`; `node scripts/ds-bot-patches.mjs generate` then `check` — `ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)`, `ok 10 patches apply cleanly in order`, `ok core/ equals ds-bot/ + patches/ds-bot/`. The generator's context-only rewrite of `0005`, `0012` and `0014` was reverted and `check` re-run green. - `node core/scripts/build-client.mjs --check` inside `verify` — the committed `core/client.js` matches `core/src/` at this tip. ## Not run - The smoke bench (`core/dev/bench.sh smoke`): it needs a live DSH instance and a free port; this touches `src/host/tasks.js`, `src/client/kanban.js` and `src/client/inbox.js`, not `index.js`, and the host behaviour is covered by `core/test/bot-tasks.test.js`, `core/test/tasks.test.js`, `core/test/inbox.test.js` and `core/test/task-drive.test.js`. - Packaging, deploy and the desktop app — untouched. ## Verification at the tip Everything above ran at `5b832b91f8ebdb1b7de782e87d40f35b24f6549d` — the tip this pull request pushes, tree `f14bf9fd5a0e75746d39c4168f57cb0ad1b719b2`, base `e37f1f838023ce81e673d8fbec66a55d81dfe5e3` (19 files, +1007/−104, one commit). ``` ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5) ok 10 patches apply cleanly in order ok core/ equals ds-bot/ + patches/ds-bot/ ``` ## Checkpoint The generated mirrors — `patches/ds-bot/files/src/{client/inbox.js,client/kanban.js,host/tasks.js}` and `patches/ds-bot/files/test/{bot-tasks,inbox,kanban,tasks}.test.js` — are copies written by the generator from the files above, so the block lists the 12 paths a reviewer should open first; the diff itself is the full 19. <!-- flybot:checkpoint --> ``` { "pr": 1289, "claim": "Dev-1", "issue": 1252, "summary": "A finished card no longer ships with every board read: the tasks endpoint lists active work, { archive: true } adds the finished rows, and { archive: true, limit: N } returns the newest N finished rows, so the board's poll carries twenty settled cards and no active ones. A Bot board names its Bot there, and the Inbox asks for the archive so its Done and Needs-you cards return. The overall group card completes when every child has ended — accepted if each ended accepted or cancelled, failed if any failed. Tests were red first; docs, the client bundle and the patches ride in the same commit.", "files": [ "changelog.d/1252-board-archive.changed.md", "core/client.js", "core/src/client/inbox.js", "core/src/client/kanban.js", "core/src/host/tasks.js", "core/test/bot-tasks.test.js", "core/test/inbox.test.js", "core/test/kanban.test.js", "docs/subsystems/core-client.md", "docs/subsystems/core-host.md", "patches/ds-bot/0008-client-bundle.patch", "patches/ds-bot/files/src/host/tasks.js" ], "checks": { "ran": "TZ=UTC npm run verify; node scripts/ds-bot-patches.mjs check", "result": "green", "at": "5b832b91f8ebdb1b7de782e87d40f35b24f6549d" }, "next": "none — ready for review" } ``` <!-- /flybot:checkpoint -->
Fixes #1293
Part of #1265 (step 4 of 4)
Owner: Dev4
Written-by: Dev4
Reviewer: Reviewer
Tip SHA: e3ded8e
Base: ef5dae4 —
mainwhen this branch was cut (commits/main, read 2026-10-26T12:09Z; re-cut after #1291 landed so #1291'score/src/shared/*and its doc rows are in the parent, not missing from this diff).mainhas moved since (#1296 at12:25:40Z, #1295 at12:43:47Z, nowd1f6e960); none of them touches a file in this diff, so the branch is not rebased and the pull request still merges clean —mergeable: true, 24 files, checked at12:46:20Zagainst thatmain.Reproduce
On base
ef5dae42, an instance that names two repositories still reads one, and the board has no way to say so. The setting was only ever spelled as one name:so
{"delivery": {"repos": ["acme/one", "acme/two"]}}is invisible to the host: the list key matches nothing, the lookup falls through to the workspace'sorigin, and the second repository's issues and pull requests never reach a card.core/src/host/delivery-board.jskeepsMap<repo, board>(#1250) but calls the single-value resolver on every round, so even the cache cannot hold two boards at once; the payload carriesrepo/repoSourcescalars andcore/src/client/delivery-view.jsdraws onep.bt-del-repoheader and one set of columns. There is no place for a second repository to be named, counted, or kept, and a repository that fails to read is the whole page failing (failed,#1211), not one block of it.The four red conditions in the child issue are the tests below; each one is red without the source change (see `## Red without the source change
Seven knives, each cut on this tip's tree and restored with
cp(cmpclean afterwards; the suite was re-run green after every cut, 132/132):resolveReposreturns only the configured first entry (return configured.slice(0, 1)) → the second repository disappears:payload.repos.length > 1→> 0) → red condition ②: the single-repository layout stops matching today's DOM:repos: [one]) and countsbt-del-repo-block= 0 and fivesection[data-column].combineReposcalls one failed repositoryfailedinstead ofpartial(withBoard.length < entries.length ? 'partial'→'failed') → red condition ③:if (state === 'failed' || !hasBoard)→if (state === 'failed' && hasBoard)) → red condition ④, an empty board instead of one sentence with a next step:stagesOfalways returns the built-in five columns (earlyreturn DELIVERY_COLUMNS) → the payload stops deciding the columns, which is the seam The workflow engine: each node names its Bot and its inputs and outputs, the nodes are drawn as a flowchart, and a node missing an input is refused on the card #1272 needs:mergeMetricskeeps.filter(Boolean)and the whole-list null check) → the blocking defect the Reviewer found: apartialboard answers with the readable half's totals:openCountturns an unknown into0again (String(value ?? 0)) → the pinned rule goes red, which is what keeps the—from sliding back:Red condition ④'s "the task cards still render next to it" half is
core/test/kanban.test.js's#1265: with no GitHub token the task cards and both tabs are still there(part of the 132 above): the page keeps the task board and its two tabs while the delivery tab says the GitHub side is unavailable. Making the whole page depend on the delivery read is step 3's work (one board), not this step, so the two halves are proven in their own suites rather than in one screenshot.Not run
coresuite (npm test): this box refuses the full run withspawn … EAGAIN; the 11 suites above are the ones this diff can reach, and CI runs the rest.plugins/*,interop,live-e2e: this diff touches no plugin and no client transport, so their suites were not run here.GITHUB_TOKENin this session; the multi-repository path is driven by the host's own views indelivery-board.test.js(two real snapshots built from the same fake client) and by the DOM tests indelivery-view.test.js. CI'sflybot-testsjob does not add a live repository either.Risk
partialnames the body, and each block carries its owndata-state; a reader who only looks at the body cannot tell which repository failed without readingmissing[](each item now carriesrepo). That is the price of keeping the five existing values instead of adding a sixth.metricssums across repositories only while every repository read. Summing counts is exact, but one repository that could not answer leaves the board'smetricsnulland the header prints—rather than the readable half (this tip; review6416122171). The medians are deliberatelynullbecause a median of medians is not a median, anddailyis merged only when every repository could read it. If One board: the delivery board and the task board are one board, and a repository task is its issue #1265 later wants per-repository metrics, the top-level numbers will need a second look.—where a one-repository board read0: an open count the host never got is—, not0. That is#1211's rule applied, pinned by a test, and it is the only visible difference on a single-repository board.probe()walks five endpoints each. The board's backoff and ETag caching are per URL, so a failing repository backs off on its own; there is no per-repository request cap beyond what the round already has.delivery.repoanddelivery.reposboth set → the list wins and the single name is ignored. That is recorded in the docs but is a silent preference; a config that keeps both will not be told the older name is dead.loading); a removed repository's snapshot stays in theMapuntil the next restart. It is memory only, no state write.Checkpoint
{ "pr": 1297, "claim": "Dev4", "issue": 1293, "summary": "Two repositories on one board: delivery.repos is a list (delivery.repo still reads as one), every name resolves to its own {repo,source}, and the host keeps one snapshot per repository. One repository keeps today's DOM; with several, combineRepos draws one block each, the body is partial when some read, columns are the union, and metrics are summed only while every repository read — one that did not leaves metrics null so the header prints —. stagesOf is the one entry point for visible columns. 132/132 in 11 suites; seven knives red at this tip.", "files": [ "core/src/host/delivery-repo.js", "core/src/host/delivery-board.js", "core/src/client/delivery-view.js", "core/test/delivery-board.test.js", "core/test/delivery-view.test.js", "core/test/delivery-repo.test.js", "docs/subsystems/core-host.md", "patches/ds-bot/files/src/host/delivery-board.js", "changelog.d/1265-multi-repo.added.md" ], "checks": { "ran": "node --import ./test/harness-resolve.mjs --test <11 suites> && node scripts/ds-bot-patches.mjs check && build-client --check && lint/arch-docs && changelog && mojibake", "result": "green", "at": "e3ded8e4dd81546880a9f31daa4eb5befa64c04d" }, "next": "reviewer judges this tip; steps 2 and 3 stay for #1272" }