@bongos/core 1.19.253 → 1.19.255

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.253",
6
- "core_contract": "1.19.253",
7
- "source_commit": "7e98077a8a33b1ef861ab40823c1db64acffc71d",
5
+ "core_version": "1.19.255",
6
+ "core_contract": "1.19.255",
7
+ "source_commit": "85c97e23dbe8dee636e134ee8f5eac4983051c1f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-19T01:42:12.781Z",
9
+ "built_at": "2026-08-19T02:17:10.653Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 315,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1345,
14
+ "functional_verbatim": 1348,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1682,
20
- "tree_sha256": "942f711051e57b17995dbd18b2ee81dee4466440acb5c3559659f72b4e0291ba",
19
+ "file_count": 1685,
20
+ "tree_sha256": "d3086bc5b3687de018836883dc4e7a1af8239ce05719be7c6b2af45a4876619d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -947,7 +947,7 @@
947
947
  {
948
948
  "path": "docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md",
949
949
  "mode": "0000644",
950
- "sha256": "3aa1e9074bd62168f999139533d672b0e0369186e2a78bbc966bca940df8d012"
950
+ "sha256": "e06b7b9a18b669d1bc584ae0db73ccf49b47962580f632f8492113cca623bf37"
951
951
  },
952
952
  {
953
953
  "path": "docs/adr/0097-retroactive-reward-backfill.md",
@@ -2399,6 +2399,11 @@
2399
2399
  "mode": "0000644",
2400
2400
  "sha256": "e0503f677b4b30eca07badd76ba9b177f603f3740da366c1be40cb67a5537690"
2401
2401
  },
2402
+ {
2403
+ "path": "migrations/core_221_claims_requested_worktree_name.sql",
2404
+ "mode": "0000644",
2405
+ "sha256": "51d7fc76fdc59a5ab145839aa4943fe7753324022f82e4b4a76c2fe3cab9b98d"
2406
+ },
2402
2407
  {
2403
2408
  "path": "modules/autonomy/CLAUDE.md",
2404
2409
  "mode": "0000644",
@@ -3702,7 +3707,7 @@
3702
3707
  {
3703
3708
  "path": "modules/lifecycle/db.js",
3704
3709
  "mode": "0000644",
3705
- "sha256": "ed948712d57d984684313afd0160b1838c8cde061f82bd62f31226e39b8df122"
3710
+ "sha256": "b35b53b3badf7f465fe97d317cd32653518f14d98a76a03b18374c49b31db65a"
3706
3711
  },
3707
3712
  {
3708
3713
  "path": "modules/lifecycle/done-when.js",
@@ -3777,7 +3782,7 @@
3777
3782
  {
3778
3783
  "path": "modules/lifecycle/routes/claims.js",
3779
3784
  "mode": "0000644",
3780
- "sha256": "60f698b88fab3b850e2ab7d4350b7192ca57fa932089d0a24a187add08e5c819"
3785
+ "sha256": "4f40c6e74c40c1ff29f8f487e2a6b574e7998aca4366a6372101d17412917eea"
3781
3786
  },
3782
3787
  {
3783
3788
  "path": "modules/lifecycle/routes/dependencies.js",
@@ -4432,12 +4437,12 @@
4432
4437
  {
4433
4438
  "path": "package-lock.json",
4434
4439
  "mode": "0000644",
4435
- "sha256": "ade08bd4afada8f234fdb5bd155ad37628bc01040f76de7a378356d338933721"
4440
+ "sha256": "edf60a53538729a978886acc667ba4b8a81f654924873b138520f54e52ad6bf6"
4436
4441
  },
4437
4442
  {
4438
4443
  "path": "package.json",
4439
4444
  "mode": "0000644",
4440
- "sha256": "2278bb9510a79406d9ee34e9662df8a81273343075d382d7241b0f360650d199"
4445
+ "sha256": "aad3432c2f8c51687d6b16e5caf66fd2c7e01901cb9756ccca2fd700fe117021"
4441
4446
  },
4442
4447
  {
4443
4448
  "path": "public-docs/index.html",
@@ -5427,7 +5432,7 @@
5427
5432
  {
5428
5433
  "path": "scripts/gds/run-unit-tests.js",
5429
5434
  "mode": "0000644",
5430
- "sha256": "96e8495ffded28083ba458e8d527c5ba25793a20d5f4c0fee5160cff778f4def"
5435
+ "sha256": "e2e3cd6299f63b838ed5a78121eedcb44e76b3cc62b622581cd537c154b1d8ae"
5431
5436
  },
5432
5437
  {
5433
5438
  "path": "scripts/gds/sandbox-stage.js",
@@ -6057,7 +6062,7 @@
6057
6062
  {
6058
6063
  "path": "src/module-api.js",
6059
6064
  "mode": "0000644",
6060
- "sha256": "a12419c00b425a6b98a646cb98e5d20584863185779453e88c52c7036ac1ff62"
6065
+ "sha256": "b34d89b988d753c21364242217269cce8fb4db8719598bd6f2c458507ffad512"
6061
6066
  },
6062
6067
  {
6063
6068
  "path": "src/module-loader/catalog.js",
@@ -6509,10 +6514,15 @@
6509
6514
  "mode": "0000644",
6510
6515
  "sha256": "d8d95c67c6c746418716b384fc46eefbf5187686f091353258768b83d5c44519"
6511
6516
  },
6517
+ {
6518
+ "path": "tests/claims_requested_worktree_db.mjs",
6519
+ "mode": "0000644",
6520
+ "sha256": "977e10f8a92acf20afa58c01bd2d0b68d64414ac7d2a1d15a4d22c332be75f36"
6521
+ },
6512
6522
  {
6513
6523
  "path": "tests/claude_materialize.mjs",
6514
6524
  "mode": "0000644",
6515
- "sha256": "ba55196528236a9473d7038f00f7c7c0d6c8bd9fd05bfe2b11d94688e97cdb90"
6525
+ "sha256": "6d31d8597e3b1e9e7efde56f9a3839715cfa87378e7555f0fa3b26b09346a569"
6516
6526
  },
6517
6527
  {
6518
6528
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -7262,7 +7272,7 @@
7262
7272
  {
7263
7273
  "path": "tests/hook_user_agents.mjs",
7264
7274
  "mode": "0000644",
7265
- "sha256": "7289010b4d9e52bce152b4acba1cd6e17f747756719664c7f7e07ac818f885b7"
7275
+ "sha256": "ed1e14050ba219a52736c2cd70f4d4333a345fb0018211c85293a995c7f0aac7"
7266
7276
  },
7267
7277
  {
7268
7278
  "path": "tests/host_topology_skips.mjs",
@@ -7667,7 +7677,7 @@
7667
7677
  {
7668
7678
  "path": "tests/one_active_claim_per_session.mjs",
7669
7679
  "mode": "0000644",
7670
- "sha256": "030c7f3958d1fef45977326b52596e21638fe80b4ff9e7ecba31c76063283aea"
7680
+ "sha256": "f4da7e663ab6d7b93bb02ecefd18178fa654a314da07108c4316317f50115ad0"
7671
7681
  },
7672
7682
  {
7673
7683
  "path": "tests/orphaned_claim_ship.mjs",
@@ -8004,6 +8014,11 @@
8004
8014
  "mode": "0000644",
8005
8015
  "sha256": "886bf7096cbdb3b758010eb0ee24e15bd74033a7bff0064b742585440cdeee79"
8006
8016
  },
8017
+ {
8018
+ "path": "tests/secret_gate.mjs",
8019
+ "mode": "0000644",
8020
+ "sha256": "20de62b7d869763c703317dae4bc3896cbf4308d1211c8fcbffbfbd32465b1b5"
8021
+ },
8007
8022
  {
8008
8023
  "path": "tests/security_docs.mjs",
8009
8024
  "mode": "0000644",
@@ -42,11 +42,21 @@ The default claim flow (`.claude/skills/builder-claim/SKILL.md`) ensures the tas
42
42
 
43
43
  > **Amended 2026-08-11 (task 1002522) — the guard and the index now fold tree identity identically.** `core_204`'s key folds `NULL` and `''` together (`COALESCE(worktree_name, '')`); the guard compared raw values, where `'' !== null`. So a claim sent with `worktree_name: ""` (the route caps the length but never required non-empty) was waved through beside a worktree-less claim and *then* refused by the index — a `23505` on a task nobody had taken. `db.foldWorktreeName` is now the single normalization: it folds empty/whitespace-only to `NULL` and trims, and it is applied at **every ingest point** (`claimTask`, `claimTasksBatch`'s prefix, and `shouldRefuseSessionClaim`), so stored names are always trimmed-or-`NULL` and the guard's comparison of stored rows is exact by construction. Pinned DB-free in `tests/one_active_claim_per_session.mjs`.
44
44
  >
45
- > Two things were checked and deliberately left alone. **The stale-claim sweep needs no re-tuning** now that one session may hold N claims: both the heartbeat (`session_pulses`, PK `claim_id`) and the sweep (`modules/sessions/pulse.js sweepStaleClaims`) are keyed per *claim*, never per session, so each parallel worktree's claim ages out on its own clock. **One residual divergence remains**, tracked as [#1002709](https://example.com/builders#/task/1002709): V3.R51 disambiguation renames a colliding `worktree_name` (`"wt-a"` → `"wt-a-2"`) while the CLI keeps sending the original, so guard and index both key on a name the caller never used — a second claim in the *same* real tree can slip past both. Closing it needs the requested name persisted (a schema decision), which is why it is a follow-up rather than part of this amendment.
45
+ > Two things were checked and deliberately left alone. **The stale-claim sweep needs no re-tuning** now that one session may hold N claims: both the heartbeat (`session_pulses`, PK `claim_id`) and the sweep (`modules/sessions/pulse.js sweepStaleClaims`) are keyed per *claim*, never per session, so each parallel worktree's claim ages out on its own clock. **One residual divergence remains**, tracked as [#1002709](https://example.com/builders#/task/1002709): V3.R51 disambiguation renames a colliding `worktree_name` (`"wt-a"` → `"wt-a-2"`) while the CLI keeps sending the original, so guard and index both key on a name the caller never used — a second claim in the *same* real tree can slip past both. Closing it needs the requested name persisted (a schema decision), which is why it is a follow-up rather than part of this amendment. **That follow-up landed — see the next amendment.**
46
46
 
47
47
  ## Consequences
48
48
 
49
49
  - A session can hold at most one active claim — commingled ships are structurally prevented, not just discouraged.
50
+ > **Amended 2026-08-19 (task 1002709 / `core_221`) — the residual divergence above is closed: the guard keys on the REQUESTED worktree name.** V3.R51 disambiguation silently renames a colliding `worktree_name` (`"overnight"` → `"overnight-2"`), which is correct and load-bearing — `.claude/worktrees/<name>` is a filesystem path and two live builders pointing at one would stomp each other. But both halves of the rule then keyed on the **stored** name while `scripts/gds/claim.js` keeps sending the **real directory** name, which is never renamed. So a second claim from the same session in the same tree compared `"overnight"` against the stored `"overnight-2"`, read them as two trees, and was allowed by the guard *and* the index: one working tree, one branch, two active claims — precisely the commingled ship this ADR exists to prevent. Reachable for any fixed reusable name (the overnight builder, box workflows, a hand-passed `--worktree`).
51
+ >
52
+ > `claims.requested_worktree_name` now stores what the caller asked for, and the guard (`shouldRefuseSessionClaim`) plus the re-keyed index `uniq_active_claim_per_session_requested_worktree` both key on it. **Two names, two jobs, and keeping them apart is the point:** `worktree_name` is the disambiguated name on disk and stays globally unique across active claims (branch naming and the operator surfaces read it); `requested_worktree_name` is the identity of the working *tree*, which is what "do these two claims share a tree" actually asks.
53
+ >
54
+ > **Why a stored column and not a cleverer guard.** Matching `^<requested>-\d+$` would refuse a builder genuinely running worktrees `foo` and `foo-2` side by side — reintroducing exactly the false refusal this family of amendments (1002570, 1002589, 1002522) keeps having to undo. The guard needs the name the claim was *filed* under, and that was simply never recorded.
55
+ >
56
+ > **The migration deliberately UNDER-heals, and that is a decision rather than an oversight.** After the backfill copies `worktree_name` across, a pre-existing commingled pair (stored `overnight` + `overnight-2`, one real tree) and a legitimate pair (stored `foo` + `foo-2`, two real trees) are **indistinguishable** — the requested name was never recorded, which is the whole bug. Any heuristic able to close the first would also silently close the second, killing an active claim of a builder who did nothing wrong, and a migration acts without asking. So pairs already in flight stay in flight (the pre-existing hazard, unchanged: the first to ship takes the branch), and no *new* pair can be created from the moment the guard lands. The heal still earns its place for rows the new code writes, where a genuine collision on the requested name is possible and would otherwise block `CREATE UNIQUE INDEX` on deploy.
57
+ >
58
+ > All three index generations stay in `claimUniqueViolationError`'s `SESSION_CLAIM_INDEXES`, because a deploy applies code and migration at different instants and an unrecognised constraint falls closed onto the generic `ALREADY_CLAIMED` — the phantom-rival answer task 1002589 removed. The guard falls back to `worktree_name` when `requested_worktree_name` is NULL, which keeps it correct in that same window. Pinned DB-free in `tests/one_active_claim_per_session.mjs` (including the three-step repro, which fails against the old code) and against a real Postgres in `tests/claims_requested_worktree_db.mjs`.
59
+
50
60
  - The many-sessions-per-builder norm (ADR 0049's `feedback_parallel_sessions_not_anomaly`) is preserved exactly: each worktree/session still gets its own claim.
51
61
  - Web claims (NULL session) are unaffected — claim-in-browser, ship-from-any-terminal still works.
52
62
  - This composes with, and does not alter, ADR 0049's split cross-claim contract: the per-task index, the advisory `touches[]` warning, and the autonomous-runner footprint gate are all untouched. This ADR only adds the within-session invariant 0049 left implicit.
@@ -0,0 +1,144 @@
1
+ -- core_221_claims_requested_worktree_name.sql
2
+ -- Task 1002709 (goal 1000024, build-pipeline integrity). A CORE migration, not a
3
+ -- lifecycle_ one, by the same rule core_204 states: it alters the shared
4
+ -- pre-module `claims` table and swaps an index on it, and a module migration
5
+ -- must stay additive within its own <key>_ tables (ADR 0083 §Decision #5).
6
+ -- Idempotent on re-run.
7
+ --
8
+ -- ── THE HOLE ────────────────────────────────────────────────────────────────
9
+ --
10
+ -- V3.R51 disambiguation (modules/lifecycle/db.js disambiguateWorktreeName)
11
+ -- silently RENAMES a requested worktree_name that collides with any other
12
+ -- currently-active claim: "overnight" is stored as "overnight-2". That rename is
13
+ -- correct and load-bearing — `.claude/worktrees/<name>` is a filesystem path and
14
+ -- two live builders pointing at one would stomp each other.
15
+ --
16
+ -- But both halves of the one-claim-per-tree rule then key on the STORED name,
17
+ -- while the CLI keeps sending the REQUESTED one (scripts/gds/claim.js reads the
18
+ -- real directory, which is never renamed). So:
19
+ --
20
+ -- 1. Builder X holds an active claim with worktree_name "overnight".
21
+ -- 2. Builder Y, session S, in a tree genuinely named "overnight", claims task
22
+ -- A. Y's name collides with X's, so it is stored as "overnight-2".
23
+ -- 3. Same session S, same tree, claims task B. The guard compares the incoming
24
+ -- "overnight" against the stored "overnight-2" — different tree, ALLOW. The
25
+ -- index sees (S,"overnight") vs (S,"overnight-2") — no collision, INSERT.
26
+ --
27
+ -- Result: ONE real working tree, ONE branch, TWO active claims — the exact
28
+ -- commingled-ship hazard task 1642 and ADR 0097 exist to prevent. Rare, because
29
+ -- worktree names are usually task-slug + random hex, but reachable for any fixed
30
+ -- reusable name: the overnight builder, box workflows, `--worktree` passed by
31
+ -- hand.
32
+ --
33
+ -- ── WHY A COLUMN AND NOT A CLEVERER GUARD ───────────────────────────────────
34
+ --
35
+ -- Matching "^<requested>-\d+$" in the guard would refuse a builder who genuinely
36
+ -- runs worktrees "foo" and "foo-2" side by side — reintroducing the false
37
+ -- refusal this whole family of bugs (1002570, 1002589, 1002522) is about. The
38
+ -- guard needs the name the claim was FILED under, which is simply not stored
39
+ -- today. So store it.
40
+ --
41
+ -- TWO NAMES, TWO JOBS, and keeping them separate is the point:
42
+ -- worktree_name the name on DISK after disambiguation. Still what
43
+ -- branch naming and the operator surfaces read, and
44
+ -- still globally unique across active claims.
45
+ -- requested_worktree_name the name the CALLER asked for. The identity of the
46
+ -- working TREE, which is what "do these two claims
47
+ -- share a tree" actually asks.
48
+ --
49
+ -- ── THE BACKFILL IS HONEST ABOUT WHAT IT CANNOT KNOW ────────────────────────
50
+ --
51
+ -- For historical rows the requested name was never recorded. Copying
52
+ -- worktree_name across is exactly right for every row that was NOT renamed (the
53
+ -- overwhelming majority — disambiguation only fires on a live collision) and is
54
+ -- the best available answer for the few that were. It cannot resurrect a lost
55
+ -- fact, and it does not pretend to: the column is nullable, and a row that was
56
+ -- renamed simply carries its post-rename name as its pre-rename one. No active
57
+ -- claim changes meaning, because the pair is only compared against other rows
58
+ -- backfilled the same way.
59
+
60
+ BEGIN;
61
+
62
+ ALTER TABLE claims
63
+ ADD COLUMN IF NOT EXISTS requested_worktree_name text;
64
+
65
+ COMMENT ON COLUMN claims.requested_worktree_name IS
66
+ 'The worktree name the CALLER asked for, before V3.R51 disambiguation renamed it. The one-claim-per-working-tree guard keys on THIS (task 1002709); worktree_name remains the disambiguated on-disk name.';
67
+
68
+ -- Backfill (see the header): the requested name equals the stored name for every
69
+ -- row disambiguation never touched, and is the closest available answer for the
70
+ -- rest. Guarded so a re-run never overwrites a real value with a stale one.
71
+ UPDATE claims
72
+ SET requested_worktree_name = worktree_name
73
+ WHERE requested_worktree_name IS NULL;
74
+
75
+ -- ── heal before CREATE (the migration 174/180/204 shape) ────────────────────
76
+ --
77
+ -- A unique index refuses to create if live rows already violate it, which would
78
+ -- block the deploy. This heal keeps that safe for rows the NEW code writes.
79
+ --
80
+ -- WHAT IT CANNOT DO, STATED PLAINLY: it cannot find a commingled pair that
81
+ -- already exists. Consider the two shapes, after the backfill above:
82
+ --
83
+ -- commingled session S, stored "overnight" + "overnight-2", ONE real tree
84
+ -- legitimate session S, stored "foo" + "foo-2", TWO real trees
85
+ --
86
+ -- Both backfill to requested = their stored name, so both read as two distinct
87
+ -- trees, and the heal closes neither. They are INDISTINGUISHABLE in the data —
88
+ -- the requested name was never recorded, which is the entire bug. Any heuristic
89
+ -- that could close the first (stripping a "-\d+" suffix) would also close the
90
+ -- second, silently killing an active claim of a builder who did nothing wrong.
91
+ -- That is the same false-refusal trap that makes suffix-matching wrong in the
92
+ -- guard, and it is worse here because a migration acts without asking.
93
+ --
94
+ -- So this deliberately under-heals. The window it leaves is bounded and small:
95
+ -- any commingled pair already in flight stays in flight, and the FIRST of the
96
+ -- two to ship takes the tree's branch — the pre-existing hazard, unchanged. From
97
+ -- the moment the new guard code lands, no NEW pair can be created. Closing the
98
+ -- ledger honestly beats closing someone's claim on a guess.
99
+ --
100
+ -- The heal still earns its place: once rows carry a real requested name, two of
101
+ -- them CAN collide on it, and then this closes the later and keeps the earliest
102
+ -- — the same rule core_204 applied, and the one the guard would have applied had
103
+ -- it been able to see the collision at claim time.
104
+ WITH ranked AS (
105
+ SELECT c.id,
106
+ ROW_NUMBER() OVER (
107
+ PARTITION BY c.creator_session_id, COALESCE(c.requested_worktree_name, '')
108
+ ORDER BY c.claimed_at, c.id
109
+ ) AS rn
110
+ FROM claims c
111
+ WHERE c.released_at IS NULL
112
+ AND c.creator_session_id IS NOT NULL
113
+ )
114
+ UPDATE claims c
115
+ SET released_at = now(),
116
+ outcome = COALESCE(c.outcome, 'abandoned')
117
+ FROM ranked r
118
+ WHERE c.id = r.id
119
+ AND r.rn > 1;
120
+
121
+ -- ── swap the index ──────────────────────────────────────────────────────────
122
+ -- COALESCE, not PG15's NULLS NOT DISTINCT, and it is load-bearing for the same
123
+ -- reason core_204 gives: in a plain multi-column unique index NULLs are
124
+ -- DISTINCT, so two worktree-less claims (one shared clone, whose single branch
125
+ -- genuinely commingles) would both insert and re-open the hole task 1642 closed.
126
+ --
127
+ -- The old index is DROPPED rather than left alongside. Keeping it would re-refuse
128
+ -- a legitimate second claim from a builder genuinely running "foo" and "foo-2"
129
+ -- in parallel — the false refusal this change exists to avoid reintroducing.
130
+ DROP INDEX IF EXISTS uniq_active_claim_per_session_worktree;
131
+
132
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_requested_worktree
133
+ ON claims (creator_session_id, (COALESCE(requested_worktree_name, '')))
134
+ WHERE released_at IS NULL AND creator_session_id IS NOT NULL;
135
+
136
+ -- (This index name and claimUniqueViolationError's SESSION_CLAIM_INDEXES set
137
+ -- must stay in lockstep — tests/one_active_claim_per_session.mjs pins both
138
+ -- sides, and all three generations stay in that set because a deploy applies
139
+ -- code and migration at different instants.)
140
+
141
+ INSERT INTO schema_migrations (version) VALUES ('core_221_claims_requested_worktree_name')
142
+ ON CONFLICT (version) DO NOTHING;
143
+
144
+ COMMIT;
@@ -2600,7 +2600,13 @@ async function unsatisfiedDeps(taskId, client = pool) {
2600
2600
  // carry the old index.
2601
2601
  const SESSION_CLAIM_INDEXES = new Set([
2602
2602
  'uniq_active_claim_per_session', // migration 180 — session-only (pre-re-key)
2603
- 'uniq_active_claim_per_session_worktree', // core_204 (task 1002570) — per (session, worktree)
2603
+ 'uniq_active_claim_per_session_worktree', // core_204 (task 1002570) — per (session, stored worktree)
2604
+ // core_221 (task 1002709) — per (session, REQUESTED worktree). All three
2605
+ // generations stay listed: a deploy applies code and migration at different
2606
+ // instants, so an instance may still carry an older index, and an unrecognised
2607
+ // constraint falls closed onto the generic ALREADY_CLAIMED — which for this
2608
+ // case is the wrong, phantom-rival answer.
2609
+ 'uniq_active_claim_per_session_requested_worktree',
2604
2610
  ]);
2605
2611
  // Branch on err.constraint; an UNKNOWN constraint fails CLOSED on the generic
2606
2612
  // ALREADY_CLAIMED (never widen a claim refusal). Pure + exported for DB-free
@@ -2830,7 +2836,18 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2830
2836
  // task 1002522: fold ''/whitespace → NULL before anything keys on the name.
2831
2837
  // core_204's index folds them together anyway (COALESCE(worktree_name, ''));
2832
2838
  // normalizing here means the stored row matches what the guard compared.
2833
- let finalWorktreeName = foldWorktreeName(worktreeName);
2839
+ // task 1002709: BOTH names are stored, and they answer different questions.
2840
+ // `requestedWorktreeName` is what the caller asked for — the identity of the
2841
+ // working TREE, which is what "do these two claims share a tree" actually
2842
+ // asks. `finalWorktreeName` is what lands on disk after disambiguation, which
2843
+ // is what must stay globally unique across active claims.
2844
+ //
2845
+ // Keying the one-claim-per-tree guard on the STORED name was the hole: a
2846
+ // second claim from the same session in the same tree compared "overnight"
2847
+ // against a previously-renamed "overnight-2", saw a different tree, and let
2848
+ // a commingling claim through. See core_221 for the full repro.
2849
+ const requestedWorktreeName = foldWorktreeName(worktreeName);
2850
+ let finalWorktreeName = requestedWorktreeName;
2834
2851
  if (finalWorktreeName) {
2835
2852
  const { rows: activeNameRows } = await client.query(
2836
2853
  `SELECT worktree_name FROM claims
@@ -2841,10 +2858,10 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2841
2858
  }
2842
2859
 
2843
2860
  const insertClaim = await client.query(
2844
- `INSERT INTO claims (task_id, builder_id, worktree_name, head_sha, reserved_migration_number, creator_session_id)
2845
- VALUES ($1, $2, $3, $4, $5, $6)
2846
- RETURNING id, task_id, builder_id, worktree_name, claimed_at, head_sha, reserved_migration_number`,
2847
- [taskId, builderId, finalWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
2861
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
2862
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
2863
+ RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
2864
+ [taskId, builderId, finalWorktreeName, requestedWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
2848
2865
  );
2849
2866
 
2850
2867
  await client.query(`UPDATE tasks SET status = 'active' WHERE id = $1`, [taskId]);
@@ -3198,10 +3215,16 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
3198
3215
  : null;
3199
3216
  const rowCreatorSessionId = i === 0 ? (creatorSessionId ?? null) : null;
3200
3217
  try {
3218
+ // task 1002709: requested == final on this path by construction — each
3219
+ // row gets its own `<prefix>-<taskId>` tree and nothing disambiguates
3220
+ // them. It is still written explicitly rather than left NULL: core_221's
3221
+ // index keys on COALESCE(requested_worktree_name, ''), so a NULL here
3222
+ // would make a row that HAS a worktree read as worktree-less and collide
3223
+ // with the shared-clone group.
3201
3224
  const insertClaim = await client.query(
3202
- `INSERT INTO claims (task_id, builder_id, worktree_name, head_sha, reserved_migration_number, creator_session_id)
3203
- VALUES ($1, $2, $3, NULL, $4, $5)
3204
- RETURNING id, task_id, builder_id, worktree_name, claimed_at, head_sha, reserved_migration_number`,
3225
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
3226
+ VALUES ($1, $2, $3, $3, NULL, $4, $5)
3227
+ RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
3205
3228
  [t.id, builderId, worktreeName, reservedMigrationNumber, rowCreatorSessionId]
3206
3229
  );
3207
3230
  inserted.push(insertClaim.rows[0]);
@@ -4843,7 +4866,12 @@ async function activeClaimCountForBuilder(builderId, deps = {}) {
4843
4866
  async function getActiveClaimsForSession(creatorSessionId) {
4844
4867
  if (!creatorSessionId) return [];
4845
4868
  const { rows } = await pool.query(
4846
- `SELECT c.id AS claim_id, c.task_id, c.creator_session_id, c.worktree_name, c.claimed_at,
4869
+ // task 1002709: requested_worktree_name rides along because the guard keys on
4870
+ // it — comparing the incoming REQUESTED name against a stored DISAMBIGUATED
4871
+ // one is what let a second same-tree claim through. worktree_name stays
4872
+ // selected: callers still render the on-disk name.
4873
+ `SELECT c.id AS claim_id, c.task_id, c.creator_session_id, c.worktree_name,
4874
+ c.requested_worktree_name, c.claimed_at,
4847
4875
  t.title
4848
4876
  FROM claims c JOIN tasks t ON t.id = c.task_id
4849
4877
  WHERE c.creator_session_id = $1 AND c.released_at IS NULL
@@ -90,13 +90,31 @@ function shouldRefuseSessionClaim({ activeClaimsForSession, creatorSessionId, wo
90
90
  // Two claims share a WORKING TREE iff their folded worktree_names match — which
91
91
  // includes both being worktree-less (a plain clone, whose single branch
92
92
  // genuinely commingles).
93
- const sameWorkingTree = (name) => db.foldWorktreeName(name) === incomingTree;
93
+ //
94
+ // task 1002709: compare against the REQUESTED name, not the stored one. V3.R51
95
+ // silently renames a colliding worktree_name ("overnight" → "overnight-2"), and
96
+ // the CLI keeps sending the real directory name, which is never renamed — so
97
+ // keying on the stored name compared "overnight" against "overnight-2", read
98
+ // them as different trees, and let a SECOND claim into the SAME tree. One tree,
99
+ // one branch, two active claims: exactly the commingled ship this guard exists
100
+ // to stop. See core_221 for the three-step repro.
101
+ //
102
+ // requested_worktree_name is NULL on rows written before core_221's backfill
103
+ // ran, so fall back to worktree_name — which for any row disambiguation never
104
+ // touched IS the requested name, and is the honest best answer for the rest. A
105
+ // deploy applies code and migration at different instants; this fallback is
106
+ // what keeps the guard correct in that window rather than reading every old
107
+ // row as worktree-less.
108
+ const treeOf = (c) => db.foldWorktreeName(
109
+ c.requested_worktree_name ?? c.worktree_name ?? null
110
+ );
111
+ const sameWorkingTree = (claim) => treeOf(claim) === incomingTree;
94
112
  // Defence in depth: only count claims that belong to THIS session, are genuinely
95
113
  // session-bound (a NULL-session row must never trip the block), AND would land in
96
114
  // the SAME working tree as the incoming claim.
97
115
  const inFlight = claims.find(
98
116
  (c) => c && c.creator_session_id != null && c.creator_session_id === creatorSessionId
99
- && sameWorkingTree(c.worktree_name ?? null)
117
+ && sameWorkingTree(c)
100
118
  );
101
119
  if (inFlight) {
102
120
  return {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.253",
3
+ "version": "1.19.255",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.253",
9
+ "version": "1.19.255",
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.253",
3
+ "version": "1.19.255",
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",
@@ -73,7 +73,14 @@ const INTEGRATION = new Set([
73
73
  // platform-server — network + package build + a spawned server, run by its own
74
74
  // .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
75
75
  'consumer_layout_boot',
76
- 'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog',
76
+ 'claim_gate_open_blocker', 'claim_gate_rebase',
77
+ // task 1002709: core_221's re-keyed backstop index against a real Postgres.
78
+ // The DB-free sibling pins the guard and the migration's text; this proves the
79
+ // index actually refuses the commingling INSERT and still allows the parallel
80
+ // -worktree case. It self-skips without a DB, and refuses any database that
81
+ // has a `builders` table, since it TRUNCATEs claims.
82
+ 'claims_requested_worktree_db',
83
+ 'clawback_confirmed_backlog',
77
84
  // task 1003113: the copy_desk_flags schema half. The module's own tests are
78
85
  // DB-free and cover the pure layer; the rules that MATTER here live in the
79
86
  // schema — a CHECK-constraint reason floor whose whole argument is that no
package/src/module-api.js CHANGED
@@ -933,8 +933,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
933
933
  // landed since 1.19.251 with no explicit bump. run 32205191350. (task 1002620)
934
934
  // 1.19.253 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
935
935
  // landed since 1.19.252 with no explicit bump. run 32205833181. (task 1002620)
936
+ // 1.19.254 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
937
+ // landed since 1.19.253 with no explicit bump. run 32207504942. (task 1002620)
938
+ // 1.19.255 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
939
+ // landed since 1.19.254 with no explicit bump. run 32207967928. (task 1002620)
936
940
  // ---------------------------------------------------------------------------
937
- const CORE_VERSION = '1.19.253'; // CI auto-patch carrier (ADR 0161); see changelog above
941
+ const CORE_VERSION = '1.19.255'; // CI auto-patch carrier (ADR 0161); see changelog above
938
942
 
939
943
  // A namespaced logger so a module's log lines are attributable + consistent.
940
944
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,196 @@
1
+ // tests/claims_requested_worktree_db.mjs — core_221's re-keyed backstop index
2
+ // against a REAL Postgres (task 1002709).
3
+ //
4
+ // WHY THIS EXISTS BESIDE tests/one_active_claim_per_session.mjs. That file is
5
+ // DB-free and covers the app GUARD plus text tripwires on the migration's shape.
6
+ // It cannot cover the half that is a claim about the database: whether the
7
+ // re-keyed unique index actually refuses the commingling INSERT, whether it
8
+ // still ALLOWS the legitimate parallel-worktree case, and whether the migration
9
+ // survives being applied over the very row shape the bug produced.
10
+ //
11
+ // The guard and the index are defence in depth for the same rule, and this
12
+ // family of bugs (1002570, 1002589, 1002522, and now 1002709) has been almost
13
+ // entirely about the two disagreeing. So both halves get their own proof.
14
+ //
15
+ // It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
16
+ // run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it.
17
+ //
18
+ // Run: DATABASE_URL=postgres://... node tests/claims_requested_worktree_db.mjs
19
+ //
20
+ // It builds its own throwaway `claims` table when one is absent, and truncates
21
+ // only that table — so point it at a scratch database, never a real one.
22
+
23
+ import pg from 'pg';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import url from 'node:url';
27
+ import { strict as assert } from 'node:assert';
28
+
29
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
30
+
31
+ try {
32
+ await pool.query('SELECT 1');
33
+ } catch (e) {
34
+ console.log(`claims_requested_worktree_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
35
+ await pool.end().catch(() => {});
36
+ process.exit(0);
37
+ }
38
+
39
+ // This test TRUNCATEs `claims`. Refuse to run against anything that looks like a
40
+ // real database — a scratch container is the only sane target, and an accidental
41
+ // DATABASE_URL pointing at prod would otherwise wipe the claims ledger.
42
+ const { rows: realRows } = await pool.query(
43
+ `SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
44
+ if (realRows[0].has_builders) {
45
+ console.log('claims_requested_worktree_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB. It TRUNCATEs `claims`; point it at a throwaway Postgres.');
46
+ await pool.end().catch(() => {});
47
+ process.exit(0);
48
+ }
49
+ let passed = 0, failed = 0;
50
+ async function t(name, fn) {
51
+ try { await fn(); passed++; console.log(` ok ${name}`); }
52
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
53
+ }
54
+
55
+ await pool.query(`
56
+ CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
57
+ CREATE TABLE IF NOT EXISTS claims (
58
+ id bigserial PRIMARY KEY,
59
+ task_id bigint, builder_id bigint,
60
+ worktree_name text,
61
+ creator_session_id text,
62
+ claimed_at timestamptz DEFAULT now(),
63
+ released_at timestamptz,
64
+ outcome text);
65
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_worktree
66
+ ON claims (creator_session_id, (COALESCE(worktree_name, '')))
67
+ WHERE released_at IS NULL AND creator_session_id IS NOT NULL;
68
+ `);
69
+
70
+ // Seed the PRE-migration commingled state: one session, one real tree, two
71
+ // active claims that were only ever distinguished by the suffix. This is the row
72
+ // shape the bug produced, and the migration has to heal it before it can build
73
+ // the unique index.
74
+ await pool.query(`TRUNCATE claims`);
75
+ await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, creator_session_id, claimed_at) VALUES
76
+ (1, 1, 'overnight', 'sess-A', now() - interval '2 hour'),
77
+ (2, 1, 'overnight-2', 'sess-A', now() - interval '1 hour')`);
78
+
79
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
80
+ const MIG = fs.readFileSync(
81
+ path.join(HERE, '..', 'migrations', 'core_221_claims_requested_worktree_name.sql'), 'utf8');
82
+
83
+ console.log('core_221 against real Postgres');
84
+
85
+ await t('the migration applies over the commingled state (heals, then indexes)', async () => {
86
+ await pool.query(MIG);
87
+ });
88
+
89
+ await t('it deliberately UNDER-heals a pre-existing commingled pair, and does not guess', async () => {
90
+ // The honest limitation, asserted rather than hoped for. After the backfill,
91
+ // a commingled pair (stored "overnight" + "overnight-2", ONE real tree) and a
92
+ // legitimate pair (stored "foo" + "foo-2", TWO real trees) are
93
+ // INDISTINGUISHABLE — the requested name was never recorded, which is the
94
+ // whole bug. Suffix-stripping could close the first but would also silently
95
+ // kill the second, so the migration closes neither.
96
+ const { rows } = await pool.query(
97
+ `SELECT task_id, released_at FROM claims ORDER BY claimed_at`);
98
+ assert.equal(rows.length, 2);
99
+ assert.equal(rows[0].released_at, null);
100
+ assert.equal(rows[1].released_at, null,
101
+ 'both stay active: a migration must not close a builder\'s live claim on a guess');
102
+ });
103
+
104
+ await t('but it DOES heal a genuine collision on the requested name', async () => {
105
+ // Rows written by the new code carry a real requested name, and two of those
106
+ // CAN collide — that is the case the heal exists for, and the case that would
107
+ // otherwise block CREATE UNIQUE INDEX on deploy.
108
+ await pool.query(`TRUNCATE claims`);
109
+ // Drop the index FIRST — the point is to recreate the pre-migration state in
110
+ // which these two rows can coexist, then prove the migration heals them.
111
+ await pool.query('DROP INDEX IF EXISTS uniq_active_claim_per_session_requested_worktree');
112
+ await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id, claimed_at) VALUES
113
+ (60, 1, 'wt', 'wt', 'sess-H', now() - interval '2 hour'),
114
+ (61, 1, 'wt-2', 'wt', 'sess-H', now() - interval '1 hour')`);
115
+ await pool.query(MIG);
116
+ const { rows } = await pool.query(
117
+ `SELECT task_id, released_at, outcome FROM claims ORDER BY claimed_at`);
118
+ assert.equal(rows[0].released_at, null, 'the earliest survives');
119
+ assert.ok(rows[1].released_at, 'the later is closed');
120
+ assert.equal(rows[1].outcome, 'abandoned');
121
+ });
122
+
123
+ await t('the backfill copies worktree_name across, and never overwrites a real value', async () => {
124
+ await pool.query(`TRUNCATE claims`);
125
+ await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id) VALUES
126
+ (70, 1, 'legacy', NULL, 'sess-I'),
127
+ (71, 1, 'newer', 'asked-for','sess-J')`);
128
+ await pool.query(MIG);
129
+ const { rows } = await pool.query(
130
+ `SELECT task_id, worktree_name, requested_worktree_name FROM claims ORDER BY task_id`);
131
+ assert.equal(rows[0].requested_worktree_name, 'legacy', 'a NULL historical row is filled from the stored name');
132
+ assert.equal(rows[1].requested_worktree_name, 'asked-for',
133
+ 'a row that already carries a real requested name is left alone — the guard on the UPDATE');
134
+ });
135
+
136
+ await t('re-running the migration is idempotent', async () => {
137
+ await pool.query(MIG);
138
+ });
139
+
140
+ await t('the old index is gone and the new one exists', async () => {
141
+ const { rows } = await pool.query(
142
+ `SELECT indexname FROM pg_indexes WHERE tablename = 'claims'`);
143
+ const names = rows.map((r) => r.indexname);
144
+ assert.ok(!names.includes('uniq_active_claim_per_session_worktree'),
145
+ 'leaving it would re-refuse the legitimate foo / foo-2 case');
146
+ assert.ok(names.includes('uniq_active_claim_per_session_requested_worktree'));
147
+ });
148
+
149
+ await pool.query(`TRUNCATE claims`);
150
+ const ins = (o) => pool.query(
151
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id)
152
+ VALUES ($1,1,$2,$3,$4) RETURNING id`,
153
+ [o.task, o.stored, o.requested, o.session]);
154
+
155
+ console.log('\nthe index now catches what the guard catches');
156
+
157
+ await t('THE HOLE IS CLOSED: same session, same REQUESTED tree, second claim → 23505', async () => {
158
+ await ins({ task: 10, stored: 'overnight-2', requested: 'overnight', session: 'sess-B' });
159
+ await assert.rejects(
160
+ () => ins({ task: 11, stored: 'overnight-3', requested: 'overnight', session: 'sess-B' }),
161
+ (e) => e.code === '23505' && e.constraint === 'uniq_active_claim_per_session_requested_worktree');
162
+ });
163
+
164
+ await t('two genuinely different trees in one session still BOTH insert', async () => {
165
+ await pool.query(`TRUNCATE claims`);
166
+ await ins({ task: 20, stored: 'foo', requested: 'foo', session: 'sess-C' });
167
+ const r = await ins({ task: 21, stored: 'foo-2', requested: 'foo-2', session: 'sess-C' });
168
+ assert.ok(r.rows[0].id, 'the parallel-worktree workflow must keep working');
169
+ });
170
+
171
+ await t('two worktree-less claims in one session still self-collide', async () => {
172
+ await pool.query(`TRUNCATE claims`);
173
+ await ins({ task: 30, stored: null, requested: null, session: 'sess-D' });
174
+ await assert.rejects(
175
+ () => ins({ task: 31, stored: null, requested: null, session: 'sess-D' }),
176
+ (e) => e.code === '23505', 'one shared clone, one branch — NULLs must fold via COALESCE');
177
+ });
178
+
179
+ await t('a released claim frees the name for reuse (the overnight builder)', async () => {
180
+ await pool.query(`TRUNCATE claims`);
181
+ await ins({ task: 40, stored: 'overnight', requested: 'overnight', session: 'sess-E' });
182
+ await pool.query(`UPDATE claims SET released_at = now() WHERE task_id = 40`);
183
+ const r = await ins({ task: 41, stored: 'overnight', requested: 'overnight', session: 'sess-E' });
184
+ assert.ok(r.rows[0].id, 'sequential reuse is load-bearing for the overnight builder');
185
+ });
186
+
187
+ await t('unbound (NULL-session) claims are still exempt', async () => {
188
+ await pool.query(`TRUNCATE claims`);
189
+ await ins({ task: 50, stored: 'shared', requested: 'shared', session: null });
190
+ const r = await ins({ task: 51, stored: 'shared', requested: 'shared', session: null });
191
+ assert.ok(r.rows[0].id, 'web/unbound claims may coexist — the partial predicate excludes them');
192
+ });
193
+
194
+ console.log(`\n${passed} passed, ${failed} failed`);
195
+ await pool.end();
196
+ process.exit(failed ? 1 : 0);
@@ -214,7 +214,15 @@ t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disable
214
214
  writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ domains: { publicOrigin: 'https://demo.cloudbongos.com', buildersOrigin: 'https://demo.cloudbongos.com', statusOrigin: 'https://demo.cloudbongos.com', oauthOrigin: 'https://demo.cloudbongos.com' }, configDir: 'cloudbongos', envPrefix: 'CLOUDBONGOS' }));
215
215
  writeFileSync(join(dir, 'config', 'modules.json'), JSON.stringify({ modules: { 'art-pipeline': false } }));
216
216
  const res = m.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
217
- assert.ok(res.rebrandRules > 0, 'rebrand rules were built from the OTB→demo branding delta');
217
+ // Founder core → demo: a branding delta exists and rules are built. The NEUTRAL
218
+ // core now carries its own dev-loop hooks (task 1003125), so this body runs there
219
+ // too — where no founder delta exists by design and rules are legitimately zero.
220
+ // The founder-specific assertions below self-scope on that signal; the offender
221
+ // walk and the core-skill materialization checks run in both environments.
222
+ const founderDelta = res.rebrandRules > 0;
223
+ if (!founderDelta) {
224
+ assert.equal(res.rebrandRules, 0, 'neutral core: no founder→instance branding delta');
225
+ }
218
226
 
219
227
  // (a) no copied hook may still name the founder origin or config dir.
220
228
  const offenders = [];
@@ -226,8 +234,11 @@ t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disable
226
234
  assert.deepEqual(offenders, [], `founder origin/config-dir survived in: ${offenders.join(', ')}`);
227
235
 
228
236
  // (b) art-pipeline is off → its otb-* skills are excluded; a core skill remains.
229
- assert.ok(res.excludedSkills.includes('otb-tile-generate'), `art-pipeline skill excluded (got ${res.excludedSkills.join(',')})`);
230
- assert.ok(!existsSync(join(dir, '.claude', 'skills', 'otb-tile-generate')), 'excluded skill not written');
237
+ // (The exclusion map, like the rebrand rules, resolves on a founder core.)
238
+ if (founderDelta) {
239
+ assert.ok(res.excludedSkills.includes('otb-tile-generate'), `art-pipeline skill excluded (got ${res.excludedSkills.join(',')})`);
240
+ assert.ok(!existsSync(join(dir, '.claude', 'skills', 'otb-tile-generate')), 'excluded skill not written');
241
+ }
231
242
  assert.ok(existsSync(join(dir, '.claude', 'skills', 'builder-ship')), 'a core skill is still materialized');
232
243
  rmSync(dir, { recursive: true, force: true });
233
244
  });
@@ -61,7 +61,15 @@ t('every hook User-Agent uses the neutral cloud-bongos- prefix (no founder-brand
61
61
  if (!ua.startsWith(NEUTRAL_PREFIX)) offenders.push(`${f}: '${ua}'`);
62
62
  }
63
63
  }
64
- assert.ok(uaCount > 0, 'expected at least one User-Agent literal across the hooks (regex drift?)');
64
+ // Drift canary, scoped: the neutral core may carry hooks that make no HTTP
65
+ // calls at all (e.g. .claude/hooks/secret-gate.js, task 1003125), so demand a
66
+ // regex match only when some hook raw-contains the header name — raw substring
67
+ // present with zero regex matches is the drift this canary exists to catch.
68
+ const rawMentions = hookFiles.some((f) =>
69
+ fs.readFileSync(path.join(HOOKS_DIR, f), 'utf8').includes('User-Agent'));
70
+ if (rawMentions) {
71
+ assert.ok(uaCount > 0, 'hooks mention User-Agent but the regex matched none (regex drift?)');
72
+ }
65
73
  assert.equal(
66
74
  offenders.length, 0,
67
75
  `hook User-Agent(s) not neutral (must start with "${NEUTRAL_PREFIX}"):\n ${offenders.join('\n ')}`
@@ -337,5 +337,130 @@ t('core_204 keeps the 180 predicate (active + bound rows only) and heals first',
337
337
  'the pre-CREATE heal must group by the NEW key');
338
338
  });
339
339
 
340
+ // =========================================================================
341
+ // task 1002709 — the disambiguation hole. V3.R51 silently RENAMES a colliding
342
+ // worktree_name ("overnight" -> "overnight-2") while the CLI keeps sending the
343
+ // real directory name, which is never renamed. Keying the guard on the STORED
344
+ // name therefore compared two different strings for ONE tree and let a second
345
+ // claim through: one tree, one branch, two active claims — the commingled ship
346
+ // this guard exists to stop. The fix stores the REQUESTED name too and keys on
347
+ // that (core_221).
348
+ // =========================================================================
349
+
350
+ t('THE REPRO: a second claim from the same session into a RENAMED tree is refused', () => {
351
+ // Step 1-2 already happened: this session's first claim asked for "overnight",
352
+ // collided with another builder's active "overnight", and was STORED as
353
+ // "overnight-2". Step 3 is this call — same session, same real directory.
354
+ const held = {
355
+ claim_id: 90, task_id: 800, creator_session_id: 'sess-9',
356
+ worktree_name: 'overnight-2', // what disambiguation stored
357
+ requested_worktree_name: 'overnight', // what the caller actually asked for
358
+ title: 'First',
359
+ };
360
+ const out = shouldRefuseSessionClaim({
361
+ activeClaimsForSession: [held],
362
+ creatorSessionId: 'sess-9',
363
+ worktreeName: 'overnight', // the CLI still sends the real dir
364
+ });
365
+ assert.equal(out.refuse, true,
366
+ 'before task 1002709 this ALLOWED — "overnight" vs the stored "overnight-2" read as two trees');
367
+ assert.equal(out.reason, 'session_already_has_active_claim');
368
+ assert.equal(out.inFlightTaskId, 800);
369
+ });
370
+
371
+ t('a builder genuinely running "foo" and "foo-2" side by side is still allowed', () => {
372
+ // The false-refusal this whole family of bugs is about. Matching
373
+ // /^<requested>-\d+$/ in the guard would have broken exactly this, which is
374
+ // why the requested name is STORED rather than pattern-matched.
375
+ const held = {
376
+ claim_id: 91, task_id: 801, creator_session_id: 'sess-9',
377
+ worktree_name: 'foo', requested_worktree_name: 'foo', title: 'In foo',
378
+ };
379
+ const out = shouldRefuseSessionClaim({
380
+ activeClaimsForSession: [held],
381
+ creatorSessionId: 'sess-9',
382
+ worktreeName: 'foo-2',
383
+ });
384
+ assert.equal(out.refuse, false, 'two real, separate trees — two branches, no commingling');
385
+ });
386
+
387
+ t('rows written before the backfill fall back to worktree_name', () => {
388
+ // A deploy applies code and migration at different instants. In that window an
389
+ // active row has requested_worktree_name NULL, and for any row disambiguation
390
+ // never touched the stored name IS the requested one — so the fallback keeps
391
+ // the guard correct instead of reading every old row as worktree-less.
392
+ const legacy = {
393
+ claim_id: 92, task_id: 802, creator_session_id: 'sess-9',
394
+ worktree_name: 'legacy-tree', title: 'Legacy',
395
+ // requested_worktree_name deliberately absent
396
+ };
397
+ assert.equal(shouldRefuseSessionClaim({
398
+ activeClaimsForSession: [legacy], creatorSessionId: 'sess-9', worktreeName: 'legacy-tree',
399
+ }).refuse, true, 'same tree by the fallback → refuse');
400
+ assert.equal(shouldRefuseSessionClaim({
401
+ activeClaimsForSession: [legacy], creatorSessionId: 'sess-9', worktreeName: 'other-tree',
402
+ }).refuse, false, 'different tree → still allowed');
403
+ });
404
+
405
+ t('an explicit NULL requested name does NOT silently become the stored name', () => {
406
+ // `??` and not `||`: a row whose requested name is genuinely null (a
407
+ // worktree-less claim, backfilled) must fold to the shared-clone group, and a
408
+ // row whose stored name is null must not resurrect anything either.
409
+ const worktreeless = {
410
+ claim_id: 93, task_id: 803, creator_session_id: 'sess-9',
411
+ worktree_name: null, requested_worktree_name: null, title: 'Shared clone',
412
+ };
413
+ assert.equal(shouldRefuseSessionClaim({
414
+ activeClaimsForSession: [worktreeless], creatorSessionId: 'sess-9', worktreeName: null,
415
+ }).refuse, true, 'one shared clone, one branch — genuinely commingles');
416
+ assert.equal(shouldRefuseSessionClaim({
417
+ activeClaimsForSession: [worktreeless], creatorSessionId: 'sess-9', worktreeName: 'wt-real',
418
+ }).refuse, false);
419
+ });
420
+
421
+ const mig221 = () => readFileSync(
422
+ join(dirname(fileURLToPath(import.meta.url)),
423
+ '../migrations/core_221_claims_requested_worktree_name.sql'),
424
+ 'utf8');
425
+
426
+ t('core_221 adds the column additively and backfills without overwriting', () => {
427
+ assert.match(mig221(), /ADD COLUMN IF NOT EXISTS requested_worktree_name text/i);
428
+ assert.match(mig221(), /WHERE requested_worktree_name IS NULL/i,
429
+ 'the backfill must be guarded so a re-run cannot overwrite a real value');
430
+ });
431
+
432
+ t('core_221 re-keys the index onto the REQUESTED name, and drops the old one', () => {
433
+ assert.match(mig221(), /DROP INDEX IF EXISTS uniq_active_claim_per_session_worktree\b/,
434
+ 'leaving core_204 index alongside would re-refuse the legitimate foo / foo-2 case');
435
+ assert.match(mig221(), /CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_requested_worktree/);
436
+ assert.match(mig221(), /COALESCE\(requested_worktree_name,\s*''\)/i,
437
+ 'NULLs are DISTINCT in a plain unique index — the fold is what makes a shared clone self-collide');
438
+ });
439
+
440
+ t('core_221 keeps the partial predicate and heals on the NEW key first', () => {
441
+ assert.match(mig221(), /WHERE released_at IS NULL AND creator_session_id IS NOT NULL/,
442
+ 'web/unbound claims stay excluded, as in 180 and core_204');
443
+ assert.match(mig221(), /PARTITION BY c\.creator_session_id,\s*COALESCE\(c\.requested_worktree_name,\s*''\)/i,
444
+ 'a unique index refuses to create over violating rows — re-keying can newly collide exactly where this bug lived');
445
+ });
446
+
447
+ t('all three index generations stay recognised by claimUniqueViolationError', () => {
448
+ // An unrecognised constraint falls closed onto the generic ALREADY_CLAIMED,
449
+ // which for this case is the phantom-rival answer task 1002589 removed.
450
+ for (const name of [
451
+ 'uniq_active_claim_per_session',
452
+ 'uniq_active_claim_per_session_worktree',
453
+ 'uniq_active_claim_per_session_requested_worktree',
454
+ ]) {
455
+ assert.equal(
456
+ claimUniqueViolationError({ constraint: name }).code,
457
+ 'session_already_has_bound_claim',
458
+ `${name} must render as the honest session answer`,
459
+ );
460
+ }
461
+ assert.equal(claimUniqueViolationError({ constraint: 'uniq_active_claim_per_task' }).code,
462
+ 'ALREADY_CLAIMED', 'a genuine per-task race still reads as a rival claim');
463
+ });
464
+
340
465
  console.log(`\n${passed} passed, ${failed} failed`);
341
466
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,124 @@
1
+ // tests/secret_gate.mjs — the PreToolUse prevent layer (task 1003125, goal 1000076).
2
+ // The gate's contract: block high-confidence secret shapes in Write/Edit/Bash
3
+ // payloads, allow the files whose job is to hold those shapes, and fail OPEN on
4
+ // anything it does not understand (it is the cooperative layer — ADR 0043 shape;
5
+ // the CI scan and the weekly patrol are the nets behind it).
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { createRequire } from 'node:module';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const gate = require(path.join(ROOT, '.claude', 'hooks', 'secret-gate.js'));
15
+
16
+ const ENV_OFF = {}; // no kill-switch
17
+
18
+ // Realistic-shaped fakes (NOT real credentials — lengths and prefixes only).
19
+ const FAKE = {
20
+ anthropic: 'sk-ant-api03-Zx9Yw8Vu7Ts6Rq5Po4Nm3Lk2Jh1Gf0De',
21
+ github: 'ghp_Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0Uv1Wx2Y',
22
+ aws: 'AKIAIOSFODNN7EXAMPLE',
23
+ slack: 'xoxb-1234567890-abcdefghij',
24
+ stripe: 'sk_live_Ab1Cd2Ef3Gh4Ij5Kl6M',
25
+ pem: '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA\n-----END OPENSSH PRIVATE KEY-----',
26
+ dburl: 'postgres://gds:hunter2secret@db.internal:5432/app',
27
+ };
28
+
29
+ test('blocks a Write whose content carries a provider-prefixed token', () => {
30
+ for (const [name, value] of Object.entries(FAKE)) {
31
+ const v = gate.decide(
32
+ { tool_name: 'Write', tool_input: { file_path: 'src/bongos/newfile.js', content: `const k = "${value}";` } },
33
+ ENV_OFF,
34
+ );
35
+ assert.equal(v.block, true, `expected block for ${name}`);
36
+ assert.match(v.reason, /secret-gate: blocked/);
37
+ }
38
+ });
39
+
40
+ test('blocks an Edit new_string and a MultiEdit edit the same way', () => {
41
+ assert.equal(
42
+ gate.decide({ tool_name: 'Edit', tool_input: { file_path: 'server.js', new_string: FAKE.github } }, ENV_OFF).block,
43
+ true,
44
+ );
45
+ assert.equal(
46
+ gate.decide(
47
+ { tool_name: 'MultiEdit', tool_input: { file_path: 'server.js', edits: [{ new_string: 'clean' }, { new_string: FAKE.aws }] } },
48
+ ENV_OFF,
49
+ ).block,
50
+ true,
51
+ );
52
+ });
53
+
54
+ test('blocks a Bash command smuggling a literal token; allows ordinary commands', () => {
55
+ assert.equal(
56
+ gate.decide({ tool_name: 'Bash', tool_input: { command: `echo "${FAKE.anthropic}" >> notes.txt` } }, ENV_OFF).block,
57
+ true,
58
+ );
59
+ for (const cmd of [
60
+ 'git commit -m "feat: rotate the key handling (ADR 0022)"',
61
+ 'export GH_TOKEN=$(gh auth token -u somebuilder) && git fetch origin',
62
+ 'npm test',
63
+ ]) {
64
+ assert.equal(gate.decide({ tool_name: 'Bash', tool_input: { command: cmd } }, ENV_OFF).block, false, `false positive on: ${cmd}`);
65
+ }
66
+ });
67
+
68
+ test('clean writes pass; short/placeholder shapes below the length floors pass', () => {
69
+ for (const content of [
70
+ 'const label = "use your ghp_ token here";', // prefix alone, no 30-char body
71
+ 'sk-ant- keys resolve from the env lane',
72
+ 'AKIA is the AWS key id prefix',
73
+ 'const url = "postgres://localhost:5432/app";', // no embedded password
74
+ ]) {
75
+ assert.equal(
76
+ gate.decide({ tool_name: 'Write', tool_input: { file_path: 'docs/notes.md', content } }, ENV_OFF).block,
77
+ false,
78
+ `false positive on: ${content}`,
79
+ );
80
+ }
81
+ });
82
+
83
+ test('allowlisted paths may hold secret shapes (fixtures, detectors, ADRs, the gate itself)', () => {
84
+ for (const fp of [
85
+ 'tests/bfg.mjs',
86
+ `${ROOT}/tests/bfg.mjs`, // absolute form matches too (segment-anchored)
87
+ 'docs/adr/0022-secrets-policy.md',
88
+ 'modules/security/artifact-scan/detectors/credentials.js',
89
+ '.claude/hooks/secret-gate.js',
90
+ '.gitleaks.toml',
91
+ ]) {
92
+ const v = gate.decide({ tool_name: 'Write', tool_input: { file_path: fp, content: FAKE.pem } }, ENV_OFF);
93
+ assert.equal(v.block, false, `allowlist miss for ${fp}`);
94
+ }
95
+ // …but the allowlist is by SEGMENT, not substring: a lookalike dir stays blocked.
96
+ assert.equal(
97
+ gate.decide({ tool_name: 'Write', tool_input: { file_path: 'src/latests/x.js', content: FAKE.pem } }, ENV_OFF).block,
98
+ true,
99
+ 'lookalike path must not satisfy the tests/ allowlist',
100
+ );
101
+ });
102
+
103
+ test('Bash never gets the path allowlist (commands have no file_path)', () => {
104
+ const v = gate.decide(
105
+ { tool_name: 'Bash', tool_input: { file_path: 'tests/bfg.mjs', command: `echo ${FAKE.github}` } },
106
+ ENV_OFF,
107
+ );
108
+ assert.equal(v.block, true);
109
+ });
110
+
111
+ test('kill-switch disables the gate; malformed payloads fail open', () => {
112
+ assert.equal(
113
+ gate.decide({ tool_name: 'Write', tool_input: { file_path: 'x.js', content: FAKE.github } }, { BONGOS_SECRET_GATE_OFF: '1' }).block,
114
+ false,
115
+ );
116
+ assert.equal(gate.decide(null, ENV_OFF).block, false);
117
+ assert.equal(gate.decide({}, ENV_OFF).block, false);
118
+ assert.equal(gate.decide({ tool_name: 'Write' }, ENV_OFF).block, false);
119
+ });
120
+
121
+ test('unknown writing tools are scanned via the stringified payload, not skipped', () => {
122
+ const v = gate.decide({ tool_name: 'FutureWriteTool', tool_input: { blob: FAKE.stripe } }, ENV_OFF);
123
+ assert.equal(v.block, true);
124
+ });