@bongos/core 1.19.73 → 1.19.75

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.73",
6
- "core_contract": "1.19.73",
7
- "source_commit": "f5ec0fadc32c8950ab4d2f02435ac2d0bc83f734",
5
+ "core_version": "1.19.75",
6
+ "core_contract": "1.19.75",
7
+ "source_commit": "f5b89a2cb47b8c69de479b9b1478f5fbdb40875b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T16:47:10.479Z",
9
+ "built_at": "2026-08-12T19:28:37.911Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 294,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1540,
20
- "tree_sha256": "357d62d4d1ad2d1e0c99bb9d6d9ab5b93459f3a4019d96c497e80e372ab88163",
20
+ "tree_sha256": "1c258f995c7524078b43066cba3d5e1eead6b3bb1560325e86b47d1d6f52fa00",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3407,7 +3407,7 @@
3407
3407
  {
3408
3408
  "path": "modules/lifecycle/github-push.js",
3409
3409
  "mode": "0000644",
3410
- "sha256": "9658ccf0dd332d6c4ce1e269c2966ebe2e2bed5f909d5d503722d8e8cfb1ebbc"
3410
+ "sha256": "f090fbe484a1c9e8bf40d2871a150cef8ce73199455e54e44d117ce1ebc21da5"
3411
3411
  },
3412
3412
  {
3413
3413
  "path": "modules/lifecycle/goal-advisory.js",
@@ -3462,7 +3462,7 @@
3462
3462
  {
3463
3463
  "path": "modules/lifecycle/publish-reconciler.js",
3464
3464
  "mode": "0000644",
3465
- "sha256": "3f7f65007955c419ccce1c89cb1f6da6eeca6daa8a76a738540db2858ebff4e6"
3465
+ "sha256": "54fd7ba3024169f5887fe58182ba03519e66acbbf28c1b753712dbd36f91da01"
3466
3466
  },
3467
3467
  {
3468
3468
  "path": "modules/lifecycle/routes/analytics.js",
@@ -4052,12 +4052,12 @@
4052
4052
  {
4053
4053
  "path": "package-lock.json",
4054
4054
  "mode": "0000644",
4055
- "sha256": "c7932df69f1c13a153563e9bb7c981d3c8275dceb74fca16353f8d35c5ac4a95"
4055
+ "sha256": "603dc614586b1e32f711c2a1920f59cc0d5a4f0110cc04328147cbefe91360b6"
4056
4056
  },
4057
4057
  {
4058
4058
  "path": "package.json",
4059
4059
  "mode": "0000644",
4060
- "sha256": "86d0679adfaa2465acb32bcec3bc74350beab35a8acdfd9127bf8fa939de994f"
4060
+ "sha256": "bb09cbe76cedaf22fe31392cd460f058b575b1dd46f5f6ab4a3ca1d9017226f0"
4061
4061
  },
4062
4062
  {
4063
4063
  "path": "public-docs/index.html",
@@ -5252,7 +5252,7 @@
5252
5252
  {
5253
5253
  "path": "scripts/gds/ship.js",
5254
5254
  "mode": "0000644",
5255
- "sha256": "aa77aef41188c5ef596c9c5e7e89f54f97c93b6e889e1c776b8fe34c8af66cce"
5255
+ "sha256": "be7d4d35f8f19e4687e9a8444ca36c222895805a7ec35952f92a27ef567ed2b5"
5256
5256
  },
5257
5257
  {
5258
5258
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5682,7 +5682,7 @@
5682
5682
  {
5683
5683
  "path": "src/module-api.js",
5684
5684
  "mode": "0000644",
5685
- "sha256": "3959276e8d8fa6161db40b398846c5d78f618960cf4b7134ff7a180ae0ca17ed"
5685
+ "sha256": "b977ffdcca426c7f9c36e7f62579064c6d7b744f30e820f0f686320bf9a7378b"
5686
5686
  },
5687
5687
  {
5688
5688
  "path": "src/module-loader/loader.js",
@@ -6467,7 +6467,7 @@
6467
6467
  {
6468
6468
  "path": "tests/github_push_land_proof.mjs",
6469
6469
  "mode": "0000644",
6470
- "sha256": "c55433099e4681a8dd091873e9f8687f35e629b4a4ad3a3c3dd462860070fc59"
6470
+ "sha256": "3774072a50fb6ffdadfe2c890e8828473b72456f1c213f812c14eb08c74401a4"
6471
6471
  },
6472
6472
  {
6473
6473
  "path": "tests/github_push_merge_driver.mjs",
@@ -7227,7 +7227,7 @@
7227
7227
  {
7228
7228
  "path": "tests/publish_reconciler.mjs",
7229
7229
  "mode": "0000644",
7230
- "sha256": "1aeb87a14e411160784c94ee5392f323a2ad0a373bd9acf91337289041e8d2bc"
7230
+ "sha256": "fe4b594292992429fe5e077491fa2916028a2f1815414f94434f83b4186b1138"
7231
7231
  },
7232
7232
  {
7233
7233
  "path": "tests/rank_gate.mjs",
@@ -497,7 +497,12 @@ async function findTaskPullRequest({ taskId }, deps = {}) {
497
497
  if (!res.ok) throw err('STATUS_FETCH_FAILED', { status: res.status });
498
498
  const prs = await res.json();
499
499
  if (!Array.isArray(prs)) return null;
500
- const titleRe = new RegExp(`^task\\s+#?${Number(taskId)}\\b`, 'i');
500
+ // task 1002723: real ships also title PRs with the COMMIT SUBJECT carrying a
501
+ // "(task <id>)" suffix (PR #89 — "docs: session log … (task 1002702)"), which the
502
+ // prefix-only match missed, stranding the task. A parenthesized exact id in the
503
+ // TITLE is still a title-level bind — body mentions stay excluded (see above).
504
+ const id = Number(taskId);
505
+ const titleRe = new RegExp(`(?:^task\\s+#?${id}\\b)|(?:\\(task\\s+#?${id}\\))`, 'i');
501
506
  for (const pr of prs) {
502
507
  if (!pr || !titleRe.test(String(pr.title || ''))) continue;
503
508
  const head = pr.head || {};
@@ -78,8 +78,13 @@ const seams = require('../../src/module-api');
78
78
  const conflictResolve = require('./conflict-resolve');
79
79
 
80
80
  // 5-min cadence: frequent enough that a strand heals within minutes of CI
81
- // finishing, cheap enough that the GitHub API cost is negligible (2 calls per
82
- // confirmed task per sweep, a handful of tasks). Override for tests/ops.
81
+ // finishing, cheap enough that the GitHub API cost is negligible: steady state
82
+ // is 2 status-path calls per confirmed task per sweep (+1 title-search when no
83
+ // PR is known). A task whose convention is not yet known walks up to three
84
+ // candidates ONCE (≤6 calls); the winning branch — or, when nothing is found,
85
+ // the conventional first candidate — is memoized per worktree, so every later
86
+ // sweep probes one branch and leaves discovery to the title search. Override
87
+ // for tests/ops.
83
88
  const INTERVAL_MS = Number(process.env.PUBLISH_RECONCILE_MS) || 5 * 60_000;
84
89
  // A first sweep shortly after boot so a strand left by the previous process (e.g.
85
90
  // a deploy that restarted the server mid-poll) heals fast, not on the next tick.
@@ -97,6 +102,10 @@ const STALE_CONFIRMED_MS = Number(process.env.PUBLISH_RECONCILE_STALE_MS) || 30
97
102
  // a persistently-red branch surfaces via the STALE strand WARN above).
98
103
  const MAX_STALE_HEAL_ATTEMPTS = Number(process.env.PUBLISH_RECONCILE_MAX_HEAL) || 3;
99
104
  const healAttempts = new Map(); // branch -> regen-heal attempts this process
105
+ // task 1002723 (round 2): worktree_name -> the branch convention that owned its PR
106
+ // last sweep, so steady-state sweeps probe ONE branch (2 calls) instead of walking
107
+ // all three candidates every tick. In-memory like healAttempts; pruned per sweep.
108
+ const branchHomes = new Map();
100
109
 
101
110
  let intervalTimer = null;
102
111
  let firstTimer = null;
@@ -186,14 +195,15 @@ async function runSweep(deps = {}) {
186
195
  }
187
196
 
188
197
  // Safety: a worktree_name can be reused across sequential (released) claims, so
189
- // two confirmed tasks could derive the SAME branch. We can't tell which task a
190
- // merged PR belongs to, so we skip ambiguous branches (logged) and leave them
198
+ // two confirmed tasks could derive the SAME branch set. We can't tell which task
199
+ // a merged PR belongs to, so we skip ambiguous worktrees (logged) and leave them
191
200
  // for manual handling rather than risk shipping the wrong task's row.
192
- const branchCount = new Map();
201
+ // task 1002723: keyed on the worktree_name itself — every branch candidate is
202
+ // derived from it, so one ambiguous wt poisons all its candidates equally.
203
+ const wtCount = new Map();
193
204
  for (const r of rows) {
194
205
  if (!r.worktree_name) continue;
195
- const b = `claude/${r.worktree_name}`;
196
- branchCount.set(b, (branchCount.get(b) || 0) + 1);
206
+ wtCount.set(r.worktree_name, (wtCount.get(r.worktree_name) || 0) + 1);
197
207
  }
198
208
 
199
209
  let reconciled = 0;
@@ -205,8 +215,15 @@ async function runSweep(deps = {}) {
205
215
  let unprovable = 0; // confirmed tasks the sweep could not prove landed this pass
206
216
  let staleStrands = 0; // of those, the ones older than STALE_CONFIRMED_MS (WARN'd)
207
217
  let strandsFlagged = 0; // task 1002606: needs_rebase flags NEWLY set this sweep (any site)
218
+ // task 1002723: one entry per confirmed task per sweep ("<id>:<disposition>").
219
+ // The 1002702/1002716 strands sat through 5+ sweeps with ZERO journal evidence
220
+ // the sweep even saw them — young unprovables were a silent `continue`. Silence
221
+ // must not masquerade as health; this feeds the always-on line below.
222
+ const dispositions = [];
208
223
  const attempts = deps.healAttempts || healAttempts; // injectable for deterministic tests
224
+ const homes = deps.branchHome || branchHomes; // task 1002723: wt -> winning branch (injectable)
209
225
  const branchesSeen = new Set(); // to prune the attempts map post-sweep
226
+ const wtSeen = new Set(); // to prune the branchHomes map post-sweep
210
227
  const nowMs = Date.now();
211
228
 
212
229
  // task 1002606 — the ONE exit for every strand signal: flag needs_rebase
@@ -430,23 +447,62 @@ async function runSweep(deps = {}) {
430
447
  };
431
448
 
432
449
  for (const r of rows) {
433
- let branch = r.worktree_name ? `claude/${r.worktree_name}` : null;
434
- if (branch) branchesSeen.add(branch);
435
- const branchUsable = !!branch && push.isSafeBranch(branch) && branchCount.get(branch) === 1;
436
- if (branch && branchCount.get(branch) > 1) {
437
- // A reused worktree_name maps a branch to >1 confirmed task, so a merged PR is
438
- // ambiguous — but each task's OWN head_sha is not, so we skip only the branch/PR
439
- // path and let the head_sha ancestry land-check below resolve it unambiguously.
440
- logFn(`[publish-reconciler] task ${r.task_id}: branch ${branch} maps to >1 confirmed task — using head_sha land-check only`);
450
+ // task 1002723: claude/<wt> is only ONE of the branch names real ships push —
451
+ // a worktree/laptop ship pushes <wt> itself (the task-1002702 strand: PR #89 on
452
+ // branch task-1002702, invisible to claude/task-1002702) or worktree-<wt> (the
453
+ // task-1002645 wave). Try each convention until one has a PR, so the merge
454
+ // nudge / heals / land proof engage regardless of which client shipped.
455
+ const wt = r.worktree_name || null;
456
+ if (wt) wtSeen.add(wt);
457
+ let candidates = wt
458
+ ? [`claude/${wt}`, wt, `worktree-${wt}`].filter((b) => push.isSafeBranch(b))
459
+ : [];
460
+ // Cross-sweep memoization: once a sweep has found which convention owns the
461
+ // task's PR, later sweeps probe that branch first and break immediately —
462
+ // steady-state cost stays at the documented 2 calls per confirmed task.
463
+ // The home may also be an ADOPTED foreign branch name (title-search result) —
464
+ // it entered the memo through an isSafeBranch-verified path either way.
465
+ const home = wt ? homes.get(wt) : null;
466
+ if (home) {
467
+ candidates = [home, ...candidates.filter((b) => b !== home)];
468
+ }
469
+ const wtUsable = !!wt && wtCount.get(wt) === 1;
470
+ if (wt && wtCount.get(wt) > 1) {
471
+ // A reused worktree_name maps its branch set to >1 confirmed task, so a merged
472
+ // PR is ambiguous — but each task's OWN head_sha is not, so we skip only the
473
+ // branch/PR path and let the head_sha ancestry land-check below resolve it.
474
+ logFn(`[publish-reconciler] task ${r.task_id}: worktree ${wt} maps to >1 confirmed task — using head_sha land-check only`);
441
475
  }
442
476
 
477
+ let branch = null;
443
478
  let landed = false;
444
479
  let landNote = '';
445
480
  let branchStatus = null;
446
481
  let prNotLanded = false;
447
482
 
448
- if (branchUsable) {
449
- ({ status: branchStatus, prNotLanded } = await sweepBranchPath(r, branch));
483
+ if (wtUsable) {
484
+ const homeKnown = !!home;
485
+ for (const cand of candidates) {
486
+ branchesSeen.add(cand);
487
+ branch = cand;
488
+ ({ status: branchStatus, prNotLanded } = await sweepBranchPath(r, cand));
489
+ if (branchStatus && branchStatus.pr_state !== 'none') {
490
+ homes.set(wt, cand); // remember the PR home for the next sweep
491
+ break;
492
+ }
493
+ // A remembered home with no PR means the full walk already happened on an
494
+ // earlier sweep and found nothing — don't re-walk the other candidates
495
+ // every 5 minutes. The title-search fallback below still runs each sweep,
496
+ // so a late-appearing PR on ANY branch name is discovered there (and the
497
+ // adoption path re-homes it).
498
+ if (homeKnown) break;
499
+ }
500
+ // First full walk found no PR anywhere: pin the conventional candidate as
501
+ // home so later sweeps probe ONE branch, not three, for this class (the
502
+ // never-pushed / allow-empty shapes, where a PR essentially never appears).
503
+ if (!(branchStatus && branchStatus.pr_state !== 'none') && !homeKnown && candidates.length) {
504
+ homes.set(wt, candidates[0]);
505
+ }
450
506
  }
451
507
 
452
508
  // task 1002645 — the claude/<worktree_name> derivation is the DEV-BOX convention;
@@ -468,6 +524,8 @@ async function runSweep(deps = {}) {
468
524
  branch = found.branch;
469
525
  branchesSeen.add(branch);
470
526
  ({ status: branchStatus, prNotLanded } = await sweepBranchPath(r, branch));
527
+ // Memoize the adopted home too, so the next sweep skips the re-search.
528
+ if (wt && branchStatus && branchStatus.pr_state !== 'none') homes.set(wt, branch);
471
529
  }
472
530
  } catch (_) {
473
531
  /* best-effort: a PR-search miss waits for the next sweep */
@@ -525,6 +583,7 @@ async function runSweep(deps = {}) {
525
583
  : prNotLanded
526
584
  ? 'pr_not_landed'
527
585
  : (!branch && !r.published_head_sha) ? 'no_branch_no_tip' : 'land_not_proven';
586
+ dispositions.push(`${r.task_id}:${reason}`);
528
587
  const ageMs = r.confirmed_at ? (nowMs - new Date(r.confirmed_at).getTime()) : null;
529
588
  if (ageMs != null && ageMs > STALE_CONFIRMED_MS) {
530
589
  staleStrands++;
@@ -554,6 +613,7 @@ async function runSweep(deps = {}) {
554
613
  try {
555
614
  const shipped = await db.shipTask({ taskId: r.task_id, builderId: r.builder_id });
556
615
  reconciled++;
616
+ dispositions.push(`${r.task_id}:shipped`);
557
617
  logFn(
558
618
  `[publish-reconciler] task ${r.task_id} (${landNote}) landed → shipped`
559
619
  );
@@ -571,7 +631,10 @@ async function runSweep(deps = {}) {
571
631
  // in the race window — expected + idempotent, NOT an error.
572
632
  if (e && e.code !== 'TASK_NOT_CONFIRMED') {
573
633
  errors++;
634
+ dispositions.push(`${r.task_id}:ship_failed`);
574
635
  errFn(`[publish-reconciler] shipTask ${r.task_id} failed: ${e && e.message}`);
636
+ } else {
637
+ dispositions.push(`${r.task_id}:already_flipped`);
575
638
  }
576
639
  }
577
640
  }
@@ -584,6 +647,11 @@ async function runSweep(deps = {}) {
584
647
  const plain = b.startsWith('behind:') ? b.slice('behind:'.length) : b;
585
648
  if (!branchesSeen.has(plain)) attempts.delete(b);
586
649
  }
650
+ // Same leak guard for the branch-home memo: a worktree that left the confirmed
651
+ // set (landed or released) never returns under the same name.
652
+ for (const w of homes.keys()) {
653
+ if (!wtSeen.has(w)) homes.delete(w);
654
+ }
587
655
 
588
656
  if (reconciled || merged || resolvedConflicts || refreshedStale || behindUpdated) {
589
657
  logFn(
@@ -595,6 +663,14 @@ async function runSweep(deps = {}) {
595
663
  `[publish-reconciler] ${staleStrands} stale strand(s): confirmed >${Math.round(STALE_CONFIRMED_MS / 60_000)}min with no provable land — credited but unlanded; see STRAND lines above`
596
664
  );
597
665
  }
666
+ // task 1002723: the always-on per-sweep account. Every confirmed task the sweep
667
+ // saw gets a named disposition — so a strand is visible on its FIRST quiet sweep,
668
+ // not at the 30-min WARN (or never, as with the silent young-unprovable class).
669
+ if (dispositions.length) {
670
+ const shown = dispositions.slice(0, 20).join(' · ');
671
+ const more = dispositions.length > 20 ? ` (+${dispositions.length - 20} more)` : '';
672
+ logFn(`[publish-reconciler] dispositions: ${shown}${more}`);
673
+ }
598
674
  return { checked: rows.length, reconciled, merged, resolvedConflicts, refreshedStale, behindUpdated, errors, unprovable, staleStrands, strandsFlagged };
599
675
  }
600
676
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.73",
3
+ "version": "1.19.75",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.73",
9
+ "version": "1.19.75",
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.73",
3
+ "version": "1.19.75",
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",
@@ -2497,6 +2497,17 @@ function zeroCommitVerdict(commitsAhead, dbOnly) {
2497
2497
  async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
2498
2498
  const branch = currentBranch();
2499
2499
  if (!branch || branch === 'main' || branch === 'HEAD') {
2500
+ // task 1002723: an --allow-empty / --db-only ship legitimately runs FROM the
2501
+ // main checkout (nothing was ever committed), but this bail fired BEFORE the
2502
+ // deliberate-empty landing below could — the task parked at confirmed with no
2503
+ // branch and no published tip, a shape the server sweep can never prove (the
2504
+ // task-1002716 strand). Land it here iff the checkout has zero commits ahead
2505
+ // of origin/main; anything else keeps the safe bail.
2506
+ if (dbOnly && shellOk('git fetch origin main')
2507
+ && zeroCommitVerdict(gitOk('git rev-list origin/main..HEAD --count'), true) === 'db-only') {
2508
+ console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) from the main checkout - nothing to merge; advancing to shipped without merge/deploy.');
2509
+ return landed();
2510
+ }
2500
2511
  console.log(` auto-merge: skipped (current branch is ${branch})`);
2501
2512
  // The merge source IS the current branch, so on main there is nothing to
2502
2513
  // merge. Half of the single-checkout catch-22 this task documents: the other
package/src/module-api.js CHANGED
@@ -573,8 +573,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
573
573
  // landed since 1.19.71 with no explicit bump. run 31617976225. (task 1002620)
574
574
  // 1.19.73 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
575
575
  // landed since 1.19.72 with no explicit bump. run 31619275904. (task 1002620)
576
+ // 1.19.74 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
577
+ // landed since 1.19.73 with no explicit bump. run 31628379432. (task 1002620)
578
+ // 1.19.75 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
579
+ // landed since 1.19.74 with no explicit bump. run 31632769733. (task 1002620)
576
580
  // ---------------------------------------------------------------------------
577
- const CORE_VERSION = '1.19.73'; // CI auto-patch carrier (ADR 0161); see changelog above
581
+ const CORE_VERSION = '1.19.75'; // CI auto-patch carrier (ADR 0161); see changelog above
578
582
 
579
583
  // A namespaced logger so a module's log lines are attributable + consistent.
580
584
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -187,6 +187,26 @@ test('findTaskPullRequest: "task <id>: …" title → returns the REAL head bran
187
187
  assert.equal(r.pr_number, 36);
188
188
  });
189
189
 
190
+ test('findTaskPullRequest 1002723: a commit-subject title with a "(task <id>)" suffix binds (the PR #89 miss)', async () => {
191
+ const r = await gh.findTaskPullRequest(
192
+ { taskId: 1002702 },
193
+ depsWithPulls([
194
+ pull('docs: session log — QA walk, 23 findings (task 1002702)', 'task-1002702', { merged: true, number: 89 }),
195
+ ])
196
+ );
197
+ assert.ok(r, 'the suffix convention must bind');
198
+ assert.equal(r.branch, 'task-1002702');
199
+ assert.equal(r.pr_number, 89);
200
+ });
201
+
202
+ test('findTaskPullRequest 1002723: a bare UNPARENTHESIZED mid-title id still does NOT bind', async () => {
203
+ const r = await gh.findTaskPullRequest(
204
+ { taskId: 55 },
205
+ depsWithPulls([pull('fix the thing near task 55 handling', 'other-branch')])
206
+ );
207
+ assert.equal(r, null, 'only the ^task-prefix or (task <id>) parenthesized forms are title-level binds');
208
+ });
209
+
190
210
  test('findTaskPullRequest: a body-only mention must NOT bind (tighter than prReferencesTask)', async () => {
191
211
  const r = await gh.findTaskPullRequest(
192
212
  { taskId: 55 },
@@ -59,6 +59,7 @@ function mkDeps({
59
59
  refreshStaleImpl,
60
60
  updateByBranch = {},
61
61
  healAttempts = new Map(),
62
+ branchHome = new Map(),
62
63
  } = {}) {
63
64
  const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], search: [], resolve: [], refresh: [], update: [], flag: [], events: [], logs: [], errors: [] };
64
65
  // isSafeBranch mirrors github-push's pattern enough for the test (rejects
@@ -189,6 +190,8 @@ function mkDeps({
189
190
  // task 1915: per-branch treadmill guard, injected so the cap is deterministic and
190
191
  // never leaks module state across tests.
191
192
  healAttempts,
193
+ // task 1002723: per-worktree branch-home memo, injected for the same reason.
194
+ branchHome,
192
195
  log: (m) => calls.logs.push(m),
193
196
  error: (m) => calls.errors.push(m),
194
197
  _calls: calls,
@@ -1194,7 +1197,10 @@ test('1002645 THE PIN: a worktree-named branch (laptop wave ship) lands UNATTEND
1194
1197
  const r = await reconciler.runSweep(deps);
1195
1198
  assert.equal(r.reconciled, 1, 'the wave-5 strand shape must land with NO human step (ADR 0162)');
1196
1199
  assert.deepEqual(deps._calls.ship, [{ taskId: 2645, builderId: 95 }]);
1197
- assert.deepEqual(deps._calls.search, [2645], 'the fallback ran exactly once');
1200
+ // task 1002723: worktree-<wt> is now a first-class branch CANDIDATE, so this shape
1201
+ // resolves before the title fallback is ever needed (the fallback stays for PRs on
1202
+ // foreign branch names). The pin's intent — lands unattended — is what matters.
1203
+ assert.equal(deps._calls.search.length, 0, 'candidate branches resolve it without the title search');
1198
1204
  assert.ok(deps._calls.merge.includes('worktree-task-2645'),
1199
1205
  'the merge nudge engages on the REAL branch — the only merger when native auto-merge is disallowed');
1200
1206
  });
@@ -1271,6 +1277,86 @@ test('1002645 the reuse guard still holds through the fallback: adopted PR merge
1271
1277
  assert.equal(deps._calls.ship.length, 0);
1272
1278
  });
1273
1279
 
1280
+ // --- task 1002723 — the 1002702/1002716 strand pair: bare-branch candidates +
1281
+ // the always-on disposition line (silence must not masquerade as health) --------
1282
+
1283
+ test('1002723 THE PIN: a PR on the BARE worktree branch (task-<id>, no prefix) lands unattended', async () => {
1284
+ // The 1002702 strand: ship pushed branch task-1002702 (== worktree_name), PR #89
1285
+ // merged, and the sweep saw pr_state:none on claude/task-1002702 forever while
1286
+ // the title search missed the commit-subject PR title. The bare name is now a
1287
+ // first-class candidate.
1288
+ const deps = mkDeps({
1289
+ rows: [{ task_id: 77, builder_id: 3, worktree_name: 'task-77', confirmed_at: new Date().toISOString() }],
1290
+ statusByBranch: { 'task-77': landedPrFor(77) },
1291
+ });
1292
+ const r = await reconciler.runSweep(deps);
1293
+ assert.equal(r.reconciled, 1, 'the merged-PR-on-bare-branch shape must land with no manual flip');
1294
+ assert.ok(deps._calls.status.includes('claude/task-77'), 'dev-box convention tried first');
1295
+ assert.ok(deps._calls.status.includes('task-77'), 'then the bare worktree branch');
1296
+ assert.deepEqual(deps._calls.ship, [{ taskId: 77, builderId: 3 }]);
1297
+ });
1298
+
1299
+ test('1002723 every confirmed task gets a per-sweep disposition entry — including silent young unprovables', async () => {
1300
+ const deps = mkDeps({
1301
+ rows: [
1302
+ // the 1002716 shape: allow-empty ship — no branch, no tip, young
1303
+ { task_id: 91, builder_id: 3, worktree_name: null, confirmed_at: new Date().toISOString() },
1304
+ // a young task whose branches have no PR anywhere yet
1305
+ { task_id: 92, builder_id: 3, worktree_name: 'wt-92', confirmed_at: new Date().toISOString() },
1306
+ ],
1307
+ });
1308
+ await reconciler.runSweep(deps);
1309
+ const line = deps._calls.logs.find((l) => typeof l === 'string' && l.includes('dispositions:'));
1310
+ assert.ok(line, 'a dispositions line is logged whenever confirmed tasks were checked');
1311
+ assert.ok(line.includes('91:no_branch_no_tip'), `task 91 named with its reason in: ${line}`);
1312
+ assert.ok(line.includes('92:land_not_proven'), `task 92 named with its reason in: ${line}`);
1313
+ });
1314
+
1315
+ test('1002723 the winning branch convention is MEMOIZED across sweeps (steady-state back to ~2 calls)', async () => {
1316
+ const branchHome = new Map();
1317
+ const row = { task_id: 78, builder_id: 3, worktree_name: 'task-78', confirmed_at: new Date().toISOString() };
1318
+ const first = mkDeps({ rows: [row], statusByBranch: { 'task-78': PENDING }, branchHome });
1319
+ await reconciler.runSweep(first);
1320
+ assert.ok(first._calls.status.includes('claude/task-78'), 'first sweep walks the candidates');
1321
+ const second = mkDeps({ rows: [row], statusByBranch: { 'task-78': PENDING }, branchHome });
1322
+ await reconciler.runSweep(second);
1323
+ assert.equal(second._calls.status[0], 'task-78', 'second sweep probes the memoized home first');
1324
+ assert.ok(!second._calls.status.includes('claude/task-78'), 'losing candidates are not re-probed');
1325
+ });
1326
+
1327
+ test('1002723 a NO-PR worktree walks the candidates once, then probes only its pinned home (title search stays the discovery channel)', async () => {
1328
+ const branchHome = new Map();
1329
+ const row = { task_id: 80, builder_id: 3, worktree_name: 'task-80', confirmed_at: new Date().toISOString() };
1330
+ const first = mkDeps({ rows: [row], branchHome }); // no PR on any branch
1331
+ await reconciler.runSweep(first);
1332
+ assert.equal(first._calls.status.length, 3, 'first sweep walks all three candidates');
1333
+ assert.equal(branchHome.get('task-80'), 'claude/task-80', 'conventional candidate pinned as home');
1334
+ const second = mkDeps({ rows: [row], branchHome });
1335
+ await reconciler.runSweep(second);
1336
+ assert.deepEqual(second._calls.status, ['claude/task-80'], 'later sweeps probe ONE branch, not three');
1337
+ assert.ok(second._calls.search.includes(80), 'the title-search fallback still runs — late PRs are discoverable');
1338
+ });
1339
+
1340
+ test('1002723 the branch-home memo is pruned when its worktree leaves the confirmed set', async () => {
1341
+ const branchHome = new Map();
1342
+ const row = { task_id: 79, builder_id: 3, worktree_name: 'task-79', confirmed_at: new Date().toISOString() };
1343
+ await reconciler.runSweep(mkDeps({ rows: [row], statusByBranch: { 'task-79': PENDING }, branchHome }));
1344
+ assert.ok(branchHome.has('task-79'), 'memoized while confirmed');
1345
+ await reconciler.runSweep(mkDeps({ rows: [], branchHome }));
1346
+ assert.equal(branchHome.size, 0, 'pruned once the worktree leaves the set');
1347
+ });
1348
+
1349
+ test('1002723 a shipped task appears in the dispositions line as shipped', async () => {
1350
+ const deps = mkDeps({
1351
+ rows: [{ task_id: 93, builder_id: 3, worktree_name: 'wt-93', published_head_sha: ON_MAIN }],
1352
+ statusByBranch: { 'claude/wt-93': LANDED },
1353
+ ancestryBySha: { [ON_MAIN]: true },
1354
+ });
1355
+ await reconciler.runSweep(deps);
1356
+ const line = deps._calls.logs.find((l) => typeof l === 'string' && l.includes('dispositions:'));
1357
+ assert.ok(line && line.includes('93:shipped'), `shipped disposition in: ${line}`);
1358
+ });
1359
+
1274
1360
  // Drain the microtask queue, then report + exit with the right code.
1275
1361
  setTimeout(() => {
1276
1362
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);