@bongos/core 1.19.628 → 1.19.630

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.628",
6
- "core_contract": "1.19.628",
7
- "source_commit": "4f99983713ad375f7f42c932c781c3c77a47bc66",
5
+ "core_version": "1.19.630",
6
+ "core_contract": "1.19.630",
7
+ "source_commit": "8ec5a34a79092057e64af1c4c3762d0b3901efb9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-09T19:56:16.788Z",
9
+ "built_at": "2026-09-09T23:03:56.905Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 466,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2098,
14
+ "functional_verbatim": 2099,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2588,
20
- "tree_sha256": "ab072d3e169daecbd6fbfc4d2e18d2786ea247cb5b84a4d4d5dc06cf17b0c210",
19
+ "file_count": 2589,
20
+ "tree_sha256": "9937af1178174c35ad4b6fd94cc34d318d04bbc74ec18d9f469b0ba163740e05",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -332,7 +332,7 @@
332
332
  {
333
333
  "path": ".gitattributes",
334
334
  "mode": "0000644",
335
- "sha256": "7f5faf22678ad182025b2f3f9107c209f92e461200e15665fa2feb4a3cd86858"
335
+ "sha256": "83c94b055dc619fdbf3bc1142fc5357810d901e4200644f1fb399e6939571c5f"
336
336
  },
337
337
  {
338
338
  "path": "CLAUDE.md",
@@ -397,7 +397,7 @@
397
397
  {
398
398
  "path": "clients/bongos-client/index.d.ts",
399
399
  "mode": "0000644",
400
- "sha256": "07caf5c1db2cfd16c92713891055b477982aef4dd847af67042b742293e6a9ce"
400
+ "sha256": "c109144665c545407dc0a56a8d08e77b277d7a1636ab3740d0e81e43c563f616"
401
401
  },
402
402
  {
403
403
  "path": "clients/bongos-client/index.mjs",
@@ -1887,12 +1887,12 @@
1887
1887
  {
1888
1888
  "path": "docs/api-reference.md",
1889
1889
  "mode": "0000644",
1890
- "sha256": "8adebe11d51d4bbce7e5acacc003ce923e820f0d8532541d3f5a74ed07570ce8"
1890
+ "sha256": "72bfc066767c1c7301516d4f747c8a29725c2d623b5be1d916e6baa11e20a988"
1891
1891
  },
1892
1892
  {
1893
1893
  "path": "docs/api/openapi.json",
1894
1894
  "mode": "0000644",
1895
- "sha256": "2059cb9b02d6a20e4b1297c866db9f717bb3514ec49ab2454c776c806775d3da"
1895
+ "sha256": "ac3c10aa8cc2cdad0d6878a7b2c51bb668b5207a2cd9649ee87da871e190494e"
1896
1896
  },
1897
1897
  {
1898
1898
  "path": "docs/architecture.md",
@@ -2767,7 +2767,7 @@
2767
2767
  {
2768
2768
  "path": "docs/module-api-changelog.md",
2769
2769
  "mode": "0000644",
2770
- "sha256": "f0bda08beedf16b6f2eed29c598f4a7602c35e84f084ca744861c6242656fe01"
2770
+ "sha256": "d70ae49d22a6ae226ad46e3c8d5fbaa0954a8d14697cdb96760f42bc24be7ced"
2771
2771
  },
2772
2772
  {
2773
2773
  "path": "docs/modules-contract.md",
@@ -2932,7 +2932,7 @@
2932
2932
  {
2933
2933
  "path": "docs/recipes/multi-builder-merge.md",
2934
2934
  "mode": "0000644",
2935
- "sha256": "0f7f91a22ab5b689ab733cac5615ace8068ef8f98c8ab7ef9bb61313c16a3122"
2935
+ "sha256": "1770644d6a4ee631d505dbbe4e682f4b6134744746a828a1af503ecc8bb4f759"
2936
2936
  },
2937
2937
  {
2938
2938
  "path": "docs/recipes/ops-gotchas.md",
@@ -3012,7 +3012,7 @@
3012
3012
  {
3013
3013
  "path": "docs/recipes/windows-builders.md",
3014
3014
  "mode": "0000644",
3015
- "sha256": "229da205775c6b84534633bc15be7355337d0d8cfacd81ff03860bc24bfc783f"
3015
+ "sha256": "b7d849d1786c7eca4156969d38343425839bef3bae19a10a94b5e54916e3ea09"
3016
3016
  },
3017
3017
  {
3018
3018
  "path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
@@ -5592,7 +5592,7 @@
5592
5592
  {
5593
5593
  "path": "modules/lifecycle/db-claims.js",
5594
5594
  "mode": "0000644",
5595
- "sha256": "dbce7de39b91cdd1843ba26efc3f3be76690a7112aaa3b1b0406a9b9f98dfb32"
5595
+ "sha256": "23ed6051b8b9283410f23e10862b6b65361cbf2f95489cd1fc50dfd4584f5c71"
5596
5596
  },
5597
5597
  {
5598
5598
  "path": "modules/lifecycle/db-deps-criteria.js",
@@ -5627,7 +5627,7 @@
5627
5627
  {
5628
5628
  "path": "modules/lifecycle/db-ship.js",
5629
5629
  "mode": "0000644",
5630
- "sha256": "e48d877a5951f02153c7384217421b2b04a3a1fed215b61ac41fc5b0b8355ded"
5630
+ "sha256": "a7a3f24e8cc01953e09c19c9dcb8f4a5a58bdd31fe8cf0076dd7cbb102491cf2"
5631
5631
  },
5632
5632
  {
5633
5633
  "path": "modules/lifecycle/db-tasks.js",
@@ -5642,7 +5642,7 @@
5642
5642
  {
5643
5643
  "path": "modules/lifecycle/db.js",
5644
5644
  "mode": "0000644",
5645
- "sha256": "af6af95c05aa6cc76c0e49cb0a99bfb24685b3efb66df0f90883c3301baeede0"
5645
+ "sha256": "95ff6bc6e9f4ad652dc2aa6bc2c7c520409a2115c714cb4ba867fc80413691e8"
5646
5646
  },
5647
5647
  {
5648
5648
  "path": "modules/lifecycle/dead-deps.js",
@@ -5797,7 +5797,7 @@
5797
5797
  {
5798
5798
  "path": "modules/lifecycle/routes/claims.js",
5799
5799
  "mode": "0000644",
5800
- "sha256": "6bc32c6ae4029748d6099701f25fc17d5875b2ff005de5ad2cacbc023092c32c"
5800
+ "sha256": "8a4f6d5513b97e84c44695b88e42c6e5bdc1875e97202be41caf6c739b2fa42e"
5801
5801
  },
5802
5802
  {
5803
5803
  "path": "modules/lifecycle/routes/closeness.js",
@@ -7697,12 +7697,12 @@
7697
7697
  {
7698
7698
  "path": "package-lock.json",
7699
7699
  "mode": "0000644",
7700
- "sha256": "aba13c3ae1fa839b97bc3f11aff342251f550f7cbcc8531a9ae3c6856f326d6e"
7700
+ "sha256": "8ce2c5dbcf03a0638065b823be4fd0bab26ed8695ba9693bc99b090ef6e0f237"
7701
7701
  },
7702
7702
  {
7703
7703
  "path": "package.json",
7704
7704
  "mode": "0000644",
7705
- "sha256": "bdea58839926be753b49b016fc961cff55481f292f911d9fa044e77db022db21"
7705
+ "sha256": "94be508ef7535b26470470845f7631d438c0716cba55a09a5de676eb65ca2cad"
7706
7706
  },
7707
7707
  {
7708
7708
  "path": "public-docs/index.html",
@@ -7947,7 +7947,7 @@
7947
7947
  {
7948
7948
  "path": "scripts/gds/claim.js",
7949
7949
  "mode": "0000644",
7950
- "sha256": "7c81b6c9e85aab5f230f72a4638402061fa516d7bb9fb7997b1151bef5b117e6"
7950
+ "sha256": "d925dfa3831cb3bd16b2ff1761344bd4bedf7d916613fffd9dc5d9574244f525"
7951
7951
  },
7952
7952
  {
7953
7953
  "path": "scripts/gds/classify-discipline.js",
@@ -7972,7 +7972,7 @@
7972
7972
  {
7973
7973
  "path": "scripts/gds/cli-lib.js",
7974
7974
  "mode": "0000644",
7975
- "sha256": "eaa4c3a172009d5ce825e528f5077a480e034658501a7d0c532a7807886beb9e"
7975
+ "sha256": "c3f5d95c3e586ea711f4e0cef245da9f8106bf4c803aa4c7b2d0025657562680"
7976
7976
  },
7977
7977
  {
7978
7978
  "path": "scripts/gds/client-baseurl-guard.js",
@@ -8012,7 +8012,7 @@
8012
8012
  {
8013
8013
  "path": "scripts/gds/copy-inventory.js",
8014
8014
  "mode": "0000644",
8015
- "sha256": "8c798359b51abdd749233e65aa5906732bccc5b9088dae32513cde7949404b6c"
8015
+ "sha256": "7269164648fc1d00e1bf3272a82f933a5f8da228e562e00848dbc29c671d3e94"
8016
8016
  },
8017
8017
  {
8018
8018
  "path": "scripts/gds/coreB-carve-proof.sh",
@@ -8822,7 +8822,7 @@
8822
8822
  {
8823
8823
  "path": "scripts/gds/ship-flow.js",
8824
8824
  "mode": "0000644",
8825
- "sha256": "9cc28796753779cc054036bbaae841ff798d27c92ad98e37ff877662b3f063ce"
8825
+ "sha256": "95b5fa325ea0407dd6cd3d9ae3a03a0a0f0478a0bfcf76d77cf36b13a7c36bea"
8826
8826
  },
8827
8827
  {
8828
8828
  "path": "scripts/gds/ship-git.js",
@@ -8847,7 +8847,7 @@
8847
8847
  {
8848
8848
  "path": "scripts/gds/ship-io.js",
8849
8849
  "mode": "0000644",
8850
- "sha256": "d881d3de837a0d13f2fcaddac7aec17969e8c2d395db238fe9bf257a0925deff"
8850
+ "sha256": "9db82555be2472f9bd6bdefdfb90411138e767fb7acd10f0d561152c1754426e"
8851
8851
  },
8852
8852
  {
8853
8853
  "path": "scripts/gds/ship-land.js",
@@ -9442,7 +9442,7 @@
9442
9442
  {
9443
9443
  "path": "src/module-api.js",
9444
9444
  "mode": "0000644",
9445
- "sha256": "f14dc0f62bb0abba340f775a22b7810b56eacc91c29015a9712f7d9afd57f0ac"
9445
+ "sha256": "9f6fac692c0299219829477c40982314b29fc7fa1854a3544b6cc104ac97791a"
9446
9446
  },
9447
9447
  {
9448
9448
  "path": "src/module-loader/catalog.js",
@@ -10004,6 +10004,11 @@
10004
10004
  "mode": "0000644",
10005
10005
  "sha256": "d47a0f20617d3bf80cef4d24c816ffa53a045165cd83494a0c8d4c83b7ad16ef"
10006
10006
  },
10007
+ {
10008
+ "path": "tests/claim_resolve_worktree_lock.mjs",
10009
+ "mode": "0000644",
10010
+ "sha256": "6e5ff3acb835fd6219136ee2a5e144841076a82733915cc7111688f707464442"
10011
+ },
10007
10012
  {
10008
10013
  "path": "tests/claims_bind_session.mjs",
10009
10014
  "mode": "0000644",
@@ -10137,7 +10142,7 @@
10137
10142
  {
10138
10143
  "path": "tests/copy_inventory.mjs",
10139
10144
  "mode": "0000644",
10140
- "sha256": "97ff5dac1f7bef96cf0f80082e8f6ebb2cea30f3c371c3ab080e9189818fb5fd"
10145
+ "sha256": "716aa03c5e93b71aec0c7e6fe646e5abd702b8b6ffda0ef6235a804d2c17a0ca"
10141
10146
  },
10142
10147
  {
10143
10148
  "path": "tests/cosmos_token_contrast.mjs",
@@ -10517,7 +10522,7 @@
10517
10522
  {
10518
10523
  "path": "tests/git_merge_regen.mjs",
10519
10524
  "mode": "0000644",
10520
- "sha256": "1d5f97589b84b493f7406fba3b162443a70fbf0ad24190049d054dbd2fe17273"
10525
+ "sha256": "c6cb1bda6809d8d384a9c35b376995af038d18c26b1b935cb5c0faebb163b72e"
10521
10526
  },
10522
10527
  {
10523
10528
  "path": "tests/github_push_ancestry.mjs",
package/.gitattributes CHANGED
@@ -85,8 +85,18 @@ src/bongos/routes/CLAUDE.md merge=otb-regen
85
85
  # files. Same otb-regen shape as docs/session-log-index.md above (whole-file
86
86
  # rebuild, all three merge sides ignored); REGENERABLE in git-merge-regen.js
87
87
  # carries the matching rule.
88
+ #
89
+ # The `text eol=lf` on the .md (task 1003619) is NOT cosmetic — it is what keeps
90
+ # `copy-inventory.js --check` honest on Windows. The generator writes LF and the
91
+ # check string-compares its output against the worktree file, so under the default
92
+ # `* text=auto` above (index LF, worktree CRLF) the check reported STALE forever on
93
+ # every Windows checkout while the committed content was already correct. The .json
94
+ # never had the bug because `*.json text eol=lf` above already pins it; the .md fell
95
+ # through to text=auto because no `*.md` rule exists (and none should — pinning all
96
+ # 685 tracked .md files would renormalize the whole docs tree for one generated file).
97
+ # `*.md` deliberately stays unpinned; only the GENERATED artifact is pinned here.
88
98
  docs/copy-registry.json merge=otb-regen
89
- docs/copy-inventory.md merge=otb-regen
99
+ docs/copy-inventory.md merge=otb-regen text eol=lf
90
100
 
91
101
  # Append-only index tables (task 1385 / ADR 0082). docs/adr/README.md is a
92
102
  # hand-maintained ADR index whose ONLY recurring conflict is two branches each
@@ -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 }
@@ -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": [
@@ -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. |
@@ -1705,5 +1705,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1705
1705
  landed since 1.19.626 with no explicit bump. run 34390069410. (task 1002620)
1706
1706
  1.19.628 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1707
1707
  landed since 1.19.627 with no explicit bump. run 34397967061. (task 1002620)
1708
+ 1.19.629 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
1708
1712
  ---------------------------------------------------------------------------
1709
1713
  ```
@@ -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
- | **`creator_session_id` on claims** | Yes | `CLAIM_SESSION_MISMATCH` (migration 013) | Only the session that created a claim may ship/release it — prevents cross-session interference. |
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`. |
@@ -147,3 +147,4 @@ caught a real bug during task 1003548, so read its assertion before blaming the
147
147
 
148
148
  - Several hall files (`people.css`, `people.html`, `style.css`, the privacy spec) and every `.claude/skills/*/SKILL.md` are CRLF; `scripts/gds/*.js` and the tests are LF. An exact-string patch must detect the file's EOL before joining lines (`text.includes('\r\n') ? '\r\n' : '\n'`), or the find matches nothing and the edit is a silent no-op (session log 2026-08-24).
149
149
  - A `git diff --stat` that touches every line of a file you barely edited is an EOL flip — check with `git diff --stat --ignore-cr-at-eol`, and restore the original EOL before committing.
150
+ - **`copy-inventory.js --check` no longer reds on line endings alone** (task 1003619). It used to: the generator emits LF and the check byte-compared that against a CRLF worktree file, so `/ship-check` showed one permanent red line on every Windows checkout while the committed content was already correct — and a builder who "fixed" it committed a whole-file CRLF diff. `docs/copy-inventory.md` is now pinned `text eol=lf` in `.gitattributes` (its `.json` sibling always was, via `*.json`), and the check compares EOL-blind so an already-CRLF worktree passes without a re-clone. If it reports STALE now, the content really is stale — regenerate, don't reach for `--ignore-cr-at-eol`. Note `*.md` is deliberately NOT pinned repo-wide; only that one generated artifact is.
@@ -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
- if (!force && claim.creator_session_id && resolverSessionId &&
756
- claim.creator_session_id !== resolverSessionId) {
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
- // Session lock: only the session that created this claim can resolve it.
57
- // Skipped when force=true (emergency), when the claim pre-dates this
58
- // migration (creator_session_id IS NULL), or when the resolver has no
59
- // session context (legacy path). This closes the cross-session release hole.
60
- if (!force && claim.creator_session_id && resolverSessionId &&
61
- claim.creator_session_id !== resolverSessionId) {
62
- throw { code: 'CLAIM_SESSION_MISMATCH', claimWorktree: claim.worktree_name ?? null };
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
  };
@@ -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.628",
3
+ "version": "1.19.630",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.628",
9
+ "version": "1.19.630",
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.628",
3
+ "version": "1.19.630",
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",
@@ -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. Only names a LINKED git
155
- // worktreewhere git-dir != git-common-dir, i.e. a real `.claude/worktrees/<name>`
156
- // checkout using its top-level directory basename (the `<name>` the convention
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
@@ -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,
@@ -485,6 +485,32 @@ function renderReport(reg) {
485
485
  return L.join('\n');
486
486
  }
487
487
 
488
+ // Line-ending-blind view of a file's text, for the --check compare ONLY (task 1003619).
489
+ // Why: the generator emits LF, and --check string-compares that against the file on
490
+ // disk. A Windows checkout hands back CRLF, so the compare reported STALE forever on
491
+ // content that was byte-identical in the index — a permanently red /ship-check gate,
492
+ // which teaches builders to wave past the one check that catches a REAL stale artifact.
493
+ // .gitattributes now pins this pair to eol=lf, but that only governs FUTURE checkouts:
494
+ // a file already sitting CRLF in an existing worktree stays CRLF until renormalized,
495
+ // so the checker must not care either way. Freshness is a question about CONTENT.
496
+ // Not used on the write path — generated files are still written with LF endings.
497
+ function lf(s) {
498
+ return s.replace(/\r\n/g, '\n');
499
+ }
500
+
501
+ // Is the artifact on disk still what the generator would write? Freshness is a
502
+ // question about CONTENT, so CRLF-vs-LF is not a difference — see lf() above for
503
+ // why that mattered. A missing file (`have === null`) is stale by definition.
504
+ // Only \r\n is folded, NOT a lone \r: no tool in this repo emits classic-Mac
505
+ // endings, and treating a bare \r as a newline would let a genuinely corrupt file
506
+ // read as fresh. Pure + exported so the decision is unit-testable without writing
507
+ // to the committed artifacts (same shape as shouldRefuseSessionClaim in
508
+ // modules/lifecycle/routes/claims.js).
509
+ function matchesGenerated(have, want) {
510
+ if (have === null || have === undefined) return false;
511
+ return lf(have) === lf(want);
512
+ }
513
+
488
514
  function write({ check = false } = {}) {
489
515
  const reg = buildRegistry();
490
516
  const json = `${JSON.stringify(reg, null, 2)}\n`;
@@ -495,7 +521,7 @@ function write({ check = false } = {}) {
495
521
  const stale = [];
496
522
  for (const [p, want] of [[jsonPath, json], [mdPath, md]]) {
497
523
  const have = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
498
- if (have !== want) stale.push(path.relative(REPO_ROOT, p));
524
+ if (!matchesGenerated(have, want)) stale.push(path.relative(REPO_ROOT, p));
499
525
  }
500
526
  return { reg, stale };
501
527
  }
@@ -525,5 +551,5 @@ if (require.main === module) {
525
551
  module.exports = {
526
552
  SURFACES, COPY_ATTRS, BLIND_SPOTS, REGISTRY_REL, REPORT_REL,
527
553
  cmp, stableId, looksLikeCopy, lineIndex, lineAt, stripEmbedded, fromHtml, fromJs, filesFor,
528
- buildRegistry, renderReport, write,
554
+ buildRegistry, renderReport, write, lf, matchesGenerated,
529
555
  };
@@ -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 r = await (await cliClient()).claims.postClaimsIdResolve({
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
- console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
455
- console.error('You can only ship from the session that originally claimed this task.');
456
- console.error(`To force-release from this session: node scripts/gds/release.js ${taskId} --force`);
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
  }
@@ -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.628'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.630'; // 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,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
+ });
Binary file
@@ -176,9 +176,24 @@ function withBlockBody(src, body) {
176
176
  // .gitattributes, one in REGENERABLE) — nothing checked that they still matched, so
177
177
  // docs/copy-inventory.md + docs/copy-registry.json could be (and were) added to
178
178
  // neither and merge like ordinary text. This pins the two lists as one set.
179
+ // A .gitattributes line may carry SEVERAL attributes for one path — git reads
180
+ // `docs/copy-inventory.md merge=otb-regen text eol=lf` exactly as it reads the
181
+ // bare `merge=otb-regen` form. The original matcher here was end-anchored
182
+ // (/^(\S+)\s+merge=otb-regen\s*$/), so adding any second attribute made this
183
+ // check stop SEEING the entry and report it as missing (task 1003619, where the
184
+ // .md gained an eol pin). Under-inclusive in the dangerous direction too: a
185
+ // regenerable path declared with extra attributes would slip the REGENERABLE
186
+ // cross-check entirely. Tokenize the attribute list instead of pattern-matching
187
+ // the whole line, so the set equality below is about PATHS, not line formatting.
179
188
  {
180
189
  const attrsText = fs.readFileSync(path.join(repo, '.gitattributes'), 'utf8');
181
- const otbRegenPaths = [...attrsText.matchAll(/^(\S+)\s+merge=otb-regen\s*$/gm)].map((m) => m[1]);
190
+ const otbRegenPaths = attrsText
191
+ .split(/\r?\n/)
192
+ .map((line) => line.trim())
193
+ .filter((line) => line && !line.startsWith('#'))
194
+ .map((line) => line.split(/\s+/))
195
+ .filter((tokens) => tokens.slice(1).includes('merge=otb-regen'))
196
+ .map((tokens) => tokens[0]);
182
197
  assert.ok(otbRegenPaths.length > 0, 'expected at least one merge=otb-regen line in .gitattributes');
183
198
  for (const p of otbRegenPaths) {
184
199
  assert.ok(regen.REGENERABLE.has(p), `.gitattributes declares merge=otb-regen for ${p} but REGENERABLE (git-merge-regen.js) does not know it`);