@bongos/core 1.19.629 → 1.19.631
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 +37 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0272-a-craft-is-an-identity-not-a-toolchain.md +64 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/multi-builder-merge.md +1 -1
- package/modules/lifecycle/db-claims.js +15 -4
- package/modules/lifecycle/db-ship.js +88 -9
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/claims.js +12 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +4 -20
- package/scripts/gds/cli-lib.js +29 -0
- package/scripts/gds/ship-flow.js +50 -10
- package/scripts/gds/ship-io.js +25 -0
- package/src/module-api.js +1 -1
- package/src/modules.js +30 -7
- package/tests/claim_resolve_worktree_lock.mjs +257 -0
- package/tests/modules.mjs +35 -14
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.631",
|
|
6
|
+
"core_contract": "1.19.631",
|
|
7
|
+
"source_commit": "8202774d7961f042840d27630f4d0e1a6a5e07e9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-09T23:08:37.908Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 467,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2099,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2590,
|
|
20
|
+
"tree_sha256": "12f67762d32cfaf31bd0af336ddcc207dcacaf27f2be902ae6b5de3aa45e3834",
|
|
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": "
|
|
400
|
+
"sha256": "c109144665c545407dc0a56a8d08e77b277d7a1636ab3740d0e81e43c563f616"
|
|
401
401
|
},
|
|
402
402
|
{
|
|
403
403
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1879,20 +1879,25 @@
|
|
|
1879
1879
|
"mode": "0000644",
|
|
1880
1880
|
"sha256": "0d4805df73af0b99c6dfd1c362debaa04d93a8a075f8e4b0cdf11af975bfc62c"
|
|
1881
1881
|
},
|
|
1882
|
+
{
|
|
1883
|
+
"path": "docs/adr/0272-a-craft-is-an-identity-not-a-toolchain.md",
|
|
1884
|
+
"mode": "0000644",
|
|
1885
|
+
"sha256": "64a49e7f90224dd348da3ac8dce9b75f8585e2c59336f71c3fceb496cb351005"
|
|
1886
|
+
},
|
|
1882
1887
|
{
|
|
1883
1888
|
"path": "docs/adr/README.md",
|
|
1884
1889
|
"mode": "0000644",
|
|
1885
|
-
"sha256": "
|
|
1890
|
+
"sha256": "83e696e6375acfc3d3dd1b27dbe9448a2b4844c9a10bea9af8b74b00442c4681"
|
|
1886
1891
|
},
|
|
1887
1892
|
{
|
|
1888
1893
|
"path": "docs/api-reference.md",
|
|
1889
1894
|
"mode": "0000644",
|
|
1890
|
-
"sha256": "
|
|
1895
|
+
"sha256": "72bfc066767c1c7301516d4f747c8a29725c2d623b5be1d916e6baa11e20a988"
|
|
1891
1896
|
},
|
|
1892
1897
|
{
|
|
1893
1898
|
"path": "docs/api/openapi.json",
|
|
1894
1899
|
"mode": "0000644",
|
|
1895
|
-
"sha256": "
|
|
1900
|
+
"sha256": "ac3c10aa8cc2cdad0d6878a7b2c51bb668b5207a2cd9649ee87da871e190494e"
|
|
1896
1901
|
},
|
|
1897
1902
|
{
|
|
1898
1903
|
"path": "docs/architecture.md",
|
|
@@ -2767,7 +2772,7 @@
|
|
|
2767
2772
|
{
|
|
2768
2773
|
"path": "docs/module-api-changelog.md",
|
|
2769
2774
|
"mode": "0000644",
|
|
2770
|
-
"sha256": "
|
|
2775
|
+
"sha256": "bba1068a4fa47d679635e4b88d714e0246094440aa3df3e1b62e4df16d387aa9"
|
|
2771
2776
|
},
|
|
2772
2777
|
{
|
|
2773
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -2932,7 +2937,7 @@
|
|
|
2932
2937
|
{
|
|
2933
2938
|
"path": "docs/recipes/multi-builder-merge.md",
|
|
2934
2939
|
"mode": "0000644",
|
|
2935
|
-
"sha256": "
|
|
2940
|
+
"sha256": "1770644d6a4ee631d505dbbe4e682f4b6134744746a828a1af503ecc8bb4f759"
|
|
2936
2941
|
},
|
|
2937
2942
|
{
|
|
2938
2943
|
"path": "docs/recipes/ops-gotchas.md",
|
|
@@ -5592,7 +5597,7 @@
|
|
|
5592
5597
|
{
|
|
5593
5598
|
"path": "modules/lifecycle/db-claims.js",
|
|
5594
5599
|
"mode": "0000644",
|
|
5595
|
-
"sha256": "
|
|
5600
|
+
"sha256": "23ed6051b8b9283410f23e10862b6b65361cbf2f95489cd1fc50dfd4584f5c71"
|
|
5596
5601
|
},
|
|
5597
5602
|
{
|
|
5598
5603
|
"path": "modules/lifecycle/db-deps-criteria.js",
|
|
@@ -5627,7 +5632,7 @@
|
|
|
5627
5632
|
{
|
|
5628
5633
|
"path": "modules/lifecycle/db-ship.js",
|
|
5629
5634
|
"mode": "0000644",
|
|
5630
|
-
"sha256": "
|
|
5635
|
+
"sha256": "a7a3f24e8cc01953e09c19c9dcb8f4a5a58bdd31fe8cf0076dd7cbb102491cf2"
|
|
5631
5636
|
},
|
|
5632
5637
|
{
|
|
5633
5638
|
"path": "modules/lifecycle/db-tasks.js",
|
|
@@ -5642,7 +5647,7 @@
|
|
|
5642
5647
|
{
|
|
5643
5648
|
"path": "modules/lifecycle/db.js",
|
|
5644
5649
|
"mode": "0000644",
|
|
5645
|
-
"sha256": "
|
|
5650
|
+
"sha256": "95ff6bc6e9f4ad652dc2aa6bc2c7c520409a2115c714cb4ba867fc80413691e8"
|
|
5646
5651
|
},
|
|
5647
5652
|
{
|
|
5648
5653
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -5797,7 +5802,7 @@
|
|
|
5797
5802
|
{
|
|
5798
5803
|
"path": "modules/lifecycle/routes/claims.js",
|
|
5799
5804
|
"mode": "0000644",
|
|
5800
|
-
"sha256": "
|
|
5805
|
+
"sha256": "8a4f6d5513b97e84c44695b88e42c6e5bdc1875e97202be41caf6c739b2fa42e"
|
|
5801
5806
|
},
|
|
5802
5807
|
{
|
|
5803
5808
|
"path": "modules/lifecycle/routes/closeness.js",
|
|
@@ -7697,12 +7702,12 @@
|
|
|
7697
7702
|
{
|
|
7698
7703
|
"path": "package-lock.json",
|
|
7699
7704
|
"mode": "0000644",
|
|
7700
|
-
"sha256": "
|
|
7705
|
+
"sha256": "cb7b272da5c1fc4642acf70c63ee1781ee34101fe912cc0e907ebb71fd5f1ae3"
|
|
7701
7706
|
},
|
|
7702
7707
|
{
|
|
7703
7708
|
"path": "package.json",
|
|
7704
7709
|
"mode": "0000644",
|
|
7705
|
-
"sha256": "
|
|
7710
|
+
"sha256": "9dba32fee8407356a478c9d5038b7d95db52c1a9d8a9e242f0be79d3c4d79f65"
|
|
7706
7711
|
},
|
|
7707
7712
|
{
|
|
7708
7713
|
"path": "public-docs/index.html",
|
|
@@ -7947,7 +7952,7 @@
|
|
|
7947
7952
|
{
|
|
7948
7953
|
"path": "scripts/gds/claim.js",
|
|
7949
7954
|
"mode": "0000644",
|
|
7950
|
-
"sha256": "
|
|
7955
|
+
"sha256": "d925dfa3831cb3bd16b2ff1761344bd4bedf7d916613fffd9dc5d9574244f525"
|
|
7951
7956
|
},
|
|
7952
7957
|
{
|
|
7953
7958
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -7972,7 +7977,7 @@
|
|
|
7972
7977
|
{
|
|
7973
7978
|
"path": "scripts/gds/cli-lib.js",
|
|
7974
7979
|
"mode": "0000644",
|
|
7975
|
-
"sha256": "
|
|
7980
|
+
"sha256": "c3f5d95c3e586ea711f4e0cef245da9f8106bf4c803aa4c7b2d0025657562680"
|
|
7976
7981
|
},
|
|
7977
7982
|
{
|
|
7978
7983
|
"path": "scripts/gds/client-baseurl-guard.js",
|
|
@@ -8822,7 +8827,7 @@
|
|
|
8822
8827
|
{
|
|
8823
8828
|
"path": "scripts/gds/ship-flow.js",
|
|
8824
8829
|
"mode": "0000644",
|
|
8825
|
-
"sha256": "
|
|
8830
|
+
"sha256": "95b5fa325ea0407dd6cd3d9ae3a03a0a0f0478a0bfcf76d77cf36b13a7c36bea"
|
|
8826
8831
|
},
|
|
8827
8832
|
{
|
|
8828
8833
|
"path": "scripts/gds/ship-git.js",
|
|
@@ -8847,7 +8852,7 @@
|
|
|
8847
8852
|
{
|
|
8848
8853
|
"path": "scripts/gds/ship-io.js",
|
|
8849
8854
|
"mode": "0000644",
|
|
8850
|
-
"sha256": "
|
|
8855
|
+
"sha256": "9db82555be2472f9bd6bdefdfb90411138e767fb7acd10f0d561152c1754426e"
|
|
8851
8856
|
},
|
|
8852
8857
|
{
|
|
8853
8858
|
"path": "scripts/gds/ship-land.js",
|
|
@@ -9442,7 +9447,7 @@
|
|
|
9442
9447
|
{
|
|
9443
9448
|
"path": "src/module-api.js",
|
|
9444
9449
|
"mode": "0000644",
|
|
9445
|
-
"sha256": "
|
|
9450
|
+
"sha256": "f4839dfff1614c47e74577cff4761fb951d17c1702d5bccc77e818348077b9c6"
|
|
9446
9451
|
},
|
|
9447
9452
|
{
|
|
9448
9453
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9477,7 +9482,7 @@
|
|
|
9477
9482
|
{
|
|
9478
9483
|
"path": "src/modules.js",
|
|
9479
9484
|
"mode": "0000644",
|
|
9480
|
-
"sha256": "
|
|
9485
|
+
"sha256": "fa0cd5ae0450512802dca281f92385de95dfee7ca24cb711af0985a588a72cc0"
|
|
9481
9486
|
},
|
|
9482
9487
|
{
|
|
9483
9488
|
"path": "src/platform-server.js",
|
|
@@ -10004,6 +10009,11 @@
|
|
|
10004
10009
|
"mode": "0000644",
|
|
10005
10010
|
"sha256": "d47a0f20617d3bf80cef4d24c816ffa53a045165cd83494a0c8d4c83b7ad16ef"
|
|
10006
10011
|
},
|
|
10012
|
+
{
|
|
10013
|
+
"path": "tests/claim_resolve_worktree_lock.mjs",
|
|
10014
|
+
"mode": "0000644",
|
|
10015
|
+
"sha256": "6e5ff3acb835fd6219136ee2a5e144841076a82733915cc7111688f707464442"
|
|
10016
|
+
},
|
|
10007
10017
|
{
|
|
10008
10018
|
"path": "tests/claims_bind_session.mjs",
|
|
10009
10019
|
"mode": "0000644",
|
|
@@ -11607,7 +11617,7 @@
|
|
|
11607
11617
|
{
|
|
11608
11618
|
"path": "tests/modules.mjs",
|
|
11609
11619
|
"mode": "0000644",
|
|
11610
|
-
"sha256": "
|
|
11620
|
+
"sha256": "09ef08f9ab5ad1927ddfea15202c0cf6820dcff12030a3ff87a4c38718c5fa0f"
|
|
11611
11621
|
},
|
|
11612
11622
|
{
|
|
11613
11623
|
"path": "tests/modules_route.mjs",
|
|
@@ -229,7 +229,7 @@ export interface PostClaimsBatchRequest { task_ids: unknown[]; worktree_name?: s
|
|
|
229
229
|
export interface PostClaimsBatchResponse { claims: unknown }
|
|
230
230
|
export interface PostClaimsBatchValidateRequest { task_ids: unknown[] }
|
|
231
231
|
export interface PostClaimsIdReleaseOnBehalfRequest { reason: string }
|
|
232
|
-
export interface PostClaimsIdResolveRequest { outcome: "shipped" | "abandoned" | "partial"; notes_md?: string; value_summary?: string; verification?: Record<string, unknown>; force?: boolean; committed_files?: string[]; sandbox_review?: Record<string, unknown
|
|
232
|
+
export interface PostClaimsIdResolveRequest { outcome: "shipped" | "abandoned" | "partial"; notes_md?: string; value_summary?: string; verification?: Record<string, unknown>; force?: boolean; committed_files?: string[]; sandbox_review?: Record<string, unknown>; resolver_worktree?: string }
|
|
233
233
|
export interface PostClaimsRequest { task_id: StringifiableId; worktree_name?: string; head_sha?: string; bind_session?: boolean; enforce_touches?: boolean }
|
|
234
234
|
export interface PostClaimsResponse { claim: unknown; task: unknown; just_unlocked: unknown; overlap_warning: unknown; reward: unknown }
|
|
235
235
|
export interface PostConnectionsIdAcceptResponse { status: unknown }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ADR 0272 — A craft is an identity, not a toolchain: `artist` becomes core
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-09-09
|
|
4
|
+
**Context:** BONGOS-V2, goal [1000111](https://cloudbongos.com/builders#/task/1003719) (Working area 7 — Government), task [1003719](https://cloudbongos.com/builders#/task/1003719), re-filed from task 1002908 under [ADR 0265](<redacted>.md). Supersedes the R59 (task 1198) decision that `artist` rides the `art-pipeline` module. Neighbour of [ADR 0098](<redacted>.md) (the publish manifest) and task 1003487 (module-contributed disciplines).
|
|
5
|
+
**Status:** Accepted.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Reported from Discord `#bugs` on 2026-08-15, verbatim: *"cant save preferences of role types in account page"*.
|
|
10
|
+
|
|
11
|
+
Reproduced live on cloudbongos.com 2026-09-09:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
GET /api/gds/me → preferred_disciplines: ["artist","ideator"]
|
|
15
|
+
PATCH /api/gds/me/disciplines → 422 invalid_discipline { bad: ["artist"] }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The API hands a builder a value and then refuses to accept it back. A builder could not save their own stored profile.
|
|
19
|
+
|
|
20
|
+
`db.updateBuilderDisciplines` validates against `modules.enabledDisciplines()` — core disciplines plus every enabled module's `contributes.disciplines`. R59 put `artist` in the module half, on the reasoning that the craft existed to produce the game's pixel art and so belonged to `art-pipeline`.
|
|
21
|
+
|
|
22
|
+
**That gate had quietly stopped existing.** `art-pipeline` was never converted to a discovered module: there is no `modules/art-pipeline/module.json`, so nothing anywhere declares `contributes.disciplines: ['artist']`. Measured on a full repo checkout, with the art pipeline present on disk:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
enabledDisciplines() → ["engineer","ideator","ui"]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
So `artist` was offered by **no instance in any configuration** — not a vanilla one, not a full checkout, not production. It is also unreachable by construction on a *published* core: [ADR 0098](<redacted>.md)'s manifest excludes `modules/art-pipeline/` outright for a hard copyright reason (it carries ripped reference maps), so no registry install could ever carry that manifest even if someone wrote one.
|
|
29
|
+
|
|
30
|
+
Meanwhile the craft stayed first-class in every other core surface:
|
|
31
|
+
|
|
32
|
+
| Surface | Accepts `artist`? |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `builders.preferred_disciplines` CHECK (migrations 086, 176) | yes |
|
|
35
|
+
| `tasks.discipline` (`task-classifier.VALID_DISCIPLINES`) | yes |
|
|
36
|
+
| Help-request craft roster (`help-requests.HELP_CRAFTS`) | yes |
|
|
37
|
+
| `modules.enabledDisciplines()` | **no** |
|
|
38
|
+
|
|
39
|
+
A *task* could be an artist task, and a builder could address a help request to the artist craft — but a *person* could not be an artist.
|
|
40
|
+
|
|
41
|
+
**Why the tests were green over it.** `tests/modules.mjs` did assert the artist leg, behind a guard: `if (!EFFECTIVE['art-pipeline']) return;`. Since the module has no manifest, that check was always false and the only real assertion skipped itself. The suite passed by not running.
|
|
42
|
+
|
|
43
|
+
## Decision
|
|
44
|
+
|
|
45
|
+
**`artist` moves into `CORE_DISCIPLINES`**, which becomes `['engineer', 'artist', 'ideator']` — the migration 176 enum order. Modules still contribute their own (`ui-design` → `ui`), unchanged.
|
|
46
|
+
|
|
47
|
+
The principle: **a craft is an identity, not a toolchain.** It names who a builder is and what work routes to them. An instance can have people who consider themselves artists without shipping a pixel-art pipeline — just as it can have engineers without shipping any particular build tool. Binding the identity to the presence of one optional module's code was the category error, and the copyright exclusion makes that binding permanently unsatisfiable on the only distribution channel instances actually use.
|
|
48
|
+
|
|
49
|
+
The offered set is now a superset of what the DB CHECK accepts, which is the invariant that was violated: **anything the database will store, some instance shape must be able to offer.** `tests/modules.mjs` pins exactly that, over the widest possible module set, with no skip guard.
|
|
50
|
+
|
|
51
|
+
## What was rejected
|
|
52
|
+
|
|
53
|
+
- **Enable `art-pipeline` on cloudbongos.com.** Impossible, not merely undesirable: the module is not in the published package, and `src/modules.js` throws at boot when a config enables an absent module. The owner's intent ("artist should exist here") could not have been delivered this way.
|
|
54
|
+
- **Write the missing `modules/art-pipeline/module.json`.** It would work only in a full repo checkout and never on a published instance, re-creating the same invisible divergence one layer down.
|
|
55
|
+
- **An instance-declared discipline list in `config/modules.json`.** More architecturally tidy on paper — which crafts an instance recognises is host context ([ADR 0062](<redacted>.md) §7) — but it puts the fix in the *instance* repo, leaving the core's own DB CHECK still describing a craft the core cannot offer. It also would not have fixed the vanilla case, where the craft is equally unofferable.
|
|
56
|
+
- **Strip unofferable crafts from stored rows.** This deletes a builder's stated identity to make a validator's mistake look consistent. The owner was asked and chose that artists should exist here.
|
|
57
|
+
|
|
58
|
+
## Consequences
|
|
59
|
+
|
|
60
|
+
- **Every instance now offers `artist`**, including vanilla ones with no art tooling. That is the intended reading of the principle above, and it costs nothing: the DB has accepted the value since migration 086, and an instance that has no artists simply has no one selecting it.
|
|
61
|
+
- **Existing stored values round-trip again.** No migration or backfill: the rows were always valid per the CHECK: only the validator disagreed. Builders holding `artist` (the owner among them) can save their profile without losing it.
|
|
62
|
+
- **The copyright exclusion is untouched.** `modules/art-pipeline/` stays out of the published core; nothing here moves art *tooling* anywhere.
|
|
63
|
+
- **The R59 rationale is formally retired.** Its premise — that the craft exists to serve the game's pixel art — no longer holds: `modules/game/` is likewise excluded from the published core, while the craft remained wired into help requests, task classification and the DB.
|
|
64
|
+
- **Not yet visible to users.** cloudbongos.com serves core 1.19.608; this reaches builders only when the instance pin moves (gate 3 of [ADR 0137](<redacted>.md)).
|
package/docs/adr/README.md
CHANGED
|
@@ -363,3 +363,4 @@ This keeps the decision history honest and traceable.
|
|
|
363
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 |
|
|
364
364
|
| 0270 | [**The rank floor derives from facts, not from a prediction** ([task 1003663](https://cloudbongos.com/builders#/task/1003663) · goal 1000111 — *Working area 7, Government*). [ADR 0084](<redacted>.md) derives every task’s claim-time `requires_rank` floor from `touches[]` + `security_sensitive`; [ADR 0049](<redacted>.md) then retired PREDICTIVE `touches[]`, which is now backfilled from the real committed diff at SHIP — after the claim the floor exists to gate. So on every create vector that omits it the matcher saw an empty array, and because the floor is computed ONCE at creation and never recomputed, it returned `xenos` forever. Nothing threw: an empty `touches[]` is a legal state. **Measured before the fix: 88 tasks declared `needs_migration` and 68 of them (77%) sat at `xenos`** — including [task 1003372](https://cloudbongos.com/builders#/task/1003372), a READY task to hash the `builder_sessions` token store at rest, claimable by any newcomer; goal 1000111 alone held 20 such rows. Two mechanisms hid it: the authoring routes DISAGREE (`POST /goals/:id/tasks` REQUIRES `touches[]` as its scope-wall blast radius, so the floor derives there, while its sibling `POST /tasks` — the route `/goal-create` actually files seed tasks through — treats it as optional), and `goal-create/SKILL.md` told authors in ONE line to *omit `touches[]`* and *never hand-set `requires_rank`*: omit the only input, never set the output. **Decision:** the floor derives from facts a task already carries at create time, never from a prediction — `deriveRequiredRank` gains `opts` with two touches-free inputs, `needs_migration` (the direct successor to the `migrations/` entry `POST /tasks` used to fold into `touches[]`) and `scopeProtected` (the owning goal’s `scope_modules` wall, ALWAYS available since `goal_id` is required, read through the same `scopeIncludesProtected` primitive the pre-push hook, grader and main-audit use, so it cannot drift). Derived at the LOWEST create layer in `createTask` after `goalIdEffective` resolves — the ADR 0096 argument — so every vector inherits it and patching the two routes separately (which is how they came to disagree) is not repeated. `updateTaskRequiresRank` re-derives with the same four inputs, else an Archon setting a migration task to `xenos` would silently undo the create-time floor; raise-only throughout; `archon` still never auto-assigned; a strict `=== true` so a stray truthy value cannot wall off the open queue. One-time backfill `core_236` raises still-CLAIMABLE rows only (the floor is a claim-time gate; rewriting shipped rows changes no live decision) and never lowers. **Accepted cost:** a protected goal wall is blunt — any protected module in the wall floors the whole goal, so a `hall-ui` task in a mixed goal floors at Metic too; same semantics the ideas vector has enforced since task 1003066, fails safe, and the remedy is to narrow the wall. Zero claimable `newcomer_friendly` tasks exist today, and no carve-out was added for them on purpose. Rejected: re-requiring predictive `touches[]` (re-litigates ADR 0049); recomputing at ship (too late by construction); deriving from `module_key` (null on most tasks, including the reporter).](<redacted>.md) | permissions / task lifecycle |
|
|
365
365
|
| 0271 | [**The ideator surface is two halves: Bongos owns the prose, the harness owns the chrome** ([task 1003506](https://cloudbongos.com/builders#/task/1003506) · goal 1000095 — *Working area 6, Governor / Builder / Artist / Ideator experience*, criterion `wa6-kernel-and-packs`; owner idea 1000732, 2026-08-16). The ask — *no need for an ideator to see all the text and technical work, or the inner Bongos logic* — is two problems in one sentence. **The PROSE half ships here**, through three seams that already existed: a `machinery` field on `interaction-prefs` (per-PERSON, default `show`, riding the live Conductor injection rail — advisory 8, which means task [#1002514](https://cloudbongos.com/builders#/task/1002514)’s claim that `contractFor()` has zero consumers is STALE); the `ideator` directive in `discipline-modes.json` (per-ROLE, the file whose own docs say *to give a discipline its own experience later, add an entry here*); and a new surface section in `/ideate`, which the directive names as the only home of the experience. Default `show` means no existing builder changes. **The CHROME half is the harness’s and is deferred to a spike, not declared impossible** — this record was nearly written on the wrong assumption that config cannot reach it. Two real levers exist: **A**, shipped client defaults (`/focus` renders *only your last prompt, a one-line tool-call summary, and the final response*; `alwaysThinkingEnabled:false` stops thinking being generated; `showTurnDuration`/`spinnerTipsEnabled`/`statusLine` trim chrome) — but `/focus` is fullscreen-only (research preview), `viewMode`’s committability is UNVERIFIED, and thinking suppression is per-model; and **B**, an app-owned surface over `claude -p --output-format stream-json`, whose documented streaming-UI pattern (working indicator on a `tool_use` block, render on `result`) IS the ask, is model-independent, and is the recorded bias. **Bypass is REJECTED** despite the idea proposing it, on three grounds ending in a technical one: the least technical role is the worst audience for removing confirmations; the prompt was never the gate ([ADR 0016](<redacted>.md) — rank is checked server-side, and sub-Metic protected-path globs already narrow an ideator); and a project **cannot** ship it — `defaultMode: bypassPermissions` in `.claude/settings.json` silently does not take effect and starts the session in Manual. Rejected also: a hall ROOM on the Studio’s precedent (goal 1000074’s *a place, not a form*) — right for an artist, wrong here, because the ideator’s complaint is about the Claude session’s surface, which the hall does not render; and waiting on per-session declared role, whose dependency is softer than filed since `discipline` + the claim-time fork already carry the craft axis. When declaration lands the contract MOVES into the Ideator pack with nothing undone.](<redacted>.md) | roles / ideator experience / session surface |
|
|
366
|
+
| 0272 | [**A craft is an identity, not a toolchain: `artist` becomes core** ([task 1003719](https://cloudbongos.com/builders#/task/1003719) · goal 1000111 — *Government*). Reported from Discord on 2026-08-15 as "cant save preferences of role types in account page"; reproduced live 2026-09-09: `GET /me` returns `preferred_disciplines: ["artist","ideator"]` and `PATCH /me/disciplines` with that SAME array returns `422 invalid_discipline {bad:["artist"]}` — the API handing a builder a value and refusing to accept it back. **Cause:** `updateBuilderDisciplines` validates against `enabledDisciplines()`, and R59 (task 1198) put `artist` in the module half on the reasoning that the craft existed to make the game pixel art, so it rode `art-pipeline`. **That gate had stopped existing** — art-pipeline was never converted to a discovered module, there is no `modules/art-pipeline/module.json`, and so NOTHING declared `contributes.disciplines: ["artist"]`. Measured on a FULL repo checkout with the pipeline present on disk: `["engineer","ideator","ui"]`. The craft was offered by no instance in any configuration, and is unreachable by construction on a published core anyway, since [ADR 0098](<redacted>.md) excludes `modules/art-pipeline/` for a hard copyright reason (ripped reference maps). Meanwhile it stayed first-class everywhere else: the `builders.preferred_disciplines` CHECK (086/176), `tasks.discipline`, and the help-request craft roster all accept it — so a TASK could be an artist task and a PERSON could not be an artist. **The tests were green because they skipped:** the artist assertion sat behind `if (!EFFECTIVE["art-pipeline"]) return;`, always false, so the only real leg never ran. **Decision:** `artist` joins `CORE_DISCIPLINES` → `["engineer","artist","ideator"]` (migration 176 enum order); modules still contribute their own (ui-design → ui). A craft names WHO A BUILDER IS, not which toolchain ships — an instance can have artists without a pixel-art pipeline, and binding the identity to one optional module was the category error. The new invariant, pinned with no skip guard over the widest module set: anything the DB CHECK will store, some instance shape must offer. Rejected: enabling art-pipeline (impossible — absent from the package, and enabling an absent module throws at boot); writing the missing manifest (works only in a full checkout); an instance-declared discipline list (leaves the core CHECK describing a craft core cannot offer, and does not fix the vanilla case); stripping unofferable crafts from stored rows (deletes a builder stated identity to make a validator mistake look consistent). No migration — the rows were always CHECK-valid; only the validator disagreed.](<redacted>.md) | crafts / instance composition |
|
package/docs/api/openapi.json
CHANGED
|
@@ -19564,6 +19564,10 @@
|
|
|
19564
19564
|
},
|
|
19565
19565
|
"sandbox_review": {
|
|
19566
19566
|
"type": "object"
|
|
19567
|
+
},
|
|
19568
|
+
"resolver_worktree": {
|
|
19569
|
+
"type": "string",
|
|
19570
|
+
"maxLength": 200
|
|
19567
19571
|
}
|
|
19568
19572
|
},
|
|
19569
19573
|
"required": [
|
package/docs/api-reference.md
CHANGED
|
@@ -141,7 +141,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
141
141
|
|---|---|---|---|---|
|
|
142
142
|
| POST | `/api/bongos/claims` | `any-builder` | `task_id`, `worktree_name`, `head_sha`, `bind_session`, `enforce_touches` | own work (claim a ready task); Xenos additionally filtered to newcomer_friendly rows inside db.claimTask (returns rank_too_low_for_task 4… |
|
|
143
143
|
| POST | `/api/bongos/claims/:id/release-on-behalf` | `metic+archon` | `reason` | POST /claims/:id/release-on-behalf — V3.R14 (task 238). |
|
|
144
|
-
| POST | `/api/bongos/claims/:id/resolve` | `any-builder` | `outcome`, `notes_md`, `value_summary`, `verification`, `force`, `committed_files`, `sandbox_review` | resolve own claim (ship/abandon); ownership checked in db.resolveClaim (NOT_YOUR_CLAIM). |
|
|
144
|
+
| POST | `/api/bongos/claims/:id/resolve` | `any-builder` | `outcome`, `notes_md`, `value_summary`, `verification`, `force`, `committed_files`, `sandbox_review`, `resolver_worktree` | resolve own claim (ship/abandon); ownership checked in db.resolveClaim (NOT_YOUR_CLAIM). |
|
|
145
145
|
| POST | `/api/bongos/claims/batch` | `any-builder` | `task_ids`, `worktree_name`, `bind_session` | Atomic multi-claim. |
|
|
146
146
|
| POST | `/api/bongos/claims/batch/release` | `any-builder` | `claim_ids`, `force` | Bulk release (task 1758) — the batch twin of resolve(outcome='abandoned'). |
|
|
147
147
|
| POST | `/api/bongos/claims/batch/validate` | `any-builder` | `task_ids` | Dry-run validate (task 1758) — read-only twin of claimTasksBatch's pre-flight checks. |
|
|
@@ -1707,5 +1707,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1707
1707
|
landed since 1.19.627 with no explicit bump. run 34397967061. (task 1002620)
|
|
1708
1708
|
1.19.629 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1709
1709
|
landed since 1.19.628 with no explicit bump. run 34412159317. (task 1002620)
|
|
1710
|
+
1.19.630 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1711
|
+
landed since 1.19.629 with no explicit bump. run 34415169250. (task 1002620)
|
|
1712
|
+
1.19.631 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1713
|
+
landed since 1.19.630 with no explicit bump. run 34415537907. (task 1002620)
|
|
1710
1714
|
---------------------------------------------------------------------------
|
|
1711
1715
|
```
|
|
@@ -24,7 +24,7 @@ This recipe is the audit deliverable for [#269](https://example.com/builders#/ta
|
|
|
24
24
|
|---|---|---|---|
|
|
25
25
|
| **`main` worktree** (`REPO_ROOT`): checkout, `git fetch`, `git merge`, `git push origin main`, post-merge smoke, `~/deploy.sh` | **Yes — the single hot resource.** One checkout, one HEAD. | **`merge-lock` mutex** (`.git/otb-automerge.lock`) — held by `ship.js` auto-merge AND `/merge-mode`. | This is the surface [#269](https://example.com/builders#/task/269) closes end-to-end. |
|
|
26
26
|
| **`claims` table** (claim/release) | Yes | Postgres txn + `uniq_active_claim_per_task` unique index + touches-overlap check (`db.js claimTask`) | Deterministic. `ALREADY_CLAIMED` / `TOUCHES_CONFLICT` are clean refusals, not corruption. |
|
|
27
|
-
|
|
|
27
|
+
| **The claim's working tree** (`worktree_name`, falling back to `creator_session_id`) | Yes | `CLAIM_SESSION_MISMATCH` (migration 013; re-keyed to the tree by task 1003767, following the same fix to the claim-side guard in task 1002570) | Only the WORKING TREE that created a claim may ship/release it — a ship publishes the whole branch a tree owns, so resolving from elsewhere would land the wrong commits. Keyed on the tree, **not** the auth session: one login is shared by every concurrent worktree and is re-issued by `/builder-reauth`, which used to strand the claim outright. Where no tree is named on either side (a plain clone, or an older CLI sending no `resolver_worktree`) the historical session compare still applies. `force` remains the override. |
|
|
28
28
|
| **`worktree_name` uniqueness** | Yes | Server-side disambiguation in `claimTask` (V3.R51 / [#270](https://example.com/builders#/task/270)) | Two claims with the same worktree name → second gets a suffix. |
|
|
29
29
|
| **`migration_counter`** (migration number allocation) | Yes | `UPDATE … RETURNING` inside the claim txn | Atomic; each claim that signals `migrations/` gets a distinct number. |
|
|
30
30
|
| **Feature worktree** (`.claude/worktrees/<name>`) | No — per-builder | n/a | Each builder's feature branch work is isolated. The collision only appears at merge time, on `main`. |
|
|
@@ -20,7 +20,7 @@ const { goalMembershipFailures } = require('./claim-feed.js');
|
|
|
20
20
|
const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
21
21
|
const { claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName, rankAllowsTask, xenosClaimAllowed } = require('./db-rank-authz.js');
|
|
22
22
|
const { sealSessionLog } = require('./db-shared.js');
|
|
23
|
-
const { needsRebaseTasksForBuilder } = require('./db-ship.js');
|
|
23
|
+
const { needsRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
24
24
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
25
25
|
|
|
26
26
|
// -------------------------------------------------------------------------
|
|
@@ -725,7 +725,7 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
725
725
|
// still releases. Mirrors resolveClaim's per-claim effects (released_at +
|
|
726
726
|
// outcome, task → 'ready', sealSessionLog, actual_minutes recompute) and
|
|
727
727
|
// releaseClaimOnBehalf's row-locked batch-read shape.
|
|
728
|
-
async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, force }) {
|
|
728
|
+
async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, resolverWorktree = null, force }) {
|
|
729
729
|
if (!Array.isArray(claimIds) || claimIds.length === 0) {
|
|
730
730
|
throw { code: 'BATCH_EMPTY' };
|
|
731
731
|
}
|
|
@@ -752,8 +752,19 @@ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, forc
|
|
|
752
752
|
if (!claim) { failures.push({ claim_id: claimId, reason: 'CLAIM_NOT_FOUND' }); continue; }
|
|
753
753
|
if (claim.builder_id !== builderId) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
|
|
754
754
|
if (claim.released_at) { failures.push({ claim_id: claimId, reason: 'ALREADY_RESOLVED' }); continue; }
|
|
755
|
-
|
|
756
|
-
|
|
755
|
+
// task 1003767: the SAME decision resolveClaim uses, not a third copy of
|
|
756
|
+
// the auth-session compare (this was the second of three). Batch release
|
|
757
|
+
// has no CLI caller passing a tree yet, so resolverWorktree is normally
|
|
758
|
+
// null here and the decision falls back to the session compare — today's
|
|
759
|
+
// behaviour exactly. Wired anyway so a re-auth cannot strand a batch
|
|
760
|
+
// release the moment a caller does send one.
|
|
761
|
+
if (shouldRefuseClaimResolve({
|
|
762
|
+
claimSessionId: claim.creator_session_id,
|
|
763
|
+
resolverSessionId,
|
|
764
|
+
claimWorktree: claim.worktree_name,
|
|
765
|
+
resolverWorktree,
|
|
766
|
+
force,
|
|
767
|
+
}).refuse) {
|
|
757
768
|
failures.push({ claim_id: claimId, reason: 'CLAIM_SESSION_MISMATCH', claimWorktree: claim.worktree_name ?? null });
|
|
758
769
|
continue;
|
|
759
770
|
}
|
|
@@ -15,9 +15,75 @@ const { pool, withTx, insertAuditLog } = api;
|
|
|
15
15
|
const log = api.logger('lifecycle');
|
|
16
16
|
const seams = api;
|
|
17
17
|
const doneWhen = require('./done-when.js');
|
|
18
|
-
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes } = require('./db-rank-authz.js');
|
|
18
|
+
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
|
|
19
19
|
const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
20
20
|
|
|
21
|
+
// shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
|
|
22
|
+
//
|
|
23
|
+
// The guard exists to catch a builder shipping the wrong claim from the wrong
|
|
24
|
+
// place: /builder-ship publishes the whole branch a working tree owns, so a
|
|
25
|
+
// resolve arriving from a different tree than the one that did the work lands
|
|
26
|
+
// somebody else's commits. The route has always described it that way ("catches a
|
|
27
|
+
// builder accidentally shipping from the wrong worktree", routes/claims.js).
|
|
28
|
+
//
|
|
29
|
+
// But it was IMPLEMENTED as `creator_session_id !== resolverSessionId`, and that
|
|
30
|
+
// is the AUTH-TOKEN session — one login shared by every concurrent worktree, and
|
|
31
|
+
// re-issued whenever the builder runs /builder-reauth. So re-authing mid-claim
|
|
32
|
+
// made the builder's own claim permanently unresolvable (CLAIM_SESSION_MISMATCH
|
|
33
|
+
// on every ship, with `release --force` — forfeiting graded work — the only exit).
|
|
34
|
+
// A standing "don't run /builder-reauth while holding a claim" warning in the
|
|
35
|
+
// project handoff was the symptom.
|
|
36
|
+
//
|
|
37
|
+
// This is the SAME confusion shouldRefuseSessionClaim (routes/claims.js) was
|
|
38
|
+
// already fixed for; its comment states the rule: "The hazard boundary is the
|
|
39
|
+
// WORKING TREE (one worktree = one branch = one HEAD), NOT the auth session."
|
|
40
|
+
// This is that rule applied to the resolve side.
|
|
41
|
+
//
|
|
42
|
+
// Worktree wins WHERE KNOWN, session is the fallback. When both sides name a
|
|
43
|
+
// tree, that comparison is the whole decision — a re-auth changes the session and
|
|
44
|
+
// is correctly ignored, while a resolve from a genuinely different tree is still
|
|
45
|
+
// refused (the hazard is real, not the guard). When either side does NOT name one
|
|
46
|
+
// — an older CLI that sends no worktree, or a claim made from a plain clone — the
|
|
47
|
+
// tree cannot be compared, so we fall back to the historical session check rather
|
|
48
|
+
// than waving the resolve through. Refusing on the best signal available keeps
|
|
49
|
+
// this a tightening-only change: nothing that was refused before is now allowed
|
|
50
|
+
// except the re-auth case this task exists to fix.
|
|
51
|
+
//
|
|
52
|
+
// Existing skips are unchanged: force=true (the emergency hatch), a claim with no
|
|
53
|
+
// creator_session_id (a web/unbound claim per #616, or one pre-dating the column),
|
|
54
|
+
// and a resolver with no session context at all.
|
|
55
|
+
//
|
|
56
|
+
// Pure + exported so the decision is unit-testable with no DB and no route —
|
|
57
|
+
// the same shape as shouldRefuseSessionClaim. Returns { refuse, reason }.
|
|
58
|
+
function shouldRefuseClaimResolve({
|
|
59
|
+
claimSessionId,
|
|
60
|
+
resolverSessionId,
|
|
61
|
+
claimWorktree = null,
|
|
62
|
+
resolverWorktree = null,
|
|
63
|
+
force = false,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const allow = { refuse: false, reason: null };
|
|
66
|
+
if (force) return allow;
|
|
67
|
+
// Nothing to scope to: an unbound/web claim is adoptable by any of the
|
|
68
|
+
// builder's own sessions, and a session-less resolver predates the check.
|
|
69
|
+
if (!claimSessionId || !resolverSessionId) return allow;
|
|
70
|
+
|
|
71
|
+
// Fold through the SAME normalization every worktree_name ingest point uses,
|
|
72
|
+
// so '' / ' ' / null all mean "no worktree" here exactly as they do in the
|
|
73
|
+
// DB index and in shouldRefuseSessionClaim (task 1002522).
|
|
74
|
+
const claimTree = foldWorktreeName(claimWorktree);
|
|
75
|
+
const resolverTree = foldWorktreeName(resolverWorktree);
|
|
76
|
+
|
|
77
|
+
// Both sides named a working tree → that IS the hazard boundary. Decide on it
|
|
78
|
+
// alone and ignore the session, which is what makes a re-auth survivable.
|
|
79
|
+
if (claimTree && resolverTree) {
|
|
80
|
+
return claimTree === resolverTree ? allow : { refuse: true, reason: 'worktree_mismatch' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Tree unknown on at least one side → fall back to the session compare.
|
|
84
|
+
return claimSessionId === resolverSessionId ? allow : { refuse: true, reason: 'session_mismatch' };
|
|
85
|
+
}
|
|
86
|
+
|
|
21
87
|
// Resolve a claim. Three-state task lifecycle:
|
|
22
88
|
// active → completed (builder declared done; awaiting grader)
|
|
23
89
|
// active → completed → confirmed (promoted by applyGrade when the subagent
|
|
@@ -39,7 +105,7 @@ const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = req
|
|
|
39
105
|
// at resolveClaim time. Both keys stay in the response shape so existing
|
|
40
106
|
// callers (ship.js) don't break.
|
|
41
107
|
|
|
42
|
-
async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary, verification, resolverSessionId, force, committedFiles = null, sandboxReview = null }) {
|
|
108
|
+
async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary, verification, resolverSessionId, resolverWorktree = null, force, committedFiles = null, sandboxReview = null }) {
|
|
43
109
|
return withTx(async (client) => {
|
|
44
110
|
const { rows: claimRows } = await client.query(
|
|
45
111
|
`SELECT c.id, c.task_id, c.builder_id, c.released_at, c.claimed_at,
|
|
@@ -53,13 +119,25 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
|
|
|
53
119
|
const claim = claimRows[0];
|
|
54
120
|
if (claim.builder_id !== builderId) throw { code: 'NOT_YOUR_CLAIM' };
|
|
55
121
|
if (claim.released_at) throw { code: 'ALREADY_RESOLVED' };
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// session
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
// Resolve lock: only the WORKING TREE that created this claim can resolve it
|
|
123
|
+
// (task 1003767) — falling back to the creating session where no tree is
|
|
124
|
+
// named. See shouldRefuseClaimResolve above for why the tree, not the auth
|
|
125
|
+
// session, is the hazard boundary. Skips: force=true, an unbound/web claim,
|
|
126
|
+
// or a resolver with no session context. `reason` distinguishes the two
|
|
127
|
+
// refusal shapes so the CLI can say which one happened.
|
|
128
|
+
const resolveLock = shouldRefuseClaimResolve({
|
|
129
|
+
claimSessionId: claim.creator_session_id,
|
|
130
|
+
resolverSessionId,
|
|
131
|
+
claimWorktree: claim.worktree_name,
|
|
132
|
+
resolverWorktree,
|
|
133
|
+
force,
|
|
134
|
+
});
|
|
135
|
+
if (resolveLock.refuse) {
|
|
136
|
+
throw {
|
|
137
|
+
code: 'CLAIM_SESSION_MISMATCH',
|
|
138
|
+
reason: resolveLock.reason,
|
|
139
|
+
claimWorktree: claim.worktree_name ?? null,
|
|
140
|
+
};
|
|
63
141
|
}
|
|
64
142
|
|
|
65
143
|
await client.query(
|
|
@@ -1149,4 +1227,5 @@ module.exports = {
|
|
|
1149
1227
|
restoreTask,
|
|
1150
1228
|
setClaimPublishedSha,
|
|
1151
1229
|
shipTask,
|
|
1230
|
+
shouldRefuseClaimResolve,
|
|
1152
1231
|
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -187,6 +187,7 @@ const {
|
|
|
187
187
|
restoreTask,
|
|
188
188
|
setClaimPublishedSha,
|
|
189
189
|
shipTask,
|
|
190
|
+
shouldRefuseClaimResolve,
|
|
190
191
|
} = require('./db-ship.js');
|
|
191
192
|
const {
|
|
192
193
|
createOverrideRequest,
|
|
@@ -338,6 +339,7 @@ module.exports = {
|
|
|
338
339
|
releaseClaimsBatch,
|
|
339
340
|
validateClaimBatch,
|
|
340
341
|
resolveClaim,
|
|
342
|
+
shouldRefuseClaimResolve, // task 1003767 — the pure "may this tree resolve this claim?" decision
|
|
341
343
|
confirmTask,
|
|
342
344
|
abandonTask,
|
|
343
345
|
restoreTask, // ADR 0152 — the abandoned→backlog undo that pairs with the Metic+ abandon grant
|
|
@@ -525,6 +525,11 @@ module.exports = function buildClaimsRouter() {
|
|
|
525
525
|
// record { stamp, url, method } when a staged game change was reviewed +
|
|
526
526
|
// approved. The server stamps `by`/`at` authoritatively in db.resolveClaim.
|
|
527
527
|
sandbox_review: { type: 'object' },
|
|
528
|
+
// task 1003767: the working tree this resolve is coming FROM, so the lock
|
|
529
|
+
// can key on the tree instead of the auth session (which a /builder-reauth
|
|
530
|
+
// re-issues, previously stranding the claim). Optional — an older CLI omits
|
|
531
|
+
// it and db.shouldRefuseClaimResolve falls back to the session compare.
|
|
532
|
+
resolver_worktree: { type: 'string', maxLength: LIMITS.WORKTREE_NAME },
|
|
528
533
|
})) return;
|
|
529
534
|
if (!Number.isFinite(Number(req.params.id))) {
|
|
530
535
|
return res.fail('validation_failed', 400, [{ field: 'id', reason: 'wrong_type', expected: 'integer' }]);
|
|
@@ -538,6 +543,7 @@ module.exports = function buildClaimsRouter() {
|
|
|
538
543
|
force,
|
|
539
544
|
committed_files: committedFiles,
|
|
540
545
|
sandbox_review: sandboxReview, // { stamp, url, method } — server stamps by/at
|
|
546
|
+
resolver_worktree: resolverWorktree, // task 1003767 — the tree this resolve comes from
|
|
541
547
|
} = req.body || {};
|
|
542
548
|
try {
|
|
543
549
|
const result = await db.resolveClaim({
|
|
@@ -548,6 +554,12 @@ module.exports = function buildClaimsRouter() {
|
|
|
548
554
|
valueSummary: valueSummary ?? null,
|
|
549
555
|
verification: verification ?? null,
|
|
550
556
|
resolverSessionId: req.gdsSession?.session_id ?? null,
|
|
557
|
+
// task 1003767: the CLI's own working tree. Client-supplied, and used
|
|
558
|
+
// ONLY to compare against the claim's stored worktree_name — it grants
|
|
559
|
+
// nothing on its own. A forged value can at most let the builder resolve
|
|
560
|
+
// THEIR OWN claim (ownership is checked separately, NOT_YOUR_CLAIM),
|
|
561
|
+
// which `release --force` already lets them do anyway.
|
|
562
|
+
resolverWorktree: typeof resolverWorktree === 'string' ? resolverWorktree : null,
|
|
551
563
|
force: force === true,
|
|
552
564
|
committedFiles: Array.isArray(committedFiles) ? committedFiles : null,
|
|
553
565
|
sandboxReview: sandboxReview && typeof sandboxReview === 'object' ? sandboxReview : null,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.631",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.631",
|
|
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.
|
|
3
|
+
"version": "1.19.631",
|
|
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",
|
package/scripts/gds/claim.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const { execSync } = require('node:child_process');
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
|
-
const { cliClient, requireSession, fmtTouches, printOnboardingNudge, rewardPolicy, costPlusOnlyNote } = require('./cli-lib');
|
|
13
|
+
const { cliClient, requireSession, fmtTouches, printOnboardingNudge, rewardPolicy, costPlusOnlyNote, detectWorktreeName } = require('./cli-lib');
|
|
14
14
|
const { nextNumbers, padAdr, padMig } = require('./next-number');
|
|
15
15
|
const { CORE_NS } = require('./migration-namespace');
|
|
16
16
|
const { contextPack, formatContextPack, resolveWorktreeRoot } = require('./context-pack');
|
|
@@ -151,25 +151,9 @@ function captureHeadSha() {
|
|
|
151
151
|
|
|
152
152
|
// Auto-detect this claim's worktree name when --worktree is omitted, so the
|
|
153
153
|
// server's one-active-claim guard (keyed on the WORKING TREE, not the shared auth
|
|
154
|
-
// session) can tell genuinely-parallel worktrees apart.
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// already uses). The MAIN checkout returns null (unchanged behaviour: one claim at
|
|
158
|
-
// a time there — parallelism is what worktrees are for). Best-effort: any git
|
|
159
|
-
// hiccup yields null, so this can never break a claim.
|
|
160
|
-
function detectWorktreeName() {
|
|
161
|
-
try {
|
|
162
|
-
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
163
|
-
const commonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim();
|
|
164
|
-
// Same dir → the main checkout, not a linked worktree → leave unnamed.
|
|
165
|
-
if (path.resolve(gitDir) === path.resolve(commonDir)) return null;
|
|
166
|
-
const top = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
167
|
-
const name = path.basename(top);
|
|
168
|
-
return name || null;
|
|
169
|
-
} catch (_) {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
154
|
+
// session) can tell genuinely-parallel worktrees apart. The detector moved to
|
|
155
|
+
// cli-lib.js in task 1003767 — the ship path now needs the SAME answer to satisfy
|
|
156
|
+
// the resolve lock, and two copies drifting apart would strand a ship.
|
|
173
157
|
|
|
174
158
|
// task 1278: nudge (don't block) when this session's build scripts are behind
|
|
175
159
|
// origin/main. The completion cards + skills are CLI-script features that ride
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -14,6 +14,7 @@ const fs = require('node:fs');
|
|
|
14
14
|
const fsp = require('node:fs/promises');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const os = require('node:os');
|
|
17
|
+
const { execSync } = require('node:child_process');
|
|
17
18
|
const ic = require('../../src/instance-config');
|
|
18
19
|
|
|
19
20
|
// Session token path — the WRITE target is the configured instance dir
|
|
@@ -1101,8 +1102,36 @@ function repairMsysMangledPath(raw, { exepath = process.env.EXEPATH, platform =
|
|
|
1101
1102
|
return rawFwd.slice(best.length);
|
|
1102
1103
|
}
|
|
1103
1104
|
|
|
1105
|
+
// detectWorktreeName — which linked git worktree is this process running in?
|
|
1106
|
+
// Moved here from claim.js in task 1003767 so the CLAIM and the RESOLVE agree on
|
|
1107
|
+
// one answer: the server keys both the one-active-claim guard and (now) the
|
|
1108
|
+
// resolve lock on the WORKING TREE rather than the shared auth session, and two
|
|
1109
|
+
// copies of this detector drifting apart would strand a ship with a name
|
|
1110
|
+
// mismatch. One definition, both callers.
|
|
1111
|
+
//
|
|
1112
|
+
// Only names a LINKED worktree — git-dir != git-common-dir, i.e. a real
|
|
1113
|
+
// `.claude/worktrees/<name>` checkout — using its top-level directory basename
|
|
1114
|
+
// (the `<name>` the convention already uses). The MAIN checkout returns null,
|
|
1115
|
+
// which the server reads as "no tree named" and falls back to the session
|
|
1116
|
+
// compare. Best-effort: any git hiccup yields null, so this can never break a
|
|
1117
|
+
// claim or a ship.
|
|
1118
|
+
function detectWorktreeName() {
|
|
1119
|
+
try {
|
|
1120
|
+
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
1121
|
+
const commonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim();
|
|
1122
|
+
// Same dir → the main checkout, not a linked worktree → leave unnamed.
|
|
1123
|
+
if (path.resolve(gitDir) === path.resolve(commonDir)) return null;
|
|
1124
|
+
const top = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
1125
|
+
const name = path.basename(top);
|
|
1126
|
+
return name || null;
|
|
1127
|
+
} catch (_) {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1104
1132
|
module.exports = {
|
|
1105
1133
|
API_BASE,
|
|
1134
|
+
detectWorktreeName,
|
|
1106
1135
|
hallBase,
|
|
1107
1136
|
productName,
|
|
1108
1137
|
rewardPolicy,
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
11
|
+
const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
|
|
12
12
|
const { resolveProse, markShipProgress, smokeGateMessages } = require('./ship-honesty.js');
|
|
13
13
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
14
|
const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
|
|
@@ -21,7 +21,7 @@ const { gitOk } = require('./ship-git.js');
|
|
|
21
21
|
const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
|
|
22
22
|
const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
|
|
23
23
|
const { buildPriorRoundContext, computeGradeResult, gradeAndRecord, printCreditsAndAchievements, printGradeOutcome } = require('./ship-grade-steps.js');
|
|
24
|
-
const { apiErrorLine, normalizeApiError } = require('./ship-io.js');
|
|
24
|
+
const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./ship-io.js');
|
|
25
25
|
// task 1003205's carve left pushVia() used at regradeMain but never imported, so
|
|
26
26
|
// EVERY resume of a 'completed' task died with ReferenceError before resolving.
|
|
27
27
|
const { pushVia } = require('./ship-deploy-target.js');
|
|
@@ -423,9 +423,7 @@ async function shipMain() {
|
|
|
423
423
|
// It now lands the task at `completed` when verification (smoke) passes, and
|
|
424
424
|
// stops. The v2 subagent grader (step 2 below) is what gates the credits +
|
|
425
425
|
// the completed→confirmed transition.
|
|
426
|
-
const
|
|
427
|
-
id: ac.claim_id,
|
|
428
|
-
body: {
|
|
426
|
+
const resolveBody = {
|
|
429
427
|
outcome: 'shipped',
|
|
430
428
|
notes_md: notes,
|
|
431
429
|
value_summary: summary,
|
|
@@ -445,15 +443,57 @@ async function shipMain() {
|
|
|
445
443
|
// reviewed + approved a staged game change. The server stamps by/at and
|
|
446
444
|
// persists it to tasks.sandbox_review. null = nothing to record.
|
|
447
445
|
sandbox_review: sandboxReview,
|
|
448
|
-
|
|
449
|
-
|
|
446
|
+
// task 1003767: the working tree this ship is running in, so the server's
|
|
447
|
+
// resolve lock can key on the TREE instead of the auth session. Without it
|
|
448
|
+
// a /builder-reauth mid-claim re-issued the session and stranded the ship
|
|
449
|
+
// with `release --force` (forfeiting graded work) the only way out. Same
|
|
450
|
+
// detector claim.js uses, so the two names always agree.
|
|
451
|
+
resolver_worktree: detectWorktreeName(),
|
|
452
|
+
};
|
|
453
|
+
let r = await (await cliClient()).claims.postClaimsIdResolve({ id: ac.claim_id, body: resolveBody });
|
|
454
|
+
|
|
455
|
+
// FORWARD COMPATIBILITY (task 1003767). The resolve route validates STRICTLY:
|
|
456
|
+
// an unknown body field is a hard 400, not an ignored extra. @cloudbongos/cli
|
|
457
|
+
// is published separately from the core and instances pin their own core
|
|
458
|
+
// version, so a CLI carrying a field the server has not learned yet would
|
|
459
|
+
// otherwise be unable to ship AT ALL against that instance — the diff that
|
|
460
|
+
// added `resolver_worktree` hit exactly this on its own ship.
|
|
461
|
+
//
|
|
462
|
+
// So: if the server rejects specifically because it does not know
|
|
463
|
+
// `resolver_worktree`, drop it and retry ONCE. The lock then falls back to
|
|
464
|
+
// the session compare server-side, which is what an older core did anyway —
|
|
465
|
+
// no behaviour is lost that that server ever had.
|
|
466
|
+
//
|
|
467
|
+
// Deliberately narrow: it retries only when the details name THIS field as
|
|
468
|
+
// unknown. Any other validation failure — including an unknown field we did
|
|
469
|
+
// not send on purpose — still surfaces, so this cannot mask a real bug.
|
|
470
|
+
if (!r.ok && isUnknownFieldRejection(r.status, r.data, 'resolver_worktree')) {
|
|
471
|
+
console.error("note: this instance's core predates resolver_worktree — retrying without it.");
|
|
472
|
+
console.error(' (the resolve lock falls back to the session check on that server.)');
|
|
473
|
+
delete resolveBody.resolver_worktree;
|
|
474
|
+
r = await (await cliClient()).claims.postClaimsIdResolve({ id: ac.claim_id, body: resolveBody });
|
|
475
|
+
}
|
|
450
476
|
if (!r.ok) {
|
|
451
477
|
const resolveErr = normalizeApiError(r.data);
|
|
452
478
|
if (resolveErr.code === 'CLAIM_SESSION_MISMATCH') {
|
|
453
479
|
const wt = resolveErr.details.claimWorktree;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
480
|
+
// task 1003767: name WHICH lock fired. 'worktree_mismatch' means the claim
|
|
481
|
+
// really was made in another working tree — the branch this ship would
|
|
482
|
+
// publish is not the branch that did the work, so shipping here would land
|
|
483
|
+
// the wrong commits. That is the guard doing its job, and the fix is to run
|
|
484
|
+
// the ship from the right tree, NOT to force-release. Only a fallback
|
|
485
|
+
// 'session_mismatch' (no tree named on one side — a plain clone, or an
|
|
486
|
+
// older CLI) is the historical session case.
|
|
487
|
+
if (resolveErr.details.reason === 'worktree_mismatch') {
|
|
488
|
+
console.error(`This claim belongs to a different working tree${wt ? ` (worktree: ${wt})` : ''}.`);
|
|
489
|
+
console.error(`Ship it from there — its branch holds the work: cd .claude/worktrees/${wt || '<name>'}`);
|
|
490
|
+
console.error('Shipping from this tree would publish the wrong branch, which is why it is refused.');
|
|
491
|
+
} else {
|
|
492
|
+
console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
|
|
493
|
+
console.error('No working tree was named on one side, so the older session check applied.');
|
|
494
|
+
}
|
|
495
|
+
console.error(`If the work is genuinely gone, give the task back: node scripts/gds/release.js ${taskId} --force`);
|
|
496
|
+
console.error('(--force abandons the claim and forfeits its credits — it is the give-up exit, not the fix.)');
|
|
457
497
|
} else {
|
|
458
498
|
console.error(`resolve failed: ${apiErrorLine(r.data, r.status)}`);
|
|
459
499
|
}
|
package/scripts/gds/ship-io.js
CHANGED
|
@@ -57,6 +57,30 @@ function normalizeApiError(data) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// isUnknownFieldRejection — did the server reject this request ONLY because it
|
|
61
|
+
// does not know the named body field? (task 1003767)
|
|
62
|
+
//
|
|
63
|
+
// WHY THIS EXISTS. The resolve route validates STRICTLY: an unknown body field
|
|
64
|
+
// is a hard 400, not an ignored extra. @cloudbongos/cli ships separately from
|
|
65
|
+
// the core and instances pin their own core version, so a CLI that starts
|
|
66
|
+
// sending a NEW field cannot resolve a claim at all against an instance whose
|
|
67
|
+
// server has not learned it yet — the change that added `resolver_worktree` hit
|
|
68
|
+
// exactly that on its own ship. A caller uses this to drop the field and retry
|
|
69
|
+
// once, degrading to the older server's behaviour instead of failing to ship.
|
|
70
|
+
//
|
|
71
|
+
// Deliberately NARROW: true only when every returned detail names THIS field.
|
|
72
|
+
// If the server also complained about anything else, the request has a real
|
|
73
|
+
// problem and must surface — a blanket "retry without extras" would turn every
|
|
74
|
+
// validation bug into a silent second attempt.
|
|
75
|
+
//
|
|
76
|
+
// Pure + exported so the decision is unit-testable without a server.
|
|
77
|
+
function isUnknownFieldRejection(status, data, field) {
|
|
78
|
+
if (status !== 400) return false;
|
|
79
|
+
const details = normalizeApiError(data).details;
|
|
80
|
+
if (!Array.isArray(details) || details.length === 0) return false;
|
|
81
|
+
return details.every((d) => d && d.field === field);
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
// task 1002789: the ONE renderer for a failed API call on the ship path.
|
|
61
85
|
// Every generic bail site used to hand the raw body to console.error (or
|
|
62
86
|
// interpolate `data.error` into a template, which stringifies the ADR 0116
|
|
@@ -109,6 +133,7 @@ module.exports = {
|
|
|
109
133
|
shipPrefsPath,
|
|
110
134
|
apiErrorLine,
|
|
111
135
|
normalizeApiError,
|
|
136
|
+
isUnknownFieldRejection,
|
|
112
137
|
playShipBell,
|
|
113
138
|
postLandShipOutcome,
|
|
114
139
|
};
|
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.
|
|
58
|
+
const CORE_VERSION = '1.19.631'; // 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');
|
package/src/modules.js
CHANGED
|
@@ -56,13 +56,36 @@ const INSTANCE_PATH = path.join(resolveInstanceRoot(), 'config', 'modules.json')
|
|
|
56
56
|
// moduleLoader from modules/*/module.json.
|
|
57
57
|
const MODULE_REGISTRY = {};
|
|
58
58
|
|
|
59
|
-
// Disciplines the CORE always provides
|
|
60
|
-
//
|
|
61
|
-
// /idea-triage ship to every instance regardless
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
59
|
+
// Disciplines the CORE always provides; modules add to this via
|
|
60
|
+
// `contributes.disciplines` (ui-design → 'ui'). Ideation is methodology core —
|
|
61
|
+
// the idea_inbox, capture.js and /idea-triage ship to every instance regardless
|
|
62
|
+
// of feature modules — so engineer + ideator are unconditional.
|
|
63
|
+
//
|
|
64
|
+
// 'artist' JOINED THEM at task 1003719 (ADR 0271), and the reason is that the
|
|
65
|
+
// R59 gate it used to sit behind had quietly stopped existing. R59 said artist
|
|
66
|
+
// "rides the art-pipeline module" because the craft existed to make the game's
|
|
67
|
+
// pixel art — but art-pipeline was never converted to a discovered module (it
|
|
68
|
+
// has no modules/art-pipeline/module.json), so NOTHING declared
|
|
69
|
+
// contributes.disciplines: ['artist'] and the craft was offered by no instance
|
|
70
|
+
// anywhere, not even a full-repo checkout. It is also unreachable by design on a
|
|
71
|
+
// PUBLISHED core: publish-manifest excludes modules/art-pipeline/ outright for a
|
|
72
|
+
// hard copyright reason (the ripped reference maps), so no registry install can
|
|
73
|
+
// ever carry that manifest even if one were written.
|
|
74
|
+
//
|
|
75
|
+
// Meanwhile the craft stayed first-class in every OTHER core surface: the
|
|
76
|
+
// builders.preferred_disciplines CHECK has accepted it since migration 086,
|
|
77
|
+
// tasks.discipline accepts it (task-classifier VALID_DISCIPLINES), and a builder
|
|
78
|
+
// can address a help request to the artist craft (help-requests HELP_CRAFTS). So
|
|
79
|
+
// a TASK could be an artist task and a PERSON could not be an artist — and any
|
|
80
|
+
// builder already holding it (the owner did) could not save their own profile at
|
|
81
|
+
// all, because updateBuilderDisciplines validates against this list and 422'd
|
|
82
|
+
// their stored value back at them.
|
|
83
|
+
//
|
|
84
|
+
// A craft is an IDENTITY, not a toolchain: an instance can have artists without
|
|
85
|
+
// shipping a pixel-art pipeline. Making it core aligns the one outlier list with
|
|
86
|
+
// the DB CHECK and keeps the copyright exclusion untouched. Order mirrors the
|
|
87
|
+
// migration 176 enum (engineer | artist | ideator | ui).
|
|
88
|
+
const CORE_DISCIPLINES = ['engineer', 'artist', 'ideator'];
|
|
66
89
|
|
|
67
90
|
const KNOWN_MODULES = Object.keys(MODULE_REGISTRY);
|
|
68
91
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// tests/claim_resolve_worktree_lock.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003767 — the resolve lock keys on the WORKING TREE, not the auth session.
|
|
4
|
+
//
|
|
5
|
+
// The guard exists to stop a builder shipping a claim from the wrong place:
|
|
6
|
+
// /builder-ship publishes the whole branch a working tree owns, so a resolve
|
|
7
|
+
// arriving from a different tree lands the wrong commits. It was IMPLEMENTED as
|
|
8
|
+
// `creator_session_id !== resolverSessionId` — the AUTH-TOKEN session, which one
|
|
9
|
+
// login shares across every concurrent worktree and which /builder-reauth
|
|
10
|
+
// RE-ISSUES. So re-authing mid-claim made the builder's own claim permanently
|
|
11
|
+
// unresolvable (CLAIM_SESSION_MISMATCH on every ship, `release --force` the only
|
|
12
|
+
// exit, graded work forfeited). This is the same auth-session-vs-worktree
|
|
13
|
+
// confusion shouldRefuseSessionClaim was already fixed for (task 1002570).
|
|
14
|
+
//
|
|
15
|
+
// What these cases defend, in order of how badly it would hurt to lose it:
|
|
16
|
+
//
|
|
17
|
+
// 1. THE RE-AUTH CASE. Same tree, changed session → ALLOW. This is the bug.
|
|
18
|
+
// 2. THE HAZARD IS STILL GUARDED. Different tree → REFUSE, even on one session.
|
|
19
|
+
// A fix that just deleted the check would pass (1) and silently permit the
|
|
20
|
+
// commingled ship the guard exists for. Both halves are asserted.
|
|
21
|
+
// 3. TIGHTENING-ONLY. Where no tree is named (older CLI, plain clone), the
|
|
22
|
+
// historical session compare still applies — nothing previously refused is
|
|
23
|
+
// now allowed except case (1).
|
|
24
|
+
// 4. THE EXISTING SKIPS survive: force, an unbound/web claim, no resolver
|
|
25
|
+
// session.
|
|
26
|
+
//
|
|
27
|
+
// DB-free, network-free — the decision is pure, the same shape as
|
|
28
|
+
// shouldRefuseSessionClaim.
|
|
29
|
+
// Run: node --test tests/claim_resolve_worktree_lock.mjs
|
|
30
|
+
|
|
31
|
+
import assert from 'node:assert/strict';
|
|
32
|
+
import { test } from 'node:test';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const { shouldRefuseClaimResolve } = require('../modules/lifecycle/db-ship.js');
|
|
37
|
+
|
|
38
|
+
// ── 1) the bug this task exists to fix ──────────────────────────────────────────
|
|
39
|
+
test('re-auth mid-claim: same worktree, NEW session → allowed', () => {
|
|
40
|
+
const d = shouldRefuseClaimResolve({
|
|
41
|
+
claimSessionId: 'sess-before-reauth',
|
|
42
|
+
resolverSessionId: 'sess-AFTER-reauth',
|
|
43
|
+
claimWorktree: 'task-1003767',
|
|
44
|
+
resolverWorktree: 'task-1003767',
|
|
45
|
+
});
|
|
46
|
+
assert.equal(d.refuse, false, '/builder-reauth must not strand the claim it was run beside');
|
|
47
|
+
assert.equal(d.reason, null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('same worktree, same session → allowed (the ordinary happy path)', () => {
|
|
51
|
+
const d = shouldRefuseClaimResolve({
|
|
52
|
+
claimSessionId: 'sess-1',
|
|
53
|
+
resolverSessionId: 'sess-1',
|
|
54
|
+
claimWorktree: 'task-42',
|
|
55
|
+
resolverWorktree: 'task-42',
|
|
56
|
+
});
|
|
57
|
+
assert.equal(d.refuse, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── 2) the hazard the guard exists for is STILL refused ─────────────────────────
|
|
61
|
+
test('different worktree → refused, even when the session matches', () => {
|
|
62
|
+
const d = shouldRefuseClaimResolve({
|
|
63
|
+
claimSessionId: 'sess-1',
|
|
64
|
+
resolverSessionId: 'sess-1',
|
|
65
|
+
claimWorktree: 'task-100',
|
|
66
|
+
resolverWorktree: 'task-999',
|
|
67
|
+
});
|
|
68
|
+
assert.equal(d.refuse, true, 'shipping from another tree would publish the wrong branch');
|
|
69
|
+
assert.equal(d.reason, 'worktree_mismatch');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('different worktree AND different session → refused as a worktree mismatch', () => {
|
|
73
|
+
// Both signals disagree; the tree is the one that names the real hazard, so the
|
|
74
|
+
// reason must be the worktree one (the CLI branches its advice on it).
|
|
75
|
+
const d = shouldRefuseClaimResolve({
|
|
76
|
+
claimSessionId: 'sess-1',
|
|
77
|
+
resolverSessionId: 'sess-2',
|
|
78
|
+
claimWorktree: 'task-100',
|
|
79
|
+
resolverWorktree: 'task-999',
|
|
80
|
+
});
|
|
81
|
+
assert.equal(d.refuse, true);
|
|
82
|
+
assert.equal(d.reason, 'worktree_mismatch');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── 3) tightening-only: the session fallback where no tree is named ──────────────
|
|
86
|
+
test('no tree on either side, different sessions → still refused (historical check)', () => {
|
|
87
|
+
const d = shouldRefuseClaimResolve({
|
|
88
|
+
claimSessionId: 'sess-1',
|
|
89
|
+
resolverSessionId: 'sess-2',
|
|
90
|
+
claimWorktree: null,
|
|
91
|
+
resolverWorktree: null,
|
|
92
|
+
});
|
|
93
|
+
assert.equal(d.refuse, true, 'a plain clone keeps the old session guard — this is not a loosening');
|
|
94
|
+
assert.equal(d.reason, 'session_mismatch');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('no tree on either side, same session → allowed', () => {
|
|
98
|
+
const d = shouldRefuseClaimResolve({
|
|
99
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: null, resolverWorktree: null,
|
|
100
|
+
});
|
|
101
|
+
assert.equal(d.refuse, false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('claim named a tree but the resolver did not (older CLI) → session decides', () => {
|
|
105
|
+
// An older /builder-ship sends no resolver_worktree. It must not be able to
|
|
106
|
+
// resolve another session's claim just by omitting the field.
|
|
107
|
+
const refused = shouldRefuseClaimResolve({
|
|
108
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-2', claimWorktree: 'task-7', resolverWorktree: null,
|
|
109
|
+
});
|
|
110
|
+
assert.equal(refused.refuse, true, 'omitting the tree must not be a way around the lock');
|
|
111
|
+
assert.equal(refused.reason, 'session_mismatch');
|
|
112
|
+
|
|
113
|
+
const allowed = shouldRefuseClaimResolve({
|
|
114
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: 'task-7', resolverWorktree: null,
|
|
115
|
+
});
|
|
116
|
+
assert.equal(allowed.refuse, false, 'an older CLI on its own session still ships fine');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('resolver named a tree but the claim did not → session decides', () => {
|
|
120
|
+
const d = shouldRefuseClaimResolve({
|
|
121
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-2', claimWorktree: null, resolverWorktree: 'task-7',
|
|
122
|
+
});
|
|
123
|
+
assert.equal(d.refuse, true);
|
|
124
|
+
assert.equal(d.reason, 'session_mismatch');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── 4) the pre-existing skips are unchanged ─────────────────────────────────────
|
|
128
|
+
test('force=true skips the lock entirely (the emergency hatch)', () => {
|
|
129
|
+
const d = shouldRefuseClaimResolve({
|
|
130
|
+
claimSessionId: 'sess-1',
|
|
131
|
+
resolverSessionId: 'sess-2',
|
|
132
|
+
claimWorktree: 'task-100',
|
|
133
|
+
resolverWorktree: 'task-999',
|
|
134
|
+
force: true,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(d.refuse, false, 'force must still override even a genuine worktree mismatch');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('an unbound/web claim (no creator session) is adoptable by any own session', () => {
|
|
140
|
+
// #616: a claim made in the browser has no working session. Binding it would
|
|
141
|
+
// force every such builder to release --force before they could ship.
|
|
142
|
+
const d = shouldRefuseClaimResolve({
|
|
143
|
+
claimSessionId: null, resolverSessionId: 'sess-cli', claimWorktree: null, resolverWorktree: 'task-7',
|
|
144
|
+
});
|
|
145
|
+
assert.equal(d.refuse, false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a resolver with no session context is allowed (legacy path)', () => {
|
|
149
|
+
const d = shouldRefuseClaimResolve({
|
|
150
|
+
claimSessionId: 'sess-1', resolverSessionId: null, claimWorktree: 'task-7', resolverWorktree: 'task-9',
|
|
151
|
+
});
|
|
152
|
+
assert.equal(d.refuse, false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── 5) normalization agrees with every other worktree_name ingest point ─────────
|
|
156
|
+
// foldWorktreeName (task 1002522) is the ONE normalization: '' and whitespace-only
|
|
157
|
+
// both mean "no worktree", matching the DB index's COALESCE(worktree_name, '').
|
|
158
|
+
// If this decision folded differently it could allow a resolve the index-backed
|
|
159
|
+
// claim guard considers a different tree.
|
|
160
|
+
test("'' and whitespace-only fold to 'no tree named', not to a tree called ''", () => {
|
|
161
|
+
const blank = shouldRefuseClaimResolve({
|
|
162
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-2', claimWorktree: '', resolverWorktree: '',
|
|
163
|
+
});
|
|
164
|
+
assert.equal(blank.refuse, true, "two ''-named sides are worktree-LESS, so the session compare applies");
|
|
165
|
+
assert.equal(blank.reason, 'session_mismatch');
|
|
166
|
+
|
|
167
|
+
const spaces = shouldRefuseClaimResolve({
|
|
168
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: ' ', resolverWorktree: 'task-7',
|
|
169
|
+
});
|
|
170
|
+
assert.equal(spaces.refuse, false, 'whitespace-only is not a tree name; same session still ships');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('surrounding whitespace is trimmed, not treated as a different tree', () => {
|
|
174
|
+
const d = shouldRefuseClaimResolve({
|
|
175
|
+
claimSessionId: 'sess-A',
|
|
176
|
+
resolverSessionId: 'sess-B',
|
|
177
|
+
claimWorktree: 'task-1003767',
|
|
178
|
+
resolverWorktree: ' task-1003767 ',
|
|
179
|
+
});
|
|
180
|
+
assert.equal(d.refuse, false, 'an untrimmed name must not strand a ship on a phantom mismatch');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('tree comparison is exact, not a prefix match', () => {
|
|
184
|
+
const d = shouldRefuseClaimResolve({
|
|
185
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: 'task-100', resolverWorktree: 'task-1000',
|
|
186
|
+
});
|
|
187
|
+
assert.equal(d.refuse, true, 'task-1000 is a different tree from task-100');
|
|
188
|
+
assert.equal(d.reason, 'worktree_mismatch');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── 6) degrades safely on odd input (never throws) ──────────────────────────────
|
|
192
|
+
test('no arguments at all does not throw and allows (nothing to scope to)', () => {
|
|
193
|
+
const d = shouldRefuseClaimResolve();
|
|
194
|
+
assert.equal(d.refuse, false);
|
|
195
|
+
assert.equal(d.reason, null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── 7) forward compatibility: a new CLI must still ship against an OLDER core ───
|
|
199
|
+
// The resolve route validates STRICTLY — an unknown body field is a hard 400, not
|
|
200
|
+
// an ignored extra (routes/_helpers.js validate(), strict defaults on). The core
|
|
201
|
+
// ships separately from @cloudbongos/cli and instances pin their own version, so
|
|
202
|
+
// a CLI that starts sending resolver_worktree cannot resolve AT ALL against a
|
|
203
|
+
// server that has not learned it. This was not theoretical: the very ship of this
|
|
204
|
+
// change was refused `validation_failed [HTTP 400]` by prod, which runs an older
|
|
205
|
+
// core. The CLI drops the field and retries once; these pin that decision.
|
|
206
|
+
const { isUnknownFieldRejection } = require('../scripts/gds/ship-io.js');
|
|
207
|
+
|
|
208
|
+
// The EXACT envelope prod returned on the refused ship (ADR 0116 shape, with
|
|
209
|
+
// reason 'unknown_field' from validate()'s strict branch).
|
|
210
|
+
const oldCoreRejection = {
|
|
211
|
+
error: {
|
|
212
|
+
code: 'validation_failed',
|
|
213
|
+
message: 'validation_failed',
|
|
214
|
+
details: [{ field: 'resolver_worktree', reason: 'unknown_field' }],
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
test('an older core rejecting only resolver_worktree triggers the retry', () => {
|
|
219
|
+
assert.equal(isUnknownFieldRejection(400, oldCoreRejection, 'resolver_worktree'), true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a rejection naming OTHER fields too must NOT be retried away', () => {
|
|
223
|
+
// A real validation bug alongside ours has to surface, not be masked by a
|
|
224
|
+
// silent second attempt that drops one field and fails again for the rest.
|
|
225
|
+
const mixed = {
|
|
226
|
+
error: {
|
|
227
|
+
code: 'validation_failed',
|
|
228
|
+
details: [
|
|
229
|
+
{ field: 'resolver_worktree', reason: 'unknown_field' },
|
|
230
|
+
{ field: 'outcome', reason: 'missing' },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
assert.equal(isUnknownFieldRejection(400, mixed, 'resolver_worktree'), false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a rejection about a DIFFERENT field alone is not our case', () => {
|
|
238
|
+
const other = {
|
|
239
|
+
error: { code: 'validation_failed', details: [{ field: 'notes_md', reason: 'too_long' }] },
|
|
240
|
+
};
|
|
241
|
+
assert.equal(isUnknownFieldRejection(400, other, 'resolver_worktree'), false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('only a 400 counts — a 403/409/500 is never an unknown-field rejection', () => {
|
|
245
|
+
for (const status of [403, 409, 500, 200]) {
|
|
246
|
+
assert.equal(isUnknownFieldRejection(status, oldCoreRejection, 'resolver_worktree'), false,
|
|
247
|
+
`status ${status} must not trigger the retry`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('an empty or malformed details payload does not trigger the retry', () => {
|
|
252
|
+
assert.equal(isUnknownFieldRejection(400, { error: { code: 'validation_failed', details: [] } }, 'resolver_worktree'), false,
|
|
253
|
+
'no details means we cannot attribute the failure to our field');
|
|
254
|
+
assert.equal(isUnknownFieldRejection(400, {}, 'resolver_worktree'), false);
|
|
255
|
+
assert.equal(isUnknownFieldRejection(400, null, 'resolver_worktree'), false);
|
|
256
|
+
assert.equal(isUnknownFieldRejection(400, { error: 'flat_string_error' }, 'resolver_worktree'), false);
|
|
257
|
+
});
|
package/tests/modules.mjs
CHANGED
|
@@ -127,17 +127,37 @@ test('isModuleEnabled throws on an unknown module name (programmer error)', () =
|
|
|
127
127
|
assert.throws(() => isModuleEnabled('nope', resolveModules({})), /unknown module/);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
test('disciplines:
|
|
130
|
+
test('disciplines: engineer + artist + ideator are CORE — offered with no modules on', () => {
|
|
131
131
|
// Ideation is methodology core (idea_inbox / capture / triage ship to every
|
|
132
|
-
// instance)
|
|
133
|
-
//
|
|
134
|
-
assert.deepEqual(enabledDisciplines(resolveModules({})), ['engineer', 'ideator']);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
// instance). 'artist' joined them at task 1003719 / ADR 0271 — see the
|
|
133
|
+
// CORE_DISCIPLINES comment for why the R59 module gate had stopped existing.
|
|
134
|
+
assert.deepEqual(enabledDisciplines(resolveModules({})), ['engineer', 'artist', 'ideator']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('THE REGRESSION: every craft the DB CHECK accepts is offered by SOME instance shape', () => {
|
|
138
|
+
// The defect this pins (task 1003719): 'artist' was in the
|
|
139
|
+
// builders.preferred_disciplines CHECK (migration 086), in tasks.discipline and
|
|
140
|
+
// in the help-request craft roster, but in NO instance's offered set — so a
|
|
141
|
+
// builder holding it could not save their own profile, and the API 422'd the
|
|
142
|
+
// exact value /me had just handed them.
|
|
143
|
+
//
|
|
144
|
+
// It survived because the old test guarded its only real assertion behind
|
|
145
|
+
// `if (!EFFECTIVE['art-pipeline']) return;`. art-pipeline has no module.json,
|
|
146
|
+
// so that leg skipped and the suite stayed green over an unofferable craft.
|
|
147
|
+
// This test takes no such escape hatch: vanilla + every discovered module is
|
|
148
|
+
// the widest set any instance can offer, and the DB's own enum is the standard
|
|
149
|
+
// it must meet.
|
|
150
|
+
const widest = new Set(enabledDisciplines(resolveModules({ instance: { modules: Object.fromEntries(
|
|
151
|
+
Object.keys(EFFECTIVE).map((k) => [k, true])
|
|
152
|
+
) }, registry: EFFECTIVE })));
|
|
153
|
+
// Mirrors the CHECK on builders.preferred_disciplines (migrations 086 + 176).
|
|
154
|
+
for (const craft of ['engineer', 'artist', 'ideator', 'ui']) {
|
|
155
|
+
assert.ok(
|
|
156
|
+
widest.has(craft),
|
|
157
|
+
`the DB accepts preferred_disciplines='${craft}' but no instance shape offers it — ` +
|
|
158
|
+
'updateBuilderDisciplines will 422 a builder who already has it stored'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
141
161
|
});
|
|
142
162
|
|
|
143
163
|
test('disciplines: ui-design contributes `ui` — the manifest key IS the unlock (task 1003487)', () => {
|
|
@@ -195,8 +215,9 @@ test('a branded sibling (GDS_BRANDING_FILE set, no GDS_MODULES_FILE) defaults to
|
|
|
195
215
|
// A non-default brand must fall through to the neutral (all-off) module set.
|
|
196
216
|
const m = loadModules({ env: { GDS_BRANDING_FILE: 'config/branding.cloudbongos.json' } });
|
|
197
217
|
for (const k of KNOWN_MODULES) assert.equal(m[k], false, `${k} must be off for a branded sibling`);
|
|
198
|
-
// The
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
218
|
+
// The three core crafts (engineer + artist + ideator — task 1003719 / ADR 0271),
|
|
219
|
+
// plus 'ui' from ui-design, which is default-on for EVERY instance ("every
|
|
220
|
+
// instance has a face") and is not one of KNOWN_MODULES, so the all-off
|
|
221
|
+
// assertion above never reaches it (task 1003487).
|
|
222
|
+
assert.deepEqual(enabledDisciplines(m), ['engineer', 'artist', 'ideator', 'ui']);
|
|
202
223
|
});
|