@bongos/core 1.19.618 → 1.19.620

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.618",
6
- "core_contract": "1.19.618",
7
- "source_commit": "1e344743e60d4921a3e5d73b32817388928e62a0",
5
+ "core_version": "1.19.620",
6
+ "core_contract": "1.19.620",
7
+ "source_commit": "7e8c8d38a15a24575258eda58829e9120d9ed55f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-09T06:20:10.684Z",
9
+ "built_at": "2026-09-09T06:53:24.739Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 462,
12
+ "docs_redacted": 464,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2085,
14
+ "functional_verbatim": 2086,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2571,
20
- "tree_sha256": "725579642848cf49184292a5271ac418e2ea288cac2beaaa80b49468625abc14",
19
+ "file_count": 2574,
20
+ "tree_sha256": "8c5d0cef512909da1de5dd90510ca13a74737c8d3b80ec2d8e0a7fc5e1d9451d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -397,7 +397,7 @@
397
397
  {
398
398
  "path": "clients/bongos-client/index.d.ts",
399
399
  "mode": "0000644",
400
- "sha256": "877ca14cd017c1392fcc5206c2f3603fb24becdc32670aa1e2ad0de9d930dfca"
400
+ "sha256": "07caf5c1db2cfd16c92713891055b477982aef4dd847af67042b742293e6a9ce"
401
401
  },
402
402
  {
403
403
  "path": "clients/bongos-client/index.mjs",
@@ -1864,20 +1864,25 @@
1864
1864
  "mode": "0000644",
1865
1865
  "sha256": "cec61252a02d5da416c77088dbf2adf99c138cb10b654fa9fef949a0848a7964"
1866
1866
  },
1867
+ {
1868
+ "path": "docs/adr/0269-the-cli-session-store-is-host-keyed-at-a-fixed-anchor.md",
1869
+ "mode": "0000644",
1870
+ "sha256": "cc44045ea59f76dabc62fe43993ffab6004add88c7f6142bfe9215f7f2c33c99"
1871
+ },
1867
1872
  {
1868
1873
  "path": "docs/adr/README.md",
1869
1874
  "mode": "0000644",
1870
- "sha256": "7ab74c04d47b89328aedfe3fca3f2fd18c049c2fe5aa6c98bc1072d26bc12c68"
1875
+ "sha256": "d660b4bc0bc3306f5fcf583118ca9149ba3deafe82dfe3894fe0b1c11b60cc2f"
1871
1876
  },
1872
1877
  {
1873
1878
  "path": "docs/api-reference.md",
1874
1879
  "mode": "0000644",
1875
- "sha256": "b430ecad01c7458dc893c11d976d3ae35f2dd5b8310e451a38af387908f64659"
1880
+ "sha256": "8adebe11d51d4bbce7e5acacc003ce923e820f0d8532541d3f5a74ed07570ce8"
1876
1881
  },
1877
1882
  {
1878
1883
  "path": "docs/api/openapi.json",
1879
1884
  "mode": "0000644",
1880
- "sha256": "81485c0440de636511dc9ee723e18af89f313b175ea626723d0beeeed31bba2f"
1885
+ "sha256": "2059cb9b02d6a20e4b1297c866db9f717bb3514ec49ab2454c776c806775d3da"
1881
1886
  },
1882
1887
  {
1883
1888
  "path": "docs/architecture.md",
@@ -2752,7 +2757,7 @@
2752
2757
  {
2753
2758
  "path": "docs/module-api-changelog.md",
2754
2759
  "mode": "0000644",
2755
- "sha256": "ec11a2ed9d50d4558aee3aca03f217dc0dde1f717859eb7a77c706535324fd32"
2760
+ "sha256": "6b3c53cfb31b4bf6963f740bb386c24059ef370af8927b0ececde09e062fff33"
2756
2761
  },
2757
2762
  {
2758
2763
  "path": "docs/modules-contract.md",
@@ -2949,6 +2954,11 @@
2949
2954
  "mode": "0000644",
2950
2955
  "sha256": "f6d2bb1da53f03f54080b8e0aec9f63390d3343c32917b64225ac47ae4ba7b42"
2951
2956
  },
2957
+ {
2958
+ "path": "docs/recipes/reranking-a-goals-board.md",
2959
+ "mode": "0000644",
2960
+ "sha256": "ecd8986c5e4831db53a803dae625e5f1b898b711911e8e6f1c4f1b79a98cbfe8"
2961
+ },
2952
2962
  {
2953
2963
  "path": "docs/recipes/search-index-ingestion.md",
2954
2964
  "mode": "0000644",
@@ -5587,7 +5597,7 @@
5587
5597
  {
5588
5598
  "path": "modules/lifecycle/db-tasks.js",
5589
5599
  "mode": "0000644",
5590
- "sha256": "e978cadb879a57d8d7e3bb84657a787ac7e83125666fa2d31013ac64b37b0d7a"
5600
+ "sha256": "15c9efea1fb5a8187fe5bae756777ebeb1a214e47e0434d7a8adeb156bd1e093"
5591
5601
  },
5592
5602
  {
5593
5603
  "path": "modules/lifecycle/db-versions.js",
@@ -5597,7 +5607,7 @@
5597
5607
  {
5598
5608
  "path": "modules/lifecycle/db.js",
5599
5609
  "mode": "0000644",
5600
- "sha256": "b4b2d6d7890ec67e52fc00718a814bf01a64f4432ed7380a175dca8bc794b0e4"
5610
+ "sha256": "af6af95c05aa6cc76c0e49cb0a99bfb24685b3efb66df0f90883c3301baeede0"
5601
5611
  },
5602
5612
  {
5603
5613
  "path": "modules/lifecycle/dead-deps.js",
@@ -5827,7 +5837,7 @@
5827
5837
  {
5828
5838
  "path": "modules/lifecycle/routes/task-write-routes.js",
5829
5839
  "mode": "0000644",
5830
- "sha256": "87843c62d8ce7af3d7d6aa5772791bd52823afe5f2c4b9b7427ca9f00dd14c11"
5840
+ "sha256": "cc86b3c05885c733cc28ba6e41f1b08cb8f0fe652e3181c1c88be4caabf18d61"
5831
5841
  },
5832
5842
  {
5833
5843
  "path": "modules/lifecycle/routes/tasks.js",
@@ -7652,12 +7662,12 @@
7652
7662
  {
7653
7663
  "path": "package-lock.json",
7654
7664
  "mode": "0000644",
7655
- "sha256": "44a869111a69105fd5f1778cb749ad2c40a067db90fee6e6bbd19fcd76acd2ab"
7665
+ "sha256": "401ae455c8e67d7bf4d622ef7e93fce203948868ae3b07de9770e6d90607249f"
7656
7666
  },
7657
7667
  {
7658
7668
  "path": "package.json",
7659
7669
  "mode": "0000644",
7660
- "sha256": "b974dffa8dcde89358258e710e0f0b2c9fbd560ff5ba4c505cc57ced1809ab0e"
7670
+ "sha256": "f6c0e1047dcef4fe6e429e149606d521e0d196ff748bcdeb6de6e1c63d2a60ab"
7661
7671
  },
7662
7672
  {
7663
7673
  "path": "public-docs/index.html",
@@ -7862,7 +7872,7 @@
7862
7872
  {
7863
7873
  "path": "scripts/gds/build-cli-package.js",
7864
7874
  "mode": "0000644",
7865
- "sha256": "81ad7fbc99f5c85219f1385cdd6e6954d27a0e2943f285676f060611a4878f4e"
7875
+ "sha256": "de1cdede17761775722509b1e1edb4bb43fbbf336450556f22119b18b93e43d3"
7866
7876
  },
7867
7877
  {
7868
7878
  "path": "scripts/gds/bump-version.js",
@@ -7922,7 +7932,7 @@
7922
7932
  {
7923
7933
  "path": "scripts/gds/cli-lib.js",
7924
7934
  "mode": "0000644",
7925
- "sha256": "44b8c997a25d9fcc68ba573d33391546ff98a871c7d99e9aa6d982c69a703824"
7935
+ "sha256": "eaa4c3a172009d5ce825e528f5077a480e034658501a7d0c532a7807886beb9e"
7926
7936
  },
7927
7937
  {
7928
7938
  "path": "scripts/gds/client-baseurl-guard.js",
@@ -8302,7 +8312,7 @@
8302
8312
  {
8303
8313
  "path": "scripts/gds/login.js",
8304
8314
  "mode": "0000644",
8305
- "sha256": "f16497d7eae23657e83f631b420f82a55ed8216480f3cd5488609d3b93274dee"
8315
+ "sha256": "bdc7fcc0876a1185fb15339740793944c755d716d998bb68533ab3a721d39ab2"
8306
8316
  },
8307
8317
  {
8308
8318
  "path": "scripts/gds/main-audit.js",
@@ -9387,7 +9397,7 @@
9387
9397
  {
9388
9398
  "path": "src/module-api.js",
9389
9399
  "mode": "0000644",
9390
- "sha256": "8d4f44cdde9eecac4f1d8294dcd8d1f1283d536c2adab2d9e49b74c8891a40e8"
9400
+ "sha256": "d2b45162be47422810ab7a05c42c9926520df6c1c5ab7e7274386c5a25eb07a3"
9391
9401
  },
9392
9402
  {
9393
9403
  "path": "src/module-loader/catalog.js",
@@ -9994,6 +10004,11 @@
9994
10004
  "mode": "0000644",
9995
10005
  "sha256": "a57516ed9e904cf1c86fe99688415f834dcae1a19f01b22e986f199fa5473258"
9996
10006
  },
10007
+ {
10008
+ "path": "tests/cli_sessions.mjs",
10009
+ "mode": "0000644",
10010
+ "sha256": "6ec406bf2bcbae261ab37ecaf820cfbe196c11007b0d8d3ee47cf21b6a3919ca"
10011
+ },
9997
10012
  {
9998
10013
  "path": "tests/cli_surface.mjs",
9999
10014
  "mode": "0000644",
@@ -11422,7 +11437,7 @@
11422
11437
  {
11423
11438
  "path": "tests/migration_allocation.mjs",
11424
11439
  "mode": "0000644",
11425
- "sha256": "e2f03570b76b4d5af7af3c87a2a4989827eb71d1b50c92ab546a456e85c4024e"
11440
+ "sha256": "0d1bc299555d98aa2ad8571e0a5a185776ca727f95577c3308a7e4b36efbfd97"
11426
11441
  },
11427
11442
  {
11428
11443
  "path": "tests/migration_namespace.mjs",
@@ -11662,7 +11677,7 @@
11662
11677
  {
11663
11678
  "path": "tests/patch_gate_ordering.mjs",
11664
11679
  "mode": "0000644",
11665
- "sha256": "b0690cf28c30bfa4ef1ecc4ceffa7d4be1666dbb09a6605ceb02cc057d2a9cc4"
11680
+ "sha256": "27a3b5023b6857f3f53734966bc7685757383f2b8dadfa3c0bf1aca2e7db5517"
11666
11681
  },
11667
11682
  {
11668
11683
  "path": "tests/path_match.mjs",
@@ -182,7 +182,7 @@ export interface PatchProvisioningInstancesIdDetailResponse { ok: boolean; descr
182
182
  export interface PatchProvisioningInstancesIdSettingsResponse { ok: boolean; saved: unknown; planet: unknown; push: unknown }
183
183
  export interface PatchTaskRecommendationsIdRequest { status: "declined" | "withdrawn" }
184
184
  export interface PatchTaskRecommendationsIdResponse { ok: boolean; recommendation: unknown }
185
- export interface PatchTasksIdRequest { kind?: string; discipline?: string; requires_rank?: string; module_key?: string; goal_id?: number; parent_task_id?: StringifiableId; parallel_safe?: unknown; newcomer_friendly?: boolean; xenos_claimable?: boolean; security_sensitive?: boolean; automation_tag?: string; needs_migration?: boolean; description?: string; title?: string; status?: unknown }
185
+ export interface PatchTasksIdRequest { kind?: string; discipline?: string; requires_rank?: string; module_key?: string; goal_id?: number; parent_task_id?: StringifiableId; parallel_safe?: unknown; newcomer_friendly?: boolean; xenos_claimable?: boolean; security_sensitive?: boolean; automation_tag?: string; needs_migration?: boolean; description?: string; title?: string; priority?: unknown; status?: unknown }
186
186
  export interface PostAccessRequestsInviteRequest { github_login: string }
187
187
  export interface PostAccessRequestsInviteResponse { ok: boolean; already_invited?: boolean; github_login?: unknown; request?: unknown }
188
188
  export interface PostAccessRequestsRequest { github_login: string; display_name?: string; note?: string }
@@ -0,0 +1,104 @@
1
+ # ADR 0269 — The CLI's session store is host-keyed at a fixed anchor; the per-brand file stays the active pointer
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-09
5
+ - **Task:** [task 1003741](https://cloudbongos.com/builders#/task/1003741) (BONGOS-V2, goal 1000090 — *Working area 4, Bongos Core distribution*)
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Related:** [ADR 0258](<redacted>.md) (the public CLI, which is where this bites) · [ADR 0108](<redacted>.md) (each instance its own checkout) · [ADR 0083](<redacted>.md) (why the store directory may not be called `sessions`)
8
+
9
+ ---
10
+
11
+ ## Context
12
+
13
+ The CLI resolved its session path from the **branding pack**, which is read out of whatever
14
+ checkout the process happens to be standing in. That gave it exactly **one session slot**, and the
15
+ slot moved depending on where you ran from:
16
+
17
+ - **Standalone** — the public `@cloudbongos/cli`'s entire situation — no checkout means no brand,
18
+ so every instance shared `~/.config/cloudbongos/gds-session.json`. Signing into a second
19
+ instance **destroyed** the first. Measured on the published 0.1.1: `configHome()` resolved to
20
+ `<HOME>/.config/cloudbongos` whichever instance was named.
21
+ - **In-repo** the brand resolved and the session landed in `~/.config/<slug>/`, where the
22
+ standalone CLI could never find it. `npx @cloudbongos/cli api GET /api/gds/me` from a bare
23
+ directory returned the cloudbongos.com builder while a valid hermeslines session sat on the same
24
+ machine.
25
+
26
+ This was not theoretical. The owner's own config dir carries a hand-made
27
+ `gds-session.<redacted>.bak.json`, alongside four more rescues of the same
28
+ shape. For a newcomer with one project it is invisible; it bites precisely the people running more
29
+ than one, which is the direction the platform is going.
30
+
31
+ ## Decision
32
+
33
+ **Keep one session file per instance, keyed by the instance's host, under a FIXED anchor —
34
+ `~/.config/<FALLBACK_DIR>/instances/<host>.json` — and leave the per-brand `gds-session.json`
35
+ exactly where it is, as the ACTIVE pointer.**
36
+
37
+ Four parts are load-bearing.
38
+
39
+ ### 1. The anchor is fixed, never brand-derived
40
+
41
+ A brand-derived anchor is the bug. `sessionStoreDir()` is built from `FALLBACK_DIR` and must not
42
+ read `configHome()`, `configDirName()` or the pack — so the same instance resolves to the same file
43
+ whether you run from a checkout or from `npx`. A test asserts the function body names none of them,
44
+ because "helpfully" routing it back through the brand would silently reinstate the split.
45
+
46
+ It lives in `scripts/gds/cli-lib.js`, **not** `src/instance-config.js`. That module is generic
47
+ config-path machinery shared with the server, and a CLI session store is not its concern; cli-lib
48
+ already owns `SESSION_PATH` and `SESSION_READ_PATHS`, so one file now describes where a session
49
+ lives. (The first cut put it in instance-config and the knip dead-code ratchet caught it: knip
50
+ cannot trace that module's `require()` consumers, so every export it gains counts as dead — three
51
+ new ones broke the gate. The right answer was better placement, not a bigger baseline.)
52
+
53
+ ### 2. The active pointer does not move
54
+
55
+ Every existing reader — in-repo skills, the card hook, the dev box, `readSessionToken` — looks at
56
+ `gds-session.json` in the configured dir. The store is **additive**: `saveSession` still writes that
57
+ file last and unchanged. Moving it would have been the tidier design and would have broken every one
58
+ of those readers at once, for no gain a builder can see.
59
+
60
+ ### 3. The OUTGOING session is archived before the incoming one lands
61
+
62
+ Order matters, and this step is the one that is easy to omit. A session written *before* the store
63
+ existed is not in it; without archiving the session being replaced, the very first login after
64
+ upgrading would still lose it — the exact bug, surviving its own fix. Archiving is best-effort and
65
+ never blocks a real sign-in.
66
+
67
+ ### 4. A stored token is verified before it is trusted
68
+
69
+ `bongos login <url>` now switches back to an instance you are already signed into with no browser
70
+ flow — but it calls `/api/gds/me` with the stored token first. Reinstating an expired or revoked
71
+ token unchecked would leave a builder "signed in" to a session every later command then fails on.
72
+ An unverified token falls straight through to the real device flow; `--force` signs in as somebody
73
+ else.
74
+
75
+ Because sessions are now kept rather than overwritten, `login` also names the other instances this
76
+ machine knows and how to move between them — silence would leave them unfindable.
77
+
78
+ ## Consequences
79
+
80
+ - Signing into a second instance is non-destructive, and moving between two projects costs no
81
+ browser round-trip.
82
+ - Session files are `0600` in a `0700` directory: they hold bearer tokens.
83
+ - An `api_base` that names no host is **not stored** rather than stored under a guessed name, so a
84
+ malformed or hostile base cannot write outside the store directory.
85
+ - **The directory is `instances/`, not `sessions/`.** `sessions` is a live module key, and
86
+ ADR 0083 §Decision #4 forbids kernel machinery from naming one — `src/instance-config.js` is a
87
+ kernel file and the fitness gate caught it. `instances/` is also the truer name: one file per
88
+ instance is exactly what it holds.
89
+ - A corrupt entry in the store is skipped, never fatal.
90
+ - Still open: the store is per-machine and holds no notion of which instance a *directory* belongs
91
+ to, so running in an instance repo does not automatically select that instance — the active
92
+ pointer is whatever you last signed into. That is a smaller, separable question.
93
+
94
+ ## Rejected
95
+
96
+ - **Moving the active pointer into the store** — breaks every existing reader at once for no
97
+ visible gain.
98
+ - **One file holding a map of instances** — changes the on-disk format that in-repo readers,
99
+ hooks and the dev box all parse today.
100
+ - **Keying the store under `configHome()`** — brand-derived, which is the bug being fixed.
101
+ - **Restoring a stored session without verifying it** — hands the builder a dead session that
102
+ looks live.
103
+ - **A new `bongos switch` verb** — `login <url>` is the command a builder already reaches for, and
104
+ the public CLI's verb surface is deliberately small (ADR 0258).
@@ -360,3 +360,4 @@ This keeps the decision history honest and traceable.
360
360
  | 0266 | [**The Board Room is its own surface, reachable by whoever may vote** ([task 1003734](https://cloudbongos.com/builders#/task/1003734) · goal 1000111 — *Working area 7, Government*, criterion `wa7-government`; owner decision 2026-09-08). The hall's only **upward-pointing** gate ([ADR 0175](<redacted>.md)) shipped as a hash tab inside `/government`, and two things about that did not survive inspection. **The nav never followed the page:** R16 correctly widened the PAGE gate from `government.manage` (archon) to `page.view.government` (metic+) when the page became three rooms — its own comment says *"the shell follows the widest legitimate audience"* — but `shell.js` still declares that item as label Permissions with gate archon, so a Metic who SITS on the board has no nav link at all and the only word in the nav is Permissions. Watch, Harbor, Gate and Sessions each own a nav item; the voting room was reachable only by knowing a URL fragment. **The surface was narrower than the franchise:** `board.vote.cast` floors at XENOS deliberately (`board.js:149` — the floor is low *so that widening the board works*) while the shell demands metic+, so the moment a constitution widens membership below Metic — the exact act [ADR 0175](<redacted>.md) §6 exists to make a CONFIG change — those members may vote by API and cannot load the page they would vote on, reintroducing the code-path change §6 removed. Decision: **its own page at `/board-room`, gated on the same atom the vote route checks.** The floor stays coarse — membership is still checked in-handler per item against that item's own snapshotted constitution — so a builder who clears the floor but sits on no board sees the room and no ballot, which is honest (§9 made the ballot open on purpose). **The migration is client-side because a fragment never reaches the server:** `#board-room` cannot be caught by any server route, so `government.js` redirects on load, preserving `?item=N` — recorded as a decision because adding a regex to `serve-internal.js` looks like the obvious fix and silently never fires. Three writers move (Discord `board-broadcast.js`, the `board_votes` need href, docs), and the redirect stays a release regardless because announcements already in channel history carry the old link forever. A **waiting-vote count** goes on the nav item: the data already exists (`boardVotesNeed` carries a live count pre-filtered to what the vote route would accept, served on `GET /me`), but `shell.js` has NO badge/bell/dot mechanism at all, so one is built once, generically, zero-is-silent, visible to exactly whoever the item is. Found while scoping and filed separately ([task 1003738](https://cloudbongos.com/builders#/task/1003738)): the hall renders needs as a SINGLE slot and `computeNeeds` sorts only by state with a stable sort, so registry order decides inside the `action_needed` bucket and `boardVotesNeed` sits fourth behind `artKeyNeed` — any builder with an unresolved image key never sees the board notice, which was live for this decision's own owner at the moment they asked for the bell. Rejected: a nav item merely deep-linking to the existing tab (cheaper, keeps the hash links free, but leaves the voting room inside a page whose gate answers a different question); a bell in the global header (a second inbox, needing its own read/unread and dismissal semantics); and a SOUND (`alert.wav`/`chime.wav` already ship, so it would be easy — it was not asked for).](<redacted>.md) | government / the board / hall nav |
361
361
  | 0267 | [**Unanimity, and the revise-and-re-sit loop** ([task 1003733](https://cloudbongos.com/builders#/task/1003733) · goal 1000111 — *Government*, owner decision 2026-09-08). A Full Idea is ratified by **unanimous** agreement from round one; a sitting that does not carry RETURNS to its author, who revises and re-sits it, unbounded, until the board is unanimous. `unanimous` joins `PASS_RULES` as a fourth answer on the axis these rules actually differ on — **what silence means**: it is two conditions, not one, because "nobody objected" is true of an empty room. Everyone who spoke must have said yes AND somebody must have spoken, so an unvoted sitting RETURNS, which is exactly what makes the clock the owner asked for safe under it ([ADR 0191](<redacted>.md) §4: a deadline may only be given to a rule whose expiry means return). **There is deliberately no membership denominator** — that is the whole difference from `majority`, and it is the owner's "silence does not block" as code: an absent member is not in the reckoning, so one yes out of four carries. The stated cost is that an attentive minority can ratify on a distracted board; the alternative is one person on holiday stopping the pipeline. Trigger changes follow from the rule: a single yes does NOT close it (a later member must still be able to object — the sitting runs its clock, full turnout closes early), while a reasoned objection closes it AT ONCE (no vote-changing in v1 fixes the outcome, and the author needs the feedback to revise). ADR 0191 §3's author rule rides both unchanged. **`BOARD_DEFAULTS` is NOT moved**: a fresh instance stays the day-one monarchy, because under any non-author-yes rule a solo founder can never ratify their own ideas (0191 §5) — adoption is a board amendment, and no env var can change a constitution. The revise-and-re-sit loop already existed (`POST /inbox/:id/resubmit` re-grades and re-fires the window hook); what the board owes it is now pinned by test — a return CLOSES the item so the next window may open, and pays nothing. Named limits: a zero-vote return carries no human objection (the author gets the mechanical weakest-section flag), and this changes nothing live until the separate defect where a ratified amendment does not survive to the next read is fixed.](<redacted>.md) | government / board room |
362
362
  | 0268 | [**The constitution comes from two roots, and a decision that did not take must say so** ([task 1003739](https://cloudbongos.com/builders#/task/1003739) · goal 1000111 — *Government*). On 2026-08-25 cloudbongos.com's board RATIFIED an amendment (rank:metic+ · consent · a 1440-minute sitting) and the constitution never changed — for two weeks `GET /government/constitution` answered the day-one monarchy with the passed amendment sitting in its own `history` array directly beneath the contradicting `board` block. Every sitting since was decided under a rule the board had voted to replace, and it silently un-shipped [ADR 0191](<redacted>.md) (majority) and would have un-shipped [ADR 0267](<redacted>.md) the same way. **Cause:** `modules/government/config.js` resolved BOTH its neutral starter and its instance pack from one `path.resolve(__dirname, '..', '..')`, under a comment saying the roots coincided *today* — true in a single checkout, false on a STANDALONE instance ([ADR 0108](<redacted>.md) §1), where the server runs `node_modules/@cloudbongos/core` with `WorkingDirectory=<instance repo>`. The neutral path stayed right (it really is core content); the INSTANCE path resolved inside the core package, so the host's own `config/government.json` was never read and `applyBoardAmendment` wrote the ratified amendment into `node_modules`, where the nightly core upgrade ([ADR 0161](<redacted>.md) cuts a release per merge) erased it within a day. **Nothing threw and nothing could have** — both paths exist, both are writable, and an absent instance pack is a legal state, so the read fell through to the neutral monarchy exactly as designed. `src/branding.js` had the pattern right three files away. **Fix:** neutral from `resolveCoreRoot()`, instance from `resolveInstanceRoot()`, reached through `src/module-api` — which had exposed NEITHER resolver, and that absence is precisely why the module re-derived the wrong one ([ADR 0083](<redacted>.md) forbids requiring a core internal). Resolved at load, not per call, because `resolveInstanceRoot()` falls back to `process.cwd()` and a lazy resolve would let a `chdir` move the constitution; `applyBoardAmendment` now mkdirs, since the write targets a directory the core does not own. Single-checkout behaviour is byte-identical, asserted rather than claimed. **The detector, which is the durable half:** `constitutionView` carries a `divergence` block comparing the newest PASSED amendment (sanitized as an apply would write it) field-by-field against what is in force, rendered above the hall's dials in words rather than config keys. It REPORTS and never HEALS — the instance pack is also the file a human editing the constitution touches, so a silent re-apply would revert a legitimate hand edit with no way to tell the two apart. A RETURNED amendment is never compared (that is what *returned* means). Does NOT put the 2026-08-25 amendment back in force — that is a board act. Sibling sweep RUN, not promised: one real hit filed as [task 1003745](https://cloudbongos.com/builders#/task/1003745) (quarantine.js's repo-containment refusal is blind to the instance repo).](<redacted>.md) | government / instance roots |
363
+ | 0269 | [**The CLI session store is host-keyed at a fixed anchor; the per-brand file stays the active pointer** ([task 1003741](https://cloudbongos.com/builders#/task/1003741) · goal 1000090 — *Working area 4, Bongos Core distribution*). The CLI resolved its session path from the BRANDING PACK, read out of whatever checkout the process stood in — one slot, that moved. Standalone (the public CLI's whole situation) no checkout means no brand, so every instance shared `~/.config/cloudbongos/gds-session.json` and signing into a second DESTROYED the first; in-repo it landed in `~/.config/<slug>/` where the standalone CLI could never find it (`npx … api GET /me` from a bare dir returned the cloudbongos builder while a valid hermeslines session sat on the same machine). Not theoretical — the owner's config dir carries a hand-made `gds-session.<redacted>.bak.json` and four more of the same shape. **Decision: one file per instance keyed by HOST at a FIXED anchor (`~/.config/<FALLBACK_DIR>/instances/<host>.json`), with the per-brand `gds-session.json` left exactly where it is as the ACTIVE pointer.** Four load-bearing parts. (1) The anchor must never read `configHome()`/`configDirName()`/the pack — a test asserts the function body names none, since routing it back through the brand silently reinstates the split. (2) The active pointer does NOT move: every existing reader (in-repo skills, the card hook, the dev box, `readSessionToken`) looks there, and the store is purely additive. (3) The OUTGOING session is archived BEFORE the incoming one lands — a session written before the store existed is not in it, so without this the first login after upgrading still loses it, the bug surviving its own fix. (4) A stored token is VERIFIED against `/api/gds/me` before being reinstated; unverified falls through to the real device flow, `--force` signs in as somebody else. `login` also names the other instances and how to switch, since sessions are now kept rather than overwritten. Files 0600 in a 0700 dir; an `api_base` naming no host is not stored rather than stored under a guess, so nothing can write outside the store. The dir is `instances/` NOT `sessions/` — `sessions` is a live module key and [ADR 0083](<redacted>.md) §Decision #4 forbids kernel machinery naming one; the fitness gate caught it. Rejected: moving the active pointer (breaks every reader for no visible gain); one file holding a map (changes a format hooks and the dev box parse today); keying under `configHome()` (the bug); restoring without verifying (hands over a dead session that looks live); a new `bongos switch` verb (login is the command already reached for, and the public CLI's surface is deliberately small).](<redacted>.md) | cli / auth / distribution |
@@ -18880,6 +18880,7 @@
18880
18880
  "minLength": 1,
18881
18881
  "maxLength": 200
18882
18882
  },
18883
+ "priority": {},
18883
18884
  "status": {}
18884
18885
  },
18885
18886
  "additionalProperties": false
@@ -591,7 +591,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
591
591
  | GET | `/api/bongos/tasks` | `any-builder` | — | |
592
592
  | POST | `/api/bongos/tasks` | `metic+archon` | `version_id`, `title`, `description`, `status`, `touches`, `est_minutes`, `est_cost_usd`, `manual_degree`, `priority`, `automation_tag`, `credits_reward`, `source`, `source_ref`, `value_summary`, `kind`, `discipline`, `parallel_safe`, `newcomer_friendly`, `security_sensitive`, `requires_rank`, `auto_detect_touches`, `criterion_ids`, `module_key`, `goal_id` | Task create/promote/edit (plan/scope mutation) is Metic+ as of ADR 0090 (owner decision 2026-06-26): a Metic who can run idea-triage may … |
593
593
  | GET | `/api/bongos/tasks/:id` | `any-builder` | — | single-task read; preserved per CRITICAL §4 (supervisor hot path). |
594
- | PATCH | `/api/bongos/tasks/:id` | `metic+archon` | `kind`, `discipline`, `requires_rank`, `module_key`, `goal_id`, `parent_task_id`, `parallel_safe`, `newcomer_friendly`, `xenos_claimable`, `security_sensitive`, `automation_tag`, `needs_migration`, `description`, `title`, `status` | PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1), body.discipline (mig 014), body.parallel_safe (mig 028), and … |
594
+ | PATCH | `/api/bongos/tasks/:id` | `metic+archon` | `kind`, `discipline`, `requires_rank`, `module_key`, `goal_id`, `parent_task_id`, `parallel_safe`, `newcomer_friendly`, `xenos_claimable`, `security_sensitive`, `automation_tag`, `needs_migration`, `description`, `title`, `priority`, `status` | PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1), body.discipline (mig 014), body.parallel_safe (mig 028), and … |
595
595
  | POST | `/api/bongos/tasks/:id/abandon` | `metic+archon` | `reason` | POST /tasks/:id/abandon — terminal "retire this task" transition (task 987). |
596
596
  | POST | `/api/bongos/tasks/:id/attest-gate` | `any-builder` | `head_sha` | POST /tasks/:id/attest-gate — body: { head_sha }. |
597
597
  | POST | `/api/bongos/tasks/:id/confirm` | `any-builder` | `verified` | |
@@ -1685,5 +1685,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1685
1685
  landed since 1.19.616 with no explicit bump. run 34317034024. (task 1002620)
1686
1686
  1.19.618 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1687
1687
  landed since 1.19.617 with no explicit bump. run 34318552090. (task 1002620)
1688
+ 1.19.619 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1689
+ landed since 1.19.618 with no explicit bump. run 34320024204. (task 1002620)
1690
+ 1.19.620 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1691
+ landed since 1.19.619 with no explicit bump. run 34321089991. (task 1002620)
1688
1692
  ---------------------------------------------------------------------------
1689
1693
  ```
@@ -0,0 +1,93 @@
1
+ # Re-ranking a goal's board — the needed-vs-nice rubric
2
+
3
+ > Written at task 1003750 (2026-09-09), the first whole-goal re-rank the project ran. Read this before re-ranking any board: the rubric is here so it is applied consistently rather than re-invented per session, and the mechanics section records the two traps that made the first pass cost more than the analysis did.
4
+
5
+ `tasks.priority` is an integer **1–5, where 1 is most urgent**. Two surfaces read it and both take low-as-urgent:
6
+
7
+ - version progress is weighted `6 - COALESCE(t.priority, 5)` (`migrations/003_pms.sql`), so an unset priority weighs the same as a P5;
8
+ - the session optimizer orders claimable work `priority ASC NULLS LAST` (`src/bongos/optimizer.js`).
9
+
10
+ So priority is not a label. It steers what every builder is offered first, and an unset one is not neutral — it sits at the bottom.
11
+
12
+ ## The rubric
13
+
14
+ The axis is **needed vs nice-to-have**, which is deliberately *not* "is this still true" (that is a relevance audit — see [the goal 1000090 one](../audits/<redacted>.md)) and not "how big is it". A task can be tiny and P1, or large and P5.
15
+
16
+ | Tier | Test | What earns it |
17
+ |---|---|---|
18
+ | **P1** | Bleeding now | A live path is broken, or **reports success falsely**, or finished paid work is being lost. Fix regardless of what else is queued. |
19
+ | **P2** | Needed, not bleeding | A defect with a workaround that taxes every session; a keystone freezing other tasks; a cost window closing on its own. |
20
+ | **P3** | Should do | Real reliability or legibility gain, no forcing function. |
21
+ | **P4** | Nice to have | Consistency, doc nits, cleanups, and work blocked behind a decision nobody has made yet. |
22
+ | **P5** | Nice to have / speculative | Large refactors and research with no forcing function. Also: tasks whose premise is contested and needs an owner call — park them here rather than guessing. |
23
+
24
+ Three rules that did the most work in practice:
25
+
26
+ 1. **"Reports success falsely" outranks "is broken".** A broken thing gets noticed; a lying one does not. Three of the five P1s on goal 1000090 were only P1 because a tool printed a success it had not verified.
27
+ 2. **Read the body, not the title.** [Task 1001479](https://cloudbongos.com/builders#/task/1001479) sat at P1 from June while its own description read *"Trivial, low priority."* Titles drift from bodies; bodies drift from reality.
28
+ 3. **A blocked task is not urgent, but its keystone is.** Where five tasks waited on one unwritten ADR, the ADR moved up and the five moved down. Prioritizing the blocked five would have produced five claims that cannot start.
29
+
30
+ **Sanity check before writing:** a tier list that is mostly P1 has ranked nothing. Aim for a distribution that narrows sharply at the top — goal 1000090's 63 open rows came out 5 / 8 / 27 / 17 / 6.
31
+
32
+ ## Mechanics
33
+
34
+ **Now:** `PATCH /api/bongos/tasks/:id` accepts `priority` (integer 1–5, or `null` to clear). It did not before task 1003750 — priority was write-once at create and immutable at every rank, which is why the first re-rank had to be SQL.
35
+
36
+ ```bash
37
+ echo '{"priority":2}' > /tmp/p.json
38
+ node scripts/gds/api.js PATCH /api/gds/tasks/1002884 --body-file /tmp/p.json
39
+ ```
40
+
41
+ Two things to know before you batch this:
42
+
43
+ - **Bulk PATCH is rate-limited to roughly 30 per minute.** A 63-row pass needs pacing, not a tight loop.
44
+ - **`priority` is not settable at the same time as `status`.** Status moves only through its own transition routes (`/promote`, `/confirm`, …); a body carrying `status` is refused outright with `status_not_patchable`.
45
+
46
+ **The trap that outlives this recipe: a P1 in `backlog` is inert.** Priority orders the *claimable* feed, and `backlog` rows are not in it. On goal 1000090, four of the five P1s were in `backlog`, so the re-rank changed nothing about what builders saw until they were promoted. **Always re-check status after re-ranking** — `/promote` the ones that should be live, and use [`/backlog-review`](../../.claude/skills/backlog-review/SKILL.md) to walk what is waiting on a person.
47
+
48
+ And before promoting anything into the claimable pool, **check `requires_rank`.** New tasks floor at `xenos` and the auto-derivation has been inert since ADR 0049, so a task about the deploy or authority surface is newcomer-claimable unless someone raised it by hand. Promoting a P1 without checking that is how a deploy-authority design lands in the newcomer queue.
49
+
50
+ ### When the API cannot reach the live board
51
+
52
+ Core reaches a live instance only through a release plus a pin move (gate 3, [`core-release-pipeline.md`](core-release-pipeline.md)) — so a *newly shipped* route is not callable against production for as long as the pin lags. In that window the board is only reachable by SQL on the control-plane box, and an agent cannot run it (the permission classifier blocks a mutating `sudo -u lars` over ssh, by design — see [task 1003159](https://cloudbongos.com/builders#/task/1003159), which exists to close exactly this gap).
53
+
54
+ Generate the statements from a checked map rather than typing ids, keep it in one transaction, and **write the revert first** — the prior values are only recoverable if you captured them before the write:
55
+
56
+ ```bash
57
+ node scripts/gds/api.js GET '/api/gds/tasks?goal_id=<goal>&limit=200' --quiet > before.json
58
+ ```
59
+
60
+ Then group the ids by target tier and emit one `UPDATE` per tier, guarded to the goal and to open statuses so a concurrent ship cannot be clobbered:
61
+
62
+ ```sql
63
+ BEGIN;
64
+ UPDATE tasks SET priority=1, updated_at=now()
65
+ WHERE goal_id=<goal> AND status IN ('ready','backlog') AND id IN (<ids>);
66
+ -- one statement per tier
67
+ COMMIT;
68
+ ```
69
+
70
+ Build the revert from `before.json` the same way, grouping by each row's *original* priority (including a `NULL` group — unset is a real prior value and restoring it as 5 is not the same thing). Verify every id in the map appears in the emitted SQL before running anything; a silently dropped id leaves a row at a priority nobody chose.
71
+
72
+ ## The goal 1000090 pass, as a worked example
73
+
74
+ All 63 open rows of [goal 1000090](https://cloudbongos.com/builders#/goal/1000090) (working area 4 — Bongos Core distribution), 2026-09-09. 48 of 63 changed; 24 had no priority at all.
75
+
76
+ | Tier | Tasks |
77
+ |---|---|
78
+ | **P1** | 1002708, 1002714, 1002884, 1003159, 1003200 |
79
+ | **P2** | 1001431, 1002204, 1002205, 1002863, 1002869, 1003526, 1003537, 1003742 |
80
+ | **P3** | 1000380, 1000861, 1000862, 1001219, 1001429, 1001433, 1001436, 1001930, 1002206, 1002531, 1002532, 1002583, 1002617, 1002622, 1002669, 1002707, 1002854, 1002880, 1002900, 1002903, 1003499, 1003502, 1003564, 1003625, 1003647, 1003648, 1003658 |
81
+ | **P4** | 1001311, 1001441, 1001479, 1001641, 1001927, 1001929, 1002049, 1002208, 1002209, 1002842, 1002844, 1002879, 1002882, 1003563, 1003573, 1003651, 1003657 |
82
+ | **P5** | 1002406, 1002413, 1002839, 1002840, 1002841, 1002843 |
83
+
84
+ The five P1s all said the same thing, which is the finding the exercise produced: **shipped work does not reliably reach users, and the tooling misreports it.** Task 1002884 health-checked the old still-running process and printed "✓ upgrade complete" while live served the previous version; 1002708 and 1002714 leave the pin uncommitted so the next commit silently downgrades live; 1003159 is the owner having no deploy control at all; 1003200 is that nothing checks whether a claimed artifact ever reached a remote, found after three paid tasks turned out to exist only on laptops.
85
+
86
+ 1002406 and 1002413 sit at P5 as *contested*, not as unimportant — ADR 0062 §7 and task 1003740 now point against their premise, so they need an owner's kill-or-re-argue rather than a builder's guess.
87
+
88
+ ## Related
89
+
90
+ - [`docs/audits/<redacted>.md`](../audits/<redacted>.md) — the relevance pass on the same board. Run relevance first: there is no point ranking a task whose premise is gone.
91
+ - `/backlog-review` — walks `status='backlog'`, which is where a re-rank's P1s often turn out to be hiding.
92
+ - `/priority-session` — reweights the **idea inbox**, not tasks, and writes no priorities. It ends in a recommendation; this recipe is the write.
93
+ - [`core-release-pipeline.md`](core-release-pipeline.md) — why a shipped route is not a live route.
@@ -272,7 +272,7 @@ async function findTaskBySourceRef({ source, sourceRef } = {}, deps = {}) {
272
272
 
273
273
  const TASK_SETTER_COLUMNS = new Set([
274
274
  'kind', 'discipline', 'newcomer_friendly', 'security_sensitive', 'parallel_safe', 'automation_tag',
275
- 'needs_migration', 'module_key', 'description', 'title',
275
+ 'needs_migration', 'module_key', 'description', 'title', 'priority',
276
276
  ]);
277
277
 
278
278
  async function updateTaskColumn(id, column, value) {
@@ -339,6 +339,14 @@ async function updateTaskDescription(id, description) { return updateTaskColumn(
339
339
  // 1002791) was permanent. Same permission, same audit trail as the body.
340
340
  async function updateTaskTitle(id, title) { return updateTaskColumn(id, 'title', title); }
341
341
 
342
+ // task 1003750: the third write-once planning field, and the one that steers
343
+ // what gets claimed — version progress is weighted `6 - COALESCE(priority, 5)`
344
+ // and the session optimizer orders claimable work by `priority ASC NULLS LAST`.
345
+ // Set at create and then immutable at every rank, so re-ranking a goal's board
346
+ // (needed vs nice-to-have) had no route at all and had to be done as raw SQL.
347
+ // 1 = most urgent, 5 = least; null clears it back to unset (weighted as 5).
348
+ async function updateTaskPriority(id, priority) { return updateTaskColumn(id, 'priority', priority); }
349
+
342
350
 
343
351
  async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
344
352
 
@@ -936,6 +944,7 @@ module.exports = {
936
944
  updateTaskNeedsMigration,
937
945
  updateTaskNewcomerFriendly,
938
946
  updateTaskParallelSafe,
947
+ updateTaskPriority,
939
948
  updateTaskRequiresRank,
940
949
  updateTaskSecuritySensitive,
941
950
  updateTaskStatus,
@@ -127,6 +127,7 @@ const {
127
127
  updateTaskNeedsMigration,
128
128
  updateTaskNewcomerFriendly,
129
129
  updateTaskParallelSafe,
130
+ updateTaskPriority,
130
131
  updateTaskRequiresRank,
131
132
  updateTaskSecuritySensitive,
132
133
  updateTaskStatus,
@@ -301,6 +302,7 @@ module.exports = {
301
302
  disambiguateWorktreeName,
302
303
  foldWorktreeName,
303
304
  updateTaskParallelSafe,
305
+ updateTaskPriority,
304
306
  updateTaskSecuritySensitive,
305
307
  updateTaskAutomationTag,
306
308
  updateTaskNeedsMigration,
@@ -318,6 +318,14 @@ module.exports = function registerTaskWriteRoutes(router) {
318
318
  // briefs naming tables that hard-fail CI). Same shape as create, same
319
319
  // task.edit permission, same audit_log trail as a description edit.
320
320
  title: { type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
321
+ // task 1003750: priority was accepted at create and then immutable at every
322
+ // rank — the same write-once defect title/description carried above. It is
323
+ // not cosmetic: the DB weights version progress as `6 - COALESCE(priority, 5)`
324
+ // and the session optimizer orders claimable work by `priority ASC NULLS LAST`,
325
+ // so a wrong value at create time steers what every builder claims, forever.
326
+ // A whole-goal re-rank (needed-vs-nice) had no route and had to run as SQL.
327
+ // null clears it back to unset; the 1..5 bounds mirror tasks_priority_check.
328
+ priority: {}, // 1..5 or null — checked inline
321
329
  // task 1002614 (map D-18): DECLARED so strict-mode admits the request far
322
330
  // enough to reach the deliberate status_not_patchable hint below (#514) —
323
331
  // the hint had been unreachable dead code behind unknown_field since the
@@ -345,6 +353,7 @@ module.exports = function registerTaskWriteRoutes(router) {
345
353
  const hasGoalId = Object.prototype.hasOwnProperty.call(body, 'goal_id');
346
354
  const hasDescription = Object.prototype.hasOwnProperty.call(body, 'description');
347
355
  const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
356
+ const hasPriority = Object.prototype.hasOwnProperty.call(body, 'priority');
348
357
  // #514: status is a lifecycle field, not a PATCH-able column — it moves only
349
358
  // through the dedicated transition routes. The #506 session tried
350
359
  // `PATCH status=ready`, got the generic no_supported_fields error, and had
@@ -356,8 +365,8 @@ module.exports = function registerTaskWriteRoutes(router) {
356
365
  if (Object.prototype.hasOwnProperty.call(body, 'status')) {
357
366
  return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
358
367
  }
359
- if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle) {
360
- return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
368
+ if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority) {
369
+ return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, priority, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
361
370
  }
362
371
  // ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
363
372
  // ---- FIRST write. The previous shape validated-and-wrote per field in ----
@@ -374,6 +383,16 @@ module.exports = function registerTaskWriteRoutes(router) {
374
383
  if (hasParallelSafe && body.parallel_safe !== null && typeof body.parallel_safe !== 'boolean') {
375
384
  return res.fail('bad_parallel_safe', { status: 400, message: 'parallel_safe must be true, false, or null' });
376
385
  }
386
+ // task 1003750: bounds mirror the tasks_priority_check DB constraint via the
387
+ // shared TASK_FIELD_BOUNDS the create schema uses, so the route and the
388
+ // column can't drift. Integer or null only — a float or "3" would pass a
389
+ // bare range test and then fail at the column.
390
+ if (hasPriority && body.priority !== null) {
391
+ const p = body.priority;
392
+ if (!Number.isInteger(p) || p < TASK_FIELD_BOUNDS.priority.min || p > TASK_FIELD_BOUNDS.priority.max) {
393
+ return res.fail('bad_priority', { status: 400, message: `priority must be an integer ${TASK_FIELD_BOUNDS.priority.min}-${TASK_FIELD_BOUNDS.priority.max} (1 = most urgent), or null to clear` });
394
+ }
395
+ }
377
396
  if (hasSecuritySensitive) {
378
397
  // task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
379
398
  // it gates whether ship.js routes the broadcast to the Archon-only
@@ -528,6 +547,15 @@ module.exports = function registerTaskWriteRoutes(router) {
528
547
  return res.fail('patch_failed', { status: 500, message: 'internal error' });
529
548
  }
530
549
  }
550
+ if (hasPriority) {
551
+ try {
552
+ updated = await db.updateTaskPriority(id, body.priority ?? null);
553
+ if (!updated) return res.fail('task_not_found', 404);
554
+ } catch (err) {
555
+ log.error('[gds] PATCH /tasks/:id priority', err);
556
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
557
+ }
558
+ }
531
559
  if (hasGoalId) {
532
560
  try {
533
561
  updated = await db.updateTaskGoal(id, body.goal_id);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.618",
3
+ "version": "1.19.620",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.618",
9
+ "version": "1.19.620",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.618",
3
+ "version": "1.19.620",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -35,7 +35,7 @@ const REPO_ROOT = path.join(__dirname, '..', '..');
35
35
 
36
36
  // The package's OWN version — deliberately independent of the core's. The core moves many times
37
37
  // a day (CI auto-patch, ADR 0161); the CLI's public contract should not.
38
- const PACKAGE_VERSION = '0.1.2';
38
+ const PACKAGE_VERSION = '0.1.3';
39
39
  const PACKAGE_NAME = '@cloudbongos/cli';
40
40
 
41
41
  // ── What the package carries ────────────────────────────────────────────────────────────────
@@ -229,12 +229,117 @@ async function loadSession() {
229
229
  return null;
230
230
  }
231
231
 
232
+ // ---- the shared session store (task 1003741, ADR 0269) ---------------------
233
+ // ONE FILE PER INSTANCE, at a FIXED anchor — deliberately NOT configHome().
234
+ //
235
+ // WHY A FIXED ANCHOR. configHome() is brand-derived, and the brand is read out of whatever checkout
236
+ // the process is standing in. That gave the CLI exactly one session slot, and the slot MOVED: run
237
+ // standalone (the public @cloudbongos/cli's whole situation) the brand cannot resolve, so every
238
+ // instance shared one gds-session.json and signing into a second DESTROYED the first; run from
239
+ // inside an instance repo and the session landed in ~/.config/<slug>/ where the standalone CLI
240
+ // could never find it. The owner's own config dir carries a hand-made `hermeslines-clobber` backup
241
+ // from the first mode.
242
+ //
243
+ // WHY HERE AND NOT instance-config.js. That module is generic config-path machinery shared with the
244
+ // server; a CLI session store is not its concern, and cli-lib already owns SESSION_PATH and
245
+ // SESSION_READ_PATHS. Keeping the two together means one file describes where a session lives.
246
+ function sessionStoreDir() {
247
+ // Directory name deliberately NOT 'sessions': that is a live module key, and ADR 0083
248
+ // §Decision #4 forbids naming one in shared machinery. 'instances' is the truer name anyway —
249
+ // one file per instance is exactly what this holds.
250
+ return path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances');
251
+ }
252
+
253
+ // The store's filename for an instance, derived from its api_base host. Returns null when the base
254
+ // names no host — an unkeyable session is simply not stored, never stored under a guessed name.
255
+ function sessionHostKey(apiBase) {
256
+ const raw = String(apiBase || '').trim();
257
+ if (!raw) return null;
258
+ let host = null;
259
+ try { host = new URL(raw).host; } catch (_) { host = null; }
260
+ if (!host) return null;
261
+ // Filename-safe, and collision-free for real hosts: ':' (a port) is the only character a valid
262
+ // host adds beyond the safe set, and it maps to '_' which cannot otherwise appear in one.
263
+ const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
264
+ return safe && safe !== '.' && safe !== '..' ? safe : null;
265
+ }
266
+
267
+ function sessionStorePath(apiBase) {
268
+ const key = sessionHostKey(apiBase);
269
+ return key ? path.join(sessionStoreDir(), `${key}.json`) : null;
270
+ }
271
+
272
+ // Write one session into the shared, host-keyed store. No-op when the session names no host —
273
+ // an unkeyable session is skipped, never filed under a guessed name.
274
+ async function writeSessionToStore(session) {
275
+ const target = session && sessionStorePath(session.api_base);
276
+ if (!target) return null;
277
+ await fsp.mkdir(path.dirname(target), { recursive: true });
278
+ await fsp.chmod(path.dirname(target), 0o700).catch(() => {});
279
+ await fsp.writeFile(target, JSON.stringify(session, null, 2), { mode: 0o600 });
280
+ return target;
281
+ }
282
+
283
+ // Every instance's session is kept, not just the last one (task 1003741).
284
+ //
285
+ // This used to write ONE file, so `bongos login <another-instance>` silently DESTROYED the session
286
+ // you already had — the owner's own config dir carries a hand-made `hermeslines-clobber` backup
287
+ // from exactly that. Three steps now, and the order matters:
288
+ //
289
+ // 1. archive the OUTGOING session first. That is what rescues a session written before the store
290
+ // existed: it was never in the store, so without this step the very first login after
291
+ // upgrading would still lose it.
292
+ // 2. store the incoming one under its own host.
293
+ // 3. write the ACTIVE pointer exactly where it has always gone, so every existing reader —
294
+ // in-repo skills, hooks, the dev box — behaves identically.
232
295
  async function saveSession(session) {
233
296
  const dir = path.dirname(SESSION_PATH);
234
297
  await fsp.mkdir(dir, { recursive: true });
298
+
299
+ try {
300
+ const prev = await loadSession();
301
+ const prevKey = prev && prev.token ? sessionHostKey(prev.api_base) : null;
302
+ const nextKey = sessionHostKey(session && session.api_base);
303
+ if (prevKey && prevKey !== nextKey) await writeSessionToStore(prev);
304
+ } catch (_) {
305
+ // Archiving is best-effort — a lost previous session must never block a real sign-in.
306
+ }
307
+
308
+ await writeSessionToStore(session).catch(() => null);
235
309
  await fsp.writeFile(SESSION_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
236
310
  }
237
311
 
312
+ // The stored session for one instance, or null. Used by `bongos login` to switch back to an
313
+ // instance you are already signed into without a fresh device flow.
314
+ async function loadStoredSession(apiBase) {
315
+ const target = sessionStorePath(apiBase);
316
+ if (!target) return null;
317
+ return readSessionFile(target);
318
+ }
319
+
320
+ // Every instance this machine has a stored session for — so the CLI can say which one it is
321
+ // targeting and what else it remembers. Sorted for a stable listing; never throws.
322
+ function listStoredSessions() {
323
+ let names = [];
324
+ try { names = fs.readdirSync(sessionStoreDir()); } catch (_) { return []; }
325
+ const out = [];
326
+ for (const name of names) {
327
+ if (!name.endsWith('.json')) continue;
328
+ try {
329
+ const j = JSON.parse(fs.readFileSync(path.join(sessionStoreDir(), name), 'utf8'));
330
+ if (j && j.token) {
331
+ out.push({
332
+ host: name.slice(0, -5),
333
+ api_base: j.api_base || null,
334
+ login: (j.builder && j.builder.github_login) || null,
335
+ instance: (j.instance && j.instance.name) || null,
336
+ });
337
+ }
338
+ } catch (_) { /* a corrupt entry is skipped, never fatal */ }
339
+ }
340
+ return out.sort((a, b) => a.host.localeCompare(b.host));
341
+ }
342
+
238
343
  function loadSessionSync() {
239
344
  for (const p of SESSION_READ_PATHS) {
240
345
  if (!fs.existsSync(p)) continue;
@@ -1006,6 +1111,11 @@ module.exports = {
1006
1111
  loadSession,
1007
1112
  loadSessionSync,
1008
1113
  saveSession,
1114
+ sessionStoreDir,
1115
+ sessionHostKey,
1116
+ sessionStorePath,
1117
+ loadStoredSession,
1118
+ listStoredSessions,
1009
1119
  arg,
1010
1120
  argText,
1011
1121
  hasFlag,
@@ -22,7 +22,7 @@
22
22
 
23
23
  const readline = require('node:readline');
24
24
  const { spawnSync } = require('node:child_process');
25
- const { saveSession } = require('./cli-lib');
25
+ const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
26
26
  const { branding } = require('../../src/branding');
27
27
 
28
28
  // --- pure helpers (unit-tested) ---------------------------------------------
@@ -204,6 +204,30 @@ async function main() {
204
204
  const idp = (man.data.auth && man.data.auth.idp) || null;
205
205
  log(` Signing in to ${instanceName}${doorWord}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
206
206
 
207
+ // 1b. Already signed in to THIS instance? Switch back instead of re-running a browser flow
208
+ // (task 1003741). Sessions are now kept per instance, so moving between two projects should
209
+ // cost nothing. The stored token is VERIFIED against the instance before it is trusted — an
210
+ // expired or revoked one falls straight through to the real flow below.
211
+ if (!process.argv.includes('--force')) {
212
+ let stored = null;
213
+ try { stored = await loadStoredSession(base); } catch (_) { stored = null; }
214
+ if (stored && stored.token) {
215
+ const me = await fetchJson(`${base}/api/gds/me`, { headers: { Authorization: `Bearer ${stored.token}` } });
216
+ if (me.ok && me.data && me.data.builder) {
217
+ await saveSession({ ...stored, builder: me.data.builder, api_base: base,
218
+ instance: { name: instanceName, origin: base } });
219
+ const back = me.data.builder.github_login || me.data.builder.display_name || 'you';
220
+ log('');
221
+ log(` ✓ Already signed in to ${instanceName} as ${back} — switched, no browser needed.`);
222
+ logOtherInstances(base, log);
223
+ log(' Next: bongos start (see what you can claim)');
224
+ log(' (Re-run with --force to sign in as somebody else.)');
225
+ return;
226
+ }
227
+ log(' Your saved session for this instance has expired — signing in again.');
228
+ }
229
+ }
230
+
207
231
  // 2. Device flow.
208
232
  let flow = await runDeviceFlow(base, { log, idp });
209
233
 
@@ -280,9 +304,23 @@ async function main() {
280
304
  const who = (flow.builder && (flow.builder.github_login || flow.builder.display_name)) || 'you';
281
305
  log('');
282
306
  log(` ✓ Signed in to ${instanceName} as ${who}.`);
307
+ logOtherInstances(base, log);
283
308
  log(' Next: bongos start (see what you can claim) · bongos code / bongos shell (get on your dev box)');
284
309
  }
285
310
 
311
+ // Name the OTHER instances this machine is signed into, and how to move between them (task
312
+ // 1003741). Signing in used to destroy the session you already had, so there was never anything
313
+ // to say; now that every instance is kept, silence would leave a builder unable to find them.
314
+ function logOtherInstances(base, log) {
315
+ let others = [];
316
+ try {
317
+ others = listStoredSessions().filter((s) => s.api_base && s.api_base !== base);
318
+ } catch (_) { return; }
319
+ if (!others.length) return;
320
+ const names = others.map((s) => s.instance || s.host);
321
+ log(` Also signed in to ${names.join(', ')} — switch with \`bongos login <url>\` (no browser needed).`);
322
+ }
323
+
286
324
  if (require.main === module) {
287
325
  main().catch((err) => {
288
326
  console.error(`✖ ${err.message}`);
@@ -290,4 +328,4 @@ if (require.main === module) {
290
328
  });
291
329
  }
292
330
 
293
- module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow };
331
+ module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow, logOtherInstances };
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.618'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.620'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,168 @@
1
+ // tests/cli_sessions.mjs — one session slot became one per instance (task 1003741).
2
+ //
3
+ // THE BUG. The CLI resolved its session path from the BRANDING PACK, which is read out of whatever
4
+ // checkout you happen to be standing in. That gave it exactly one slot, with two failure modes:
5
+ //
6
+ // • standalone (the public @cloudbongos/cli's whole situation) the brand cannot resolve, so every
7
+ // instance shared ~/.config/cloudbongos/gds-session.json and `bongos login <other>` DESTROYED
8
+ // the session you already had. The owner's own config dir carries a hand-made
9
+ // `gds-session.hermeslines-clobber-2026-08-12.bak.json` from exactly this.
10
+ // • in-repo the session landed in ~/.config/<slug>/ where the standalone CLI could never find it.
11
+ //
12
+ // The store is keyed by instance host at a FIXED anchor, so the same instance resolves to the same
13
+ // file from either direction. The per-brand gds-session.json stays the ACTIVE pointer, untouched,
14
+ // so every existing reader behaves identically — that back-compat is asserted here too.
15
+
16
+ import test from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import fs from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createRequire } from 'node:module';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const require = createRequire(import.meta.url);
27
+ const ic = require(path.join(REPO_ROOT, 'src', 'instance-config.js'));
28
+ const lib = require(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'));
29
+
30
+ // SESSION_PATH is frozen at module load from os.homedir(), so anything touching the real save path
31
+ // runs in a child with its own HOME.
32
+ function inSandbox(body) {
33
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-sess-'));
34
+ try {
35
+ const code = `const R=${JSON.stringify(REPO_ROOT)};
36
+ const lib=require(R+'/scripts/gds/cli-lib.js');
37
+ const ic=require(R+'/src/instance-config.js');
38
+ const fs=require('node:fs'), path=require('node:path');
39
+ (async()=>{ ${body} })().catch((e)=>{ console.error('ERR '+e.message); process.exit(1); });`;
40
+ const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env: { ...process.env, HOME: home } });
41
+ return { home, out: `${r.stdout || ''}${r.stderr || ''}`, status: r.status };
42
+ } finally {
43
+ fs.rmSync(home, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ // ── 1. The key and the anchor ───────────────────────────────────────────────────────────────
48
+
49
+ test('a session is keyed by the instance host', () => {
50
+ assert.equal(lib.sessionHostKey('https://cloudbongos.com'), 'cloudbongos.com');
51
+ assert.equal(lib.sessionHostKey('https://hermeslines-marketing.cloudbongos.com'), 'hermeslines-marketing.cloudbongos.com');
52
+ assert.equal(lib.sessionHostKey('http://localhost:3000'), 'localhost_3000');
53
+ });
54
+
55
+ test('an unkeyable base is stored nowhere, never under a guess', () => {
56
+ for (const bad of ['', null, undefined, 'not a url', '/etc/passwd', '../../escape']) {
57
+ assert.equal(lib.sessionHostKey(bad), null, `should refuse ${JSON.stringify(bad)}`);
58
+ assert.equal(lib.sessionStorePath(bad), null);
59
+ }
60
+ });
61
+
62
+ test('no host can escape the store directory', () => {
63
+ // The key becomes a filename, so a traversal in a hostile api_base must not reach outside.
64
+ for (const b of ['https://cloudbongos.com', 'http://localhost:3000']) {
65
+ const p = lib.sessionStorePath(b);
66
+ assert.equal(path.dirname(p), lib.sessionStoreDir(), `${b} escaped the store dir`);
67
+ assert.ok(!path.basename(p).includes('/') && !path.basename(p).includes('\\'));
68
+ }
69
+ });
70
+
71
+ test('the store anchor is FIXED, not brand-derived — that is the whole fix', () => {
72
+ // A brand-derived anchor is what split in-repo from standalone in the first place. If someone
73
+ // "helpfully" routes this through configHome() again, the two diverge and the bug is back.
74
+ assert.equal(lib.sessionStoreDir(), path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances'));
75
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'), 'utf8');
76
+ const body = src.slice(src.indexOf('function sessionStoreDir()'));
77
+ const fn = body.slice(0, body.indexOf('\n}') + 2);
78
+ assert.ok(!/configHome|configDirName|safeBrand/.test(fn), `sessionStoreDir must not read the brand:\n${fn}`);
79
+ });
80
+
81
+ // ── 2. A login never destroys another instance's session ────────────────────────────────────
82
+
83
+ test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
84
+ const { out } = inSandbox(`
85
+ // A session from before this feature: an active pointer, nothing in the store.
86
+ const active = ic.configPath('gds-session.json');
87
+ fs.mkdirSync(path.dirname(active), { recursive: true });
88
+ fs.writeFileSync(active, JSON.stringify({ token:'TOKEN-A', api_base:'https://a.example.com',
89
+ builder:{github_login:'someone'} }), { mode: 0o600 });
90
+
91
+ await lib.saveSession({ token:'TOKEN-B', api_base:'https://b.example.com', builder:{github_login:'someone'} });
92
+
93
+ const rescued = await lib.loadStoredSession('https://a.example.com');
94
+ const stored = await lib.loadStoredSession('https://b.example.com');
95
+ const pointer = JSON.parse(fs.readFileSync(active,'utf8'));
96
+ console.log(JSON.stringify({
97
+ firstSurvived: !!(rescued && rescued.token === 'TOKEN-A'),
98
+ secondStored: !!(stored && stored.token === 'TOKEN-B'),
99
+ activeIsSecond: pointer.api_base === 'https://b.example.com',
100
+ hosts: lib.listStoredSessions().map(s => s.host),
101
+ }));`);
102
+ const r = JSON.parse(out.trim().split('\n').pop());
103
+ assert.equal(r.firstSurvived, true, 'the first instance\'s session was destroyed — the bug is back');
104
+ assert.equal(r.secondStored, true);
105
+ assert.equal(r.activeIsSecond, true, 'the active pointer must follow the newest sign-in');
106
+ assert.deepEqual(r.hosts, ['a.example.com', 'b.example.com']);
107
+ });
108
+
109
+ test('the ACTIVE pointer still lands exactly where every existing reader looks', () => {
110
+ // In-repo skills, hooks and the dev box all read gds-session.json in the configured dir. The
111
+ // store is additive; if this moved, every one of them would silently stop finding a session.
112
+ const { out } = inSandbox(`
113
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
114
+ console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath('gds-session.json')) }));`);
115
+ assert.equal(JSON.parse(out.trim().split('\n').pop()).wroteActive, true);
116
+ });
117
+
118
+ test('re-saving the SAME instance is idempotent and files nothing extra', () => {
119
+ const { out } = inSandbox(`
120
+ const s = { token:'T1', api_base:'https://a.example.com', builder:{github_login:'x'} };
121
+ await lib.saveSession(s);
122
+ await lib.saveSession({ ...s, token:'T2' }); // e.g. fetch-art-key rewriting one field
123
+ const cur = await lib.loadStoredSession('https://a.example.com');
124
+ console.log(JSON.stringify({ hosts: lib.listStoredSessions().map(h=>h.host), token: cur.token }));`);
125
+ const r = JSON.parse(out.trim().split('\n').pop());
126
+ assert.deepEqual(r.hosts, ['a.example.com'], 'a same-instance re-save must not fan out');
127
+ assert.equal(r.token, 'T2');
128
+ });
129
+
130
+ test('session files are owner-only', () => {
131
+ const { out } = inSandbox(`
132
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
133
+ const f = fs.statSync(lib.sessionStorePath('https://a.example.com')).mode & 0o777;
134
+ const d = fs.statSync(lib.sessionStoreDir()).mode & 0o777;
135
+ console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8) }));`);
136
+ const r = JSON.parse(out.trim().split('\n').pop());
137
+ assert.equal(r.file, '600', 'a session file holds a bearer token');
138
+ assert.equal(r.dir, '700');
139
+ });
140
+
141
+ test('a corrupt store entry is skipped, never fatal', () => {
142
+ const { out } = inSandbox(`
143
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
144
+ fs.writeFileSync(path.join(lib.sessionStoreDir(),'broken.json'), '{ not json');
145
+ console.log(JSON.stringify({ hosts: lib.listStoredSessions().map(h=>h.host) }));`);
146
+ assert.deepEqual(JSON.parse(out.trim().split('\n').pop()).hosts, ['a.example.com']);
147
+ });
148
+
149
+ // ── 3. Switching back ───────────────────────────────────────────────────────────────────────
150
+
151
+ test('login VERIFIES a stored token before trusting it, and can be forced past', () => {
152
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'login.js'), 'utf8');
153
+ // A stored token may be expired or revoked. Reinstating one unchecked would leave the builder
154
+ // "signed in" to a session every later command then fails on.
155
+ assert.match(src, /loadStoredSession\(base\)/);
156
+ assert.match(src, /\/api\/gds\/me`, \{ headers: \{ Authorization/);
157
+ assert.match(src, /if \(me\.ok && me\.data && me\.data\.builder\)/);
158
+ assert.match(src, /--force/, 'there must be a way to sign in as somebody else');
159
+ });
160
+
161
+ test('login names the other instances and how to move between them', () => {
162
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'login.js'), 'utf8');
163
+ assert.match(src, /function logOtherInstances/);
164
+ assert.match(src, /Also signed in to/);
165
+ // Terminal reader: no slash command may appear in that guidance (task 1003730's rule).
166
+ const fnStart = src.indexOf('function logOtherInstances');
167
+ assert.ok(!/\/builder-/.test(src.slice(fnStart, fnStart + 700)), 'no slash command in terminal guidance');
168
+ });
@@ -204,6 +204,7 @@ function shouldReserveMigrationNumber(task) {
204
204
  [() => db.updateTaskModuleKey(1, null), 'module_key'],
205
205
  [() => db.updateTaskDescription(1, 'edited body'), 'description'], // task 1002647
206
206
  [() => db.updateTaskTitle(1, 'corrected title'), 'title'], // task 1002792
207
+ [() => db.updateTaskPriority(1, 2), 'priority'], // task 1003750
207
208
  ];
208
209
  for (const [call, column] of setters) {
209
210
  const before = seen.length;
@@ -225,10 +226,35 @@ function shouldReserveMigrationNumber(task) {
225
226
  'the PATCH schema must declare title with the create-time limits — a title can be rewritten, never blanked'
226
227
  );
227
228
  assert.ok(/const hasTitle = Object\.prototype\.hasOwnProperty\.call\(body, 'title'\)/.test(routesSrc), 'title is gated like every other field');
228
- assert.ok(/&& !hasTitle\) \{/.test(routesSrc), 'a title-only PATCH is a supported request, not no_supported_fields');
229
+ // Assert !hasTitle is a TERM of the no_supported_fields guard, not that it is
230
+ // the LAST term: pinning the final position made this red the moment task
231
+ // 1003750 appended !hasPriority, for a change that kept the stated intent
232
+ // (a title-only PATCH is supported) perfectly intact.
233
+ assert.ok(/if \(!hasParent &&[^)]*!hasTitle\b/.test(routesSrc), 'a title-only PATCH is a supported request, not no_supported_fields');
229
234
  assert.ok(/db\.updateTaskTitle\(id, body\.title\.trim\(\)\)/.test(routesSrc), 'the write goes through the allowlisted setter');
230
235
  });
231
236
 
237
+ // task 1003750: priority was the third write-once planning field (after title
238
+ // and description above) — accepted at create, then immutable at every rank.
239
+ // It is the one that steers what gets claimed: version progress is weighted
240
+ // `6 - COALESCE(priority, 5)` and the optimizer orders by `priority ASC NULLS
241
+ // LAST`, so re-ranking a goal's board had to be done as raw SQL against prod.
242
+ await test('PATCH /tasks/:id accepts priority so a board can be re-ranked (task 1003750)', () => {
243
+ assert.ok(/priority: \{\},\s*\/\/ 1\.\.5 or null/.test(routesSrc), 'the PATCH schema must declare priority so strict-mode admits it');
244
+ assert.ok(/const hasPriority = Object\.prototype\.hasOwnProperty\.call\(body, 'priority'\)/.test(routesSrc), 'priority is gated like every other field');
245
+ assert.ok(/if \(!hasParent &&[^)]*!hasPriority\b/.test(routesSrc), 'a priority-only PATCH is a supported request');
246
+ assert.ok(/db\.updateTaskPriority\(id, body\.priority \?\? null\)/.test(routesSrc), 'the write goes through the allowlisted setter, and null clears');
247
+ // The bounds must come from the shared TASK_FIELD_BOUNDS the create schema
248
+ // reads, never a literal 1..5 here — that is how a route and its column drift.
249
+ assert.ok(/TASK_FIELD_BOUNDS\.priority\.min.*TASK_FIELD_BOUNDS\.priority\.max/s.test(routesSrc), 'priority bounds are shared with the create schema, not re-typed');
250
+ // Validation must sit ABOVE the first write (the task 1002609 rule this
251
+ // handler documents): a body mixing a valid field with a bad priority must
252
+ // commit nothing.
253
+ const guardAt = routesSrc.indexOf("res.fail('bad_priority'");
254
+ const firstWriteAt = routesSrc.indexOf('await db.updateTaskKind(');
255
+ assert.ok(guardAt > 0 && firstWriteAt > 0 && guardAt < firstWriteAt, 'the priority bounds check must run before the first write, not after');
256
+ });
257
+
232
258
  await test('PATCH /tasks/:id accepts needs_migration so a wrong flag is correctable', () => {
233
259
  assert.ok(
234
260
  /needs_migration: \{ type: 'boolean' \}/.test(routesSrc),
@@ -46,6 +46,9 @@ const REFUSALS = [
46
46
  // rendered from goalNotOpenRefusal, so the pin looks for the call.
47
47
  'goalNotOpenRefusal(goal)',
48
48
  "'bad_requires_rank'",
49
+ // task 1003750: priority joined the PATCH surface; its bounds check is a
50
+ // validation refusal like the rest, so it must settle before the first write.
51
+ "'bad_priority'",
49
52
  'badKind(res)',
50
53
  "'bad_discipline'",
51
54
  ];