klypix-mcp 1.74.0 → 1.75.0

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.
@@ -791,9 +791,14 @@ server.registerTool('brain_sync', {
791
791
  version: z.string().max(64).describe('The version this session intends to release (e.g. "1.70.0").'),
792
792
  ref: z.string().max(200).describe('The git ref (branch or tag) the release will be cut from.'),
793
793
  acknowledge: z.array(z.string().max(40)).max(1024).optional().describe('Commit shas this release DELIBERATELY leaves behind. Only needed after a refusal: if the ref would drop finished work, the lease is refused and the response names every sha. Re-declare with those shas here to proceed — and tell the user what they are first.'),
794
- }).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line. A NEW declaration is also checked against what the release would LEAVE BEHIND: if the ref is missing commits that are on trunk or on a branch a live peer session is working on, the lease is REFUSED (nothing is changed) and the response lists them — report those commits to the user, then re-declare with acknowledge:[...] naming each sha if the release should go ahead without them.'),
794
+ }).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line. A NEW declaration is also checked against what the release would LEAVE BEHIND: if the ref is missing commits that are on trunk or on a branch a live peer session is working on, OR commits any session STAKED a releaseClaim on (even one that has since ended), the lease is REFUSED (nothing is changed) and the response lists them — report those commits to the user, then re-declare with acknowledge:[...] naming each sha if the release should go ahead without them. Acknowledging away a claimed or live-owned commit queues its owner a notification automatically.'),
795
+ releaseClaim: z.object({
796
+ shas: z.array(z.string().max(40)).max(20).optional().describe('Commit shas that MUST ride the next release. Stake after committing work a user was promised — the claim OUTLIVES this session (14d), and every future releaseIntent must contain these commits or acknowledge them by name.'),
797
+ note: z.string().max(160).optional().describe('One line of why — shown verbatim in any refusal that names this claim ("founder was told the Arrow tool ships in the next build").'),
798
+ withdraw: z.union([z.array(z.string().max(40)).max(20), z.boolean()]).optional().describe('Shas to withdraw from this session\'s claim; [] or true withdraws the whole claim. Only the staking session (or its logical continuation) can withdraw.'),
799
+ }).optional().describe('Stake a durable claim that specific commits ride the NEXT release — the promise "you\'ll see it in the next build" made machine-readable. Unlike presence rows (which age out ~10min after a session ends), a claim persists until fulfilled (the release ref contains the shas — auto-retired with a courtesy note), withdrawn, or expired (14d). A release that would drop claimed shas is REFUSED until they are acknowledged BY NAME, and acknowledging them away notifies the owner. Use exactly one of shas (stake/extend) or withdraw.'),
795
800
  },
796
- }, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
801
+ }, async ({ project, intent, files, phase, include_context, results, releaseIntent, releaseClaim }, extra) => {
797
802
  const totalStartedAt = Date.now();
798
803
  const report = mcpPresence.sync({
799
804
  project,
@@ -802,6 +807,7 @@ server.registerTool('brain_sync', {
802
807
  phase,
803
808
  results,
804
809
  releaseIntent,
810
+ releaseClaim,
805
811
  deliverMessages: include_context !== false,
806
812
  actionId: extra?.klypixRequestIdentity?.actionId || '',
807
813
  preflight: extra?.klypixBrainSyncPreflight,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.74.0",
3
+ "version": "1.75.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -83,7 +83,7 @@
83
83
  "bench": "node bin/klypix-mcp.mjs bench",
84
84
  "test:bench": "node test/bench.mjs",
85
85
  "pretest": "node test/publish-workflow.mjs",
86
- "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
86
+ "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
87
  "test:memory": "node test/memory-runtime.mjs",
88
88
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
89
89
  "runtime": "node bin/klypix-runtime.mjs"
@@ -1235,6 +1235,9 @@ function normalizeReleaseLease(raw) {
1235
1235
  const refreshedAt = Number(raw.refreshedAt || raw.takenAt || 0);
1236
1236
  if (!holderId || !version || !ref || !takenAt || !refreshedAt) return null;
1237
1237
  const ttlMs = Math.max(60_000, Number(raw.ttlMs) || RELEASE_LEASE_TTL_MS);
1238
+ const acknowledgedShas = [...new Set((Array.isArray(raw.acknowledgedShas) ? raw.acknowledgedShas : [])
1239
+ .map((x) => String(x || '').trim().toLowerCase())
1240
+ .filter((x) => /^[0-9a-f]{4,40}$/.test(x)))].slice(0, 2048);
1238
1241
  return {
1239
1242
  holderId,
1240
1243
  holderClient: String(raw.holderClient || 'unknown').slice(0, 40),
@@ -1244,6 +1247,7 @@ function normalizeReleaseLease(raw) {
1244
1247
  refreshedAt,
1245
1248
  ttlMs,
1246
1249
  expiresAt: refreshedAt + ttlMs,
1250
+ ...(acknowledgedShas.length ? { acknowledgedShas } : {}),
1247
1251
  };
1248
1252
  }
1249
1253
 
@@ -1327,6 +1331,14 @@ export function declareReleaseLease({
1327
1331
  home,
1328
1332
  now = Date.now(),
1329
1333
  ttlMs = RELEASE_LEASE_TTL_MS,
1334
+ // Shas this declaration acknowledged away (ancestry + claims). PERSISTED on
1335
+ // the lease so a holder's refresh does not have to re-litigate them: without
1336
+ // this, the natural refresh pattern — keep attaching the same releaseIntent —
1337
+ // was refused on the very claim the holder had already acknowledged, the
1338
+ // else-chain skipped the refresh, and the lease starved mid-build: the exact
1339
+ // failure the lease-loss warning exists for, rebuilt by the claims gate
1340
+ // (2026-08-17 review blocker B1, found independently by all four lenses).
1341
+ acknowledgedShas = [],
1330
1342
  }) {
1331
1343
  const holderId = recipientKey(sessionId);
1332
1344
  const nextVersion = releaseLeaseVersionKey(version);
@@ -1338,6 +1350,14 @@ export function declareReleaseLease({
1338
1350
  return { write: false, outcome: { ok: false, status: 'conflict', holder: verdict.lease } };
1339
1351
  }
1340
1352
  const stillMine = mine && verdict?.status === 'active';
1353
+ // Union with what the held lease already acknowledged — a re-declare with a
1354
+ // NEW acknowledgement must extend the record, never erase the history that
1355
+ // keeps the holder's refresh from re-refusing.
1356
+ const priorAcks = stillMine && Array.isArray(verdict.lease.acknowledgedShas)
1357
+ ? verdict.lease.acknowledgedShas : [];
1358
+ const acks = [...new Set([...priorAcks, ...(Array.isArray(acknowledgedShas) ? acknowledgedShas : [])]
1359
+ .map((x) => String(x || '').trim().toLowerCase())
1360
+ .filter((x) => /^[0-9a-f]{4,40}$/.test(x)))].slice(0, 2048);
1341
1361
  const stored = {
1342
1362
  schemaVersion: 1,
1343
1363
  holderId,
@@ -1347,6 +1367,7 @@ export function declareReleaseLease({
1347
1367
  takenAt: stillMine ? verdict.lease.takenAt : now,
1348
1368
  refreshedAt: now,
1349
1369
  ttlMs: Math.max(60_000, Number(ttlMs) || RELEASE_LEASE_TTL_MS),
1370
+ ...(acks.length ? { acknowledgedShas: acks } : {}),
1350
1371
  };
1351
1372
  const reclaimed = !stillMine && verdict ? verdict.status : null;
1352
1373
  return {
@@ -1430,6 +1451,191 @@ export function freeReleaseLease({ brainPath, sessionId, home, now = Date.now()
1430
1451
  } });
1431
1452
  }
1432
1453
 
1454
+ // ── Release claims — "my commits ride the next build", surviving session exit ─
1455
+ //
1456
+ // The presence half of the release gate dies with its session: rows age out in
1457
+ // ~10 minutes, after which a dropped commit reverts to an anonymous sha, and a
1458
+ // commit on a branch NO live session is on never enters the ancestry comparison
1459
+ // at all. Field shape (2026-08-17, founder-surfaced): a session promises "you'll
1460
+ // see it in the next build", closes, and the next release passes every gate
1461
+ // while silently leaving that work behind — the exact v1.3.120 failure, one
1462
+ // session-lifetime later. A claim is that promise made DURABLE: shas + owner +
1463
+ // note, persisted in the lane, checked against EVERY releaseIntent ref until it
1464
+ // is fulfilled, withdrawn, or expires.
1465
+ //
1466
+ // Deliberately NOT a lease: claims never conflict with each other, any number
1467
+ // may coexist, and fulfilment is mechanical (the shas are contained in the
1468
+ // release ref). Same identity model as the lease (a claim survives id rotation
1469
+ // via the logical-session match), same atomic lane write, same fail-loud
1470
+ // bounding — a store the gate depends on must never silently drop an entry.
1471
+ export const RELEASE_CLAIM_TTL_MS = 14 * 24 * 60 * 60 * 1000;
1472
+ export const RELEASE_CLAIMS_MAX = 32;
1473
+ export const RELEASE_CLAIM_SHAS_MAX = 20;
1474
+
1475
+ const claimSha = (value) => {
1476
+ const s = String(value || '').trim().toLowerCase();
1477
+ return /^[0-9a-f]{4,40}$/.test(s) ? s : null;
1478
+ };
1479
+
1480
+ function normalizeReleaseClaim(raw, now = Date.now()) {
1481
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1482
+ const ownerId = recipientKey(raw.ownerId);
1483
+ const shas = [...new Set((Array.isArray(raw.shas) ? raw.shas : []).map(claimSha).filter(Boolean))]
1484
+ .slice(0, RELEASE_CLAIM_SHAS_MAX);
1485
+ const stakedAt = Number(raw.stakedAt || 0);
1486
+ if (!ownerId || !shas.length || !stakedAt) return null;
1487
+ const expiresAt = Number(raw.expiresAt || 0) || (stakedAt + RELEASE_CLAIM_TTL_MS);
1488
+ if (now >= expiresAt) return null;
1489
+ return {
1490
+ ownerId,
1491
+ ownerClient: String(raw.ownerClient || 'unknown').slice(0, 40),
1492
+ ownerLogicalId: recipientKey(raw.ownerLogicalId) || null,
1493
+ branch: String(raw.branch || '').slice(0, 120) || null,
1494
+ note: String(raw.note || '').replace(/\s+/g, ' ').trim().slice(0, 160) || null,
1495
+ shas,
1496
+ stakedAt,
1497
+ expiresAt,
1498
+ };
1499
+ }
1500
+
1501
+ const liveReleaseClaims = (raw, now = Date.now()) => (Array.isArray(raw) ? raw : [])
1502
+ .map((c) => normalizeReleaseClaim(c, now))
1503
+ .filter(Boolean);
1504
+
1505
+ // A claim belongs to the caller when any identity in the caller's set matches
1506
+ // the identity the claim recorded — the same rotation-tolerant rule the lease
1507
+ // uses, because "the same conversation after /clear" must still own its claim.
1508
+ const sessionOwnsClaim = (claim, sessions, callerId) => {
1509
+ const key = recipientKey(callerId);
1510
+ if (!key || !claim) return false;
1511
+ if (claim.ownerId === key || claim.ownerLogicalId === key) return true;
1512
+ const me = (Array.isArray(sessions) ? sessions : [])
1513
+ .find((s) => recipientKey(s.id) === key) || null;
1514
+ if (!me) return false;
1515
+ const mine = new Set([recipientKey(me.id), recipientKey(me.logicalSessionId),
1516
+ ...normalizeAliases(me.aliases).map(recipientKey)].filter(Boolean));
1517
+ return mine.has(claim.ownerId) || (claim.ownerLogicalId && mine.has(claim.ownerLogicalId));
1518
+ };
1519
+
1520
+ function mutateReleaseClaims({ brainPath, home, now, mutate }) {
1521
+ if (!brainPath) return { ok: false, status: 'no-brain' };
1522
+ const laneFile = laneFileFor(brainPath, home);
1523
+ const lockFile = laneFile + '.lock';
1524
+ if (!acquireLock(lockFile)) return { ok: false, status: 'lane-locked' };
1525
+ try {
1526
+ const laneRead = readMutableLane(laneFile);
1527
+ if (!laneRead.ok) return { ok: false, status: laneRead.reason };
1528
+ const data = laneRead.data;
1529
+ const sessions = pruneSessions(data.sessions, now);
1530
+ const claims = liveReleaseClaims(data.releaseClaims, now);
1531
+ const result = mutate({ data, sessions, claims });
1532
+ if (result.write) {
1533
+ const next = { ...data };
1534
+ if (!result.claims || !result.claims.length) delete next.releaseClaims;
1535
+ else next.releaseClaims = result.claims;
1536
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1537
+ writeLaneFileAtomic(laneFile, JSON.stringify(next));
1538
+ }
1539
+ return result.outcome;
1540
+ } finally {
1541
+ releaseLock(lockFile);
1542
+ }
1543
+ }
1544
+
1545
+ // Stake (or extend) this session's claim. One claim per owner: re-staking
1546
+ // UNIONS the shas and refreshes note/expiry, so a session adding a second
1547
+ // commit does not spawn a second entry. The lane bound fails LOUD — silently
1548
+ // dropping a claim the gate depends on is the 1.74.0 livelock lesson applied
1549
+ // to a different store.
1550
+ export function stakeReleaseClaim({ brainPath, sessionId, client, logicalSessionId, branch, shas, note, home, now = Date.now() }) {
1551
+ const ownerId = recipientKey(sessionId);
1552
+ const cleanShas = [...new Set((Array.isArray(shas) ? shas : []).map(claimSha).filter(Boolean))];
1553
+ if (!ownerId) return { ok: false, status: 'no-session' };
1554
+ if (!cleanShas.length) return { ok: false, status: 'no-valid-shas' };
1555
+ if (cleanShas.length > RELEASE_CLAIM_SHAS_MAX) {
1556
+ return { ok: false, status: 'too-many-shas', limit: RELEASE_CLAIM_SHAS_MAX, given: cleanShas.length };
1557
+ }
1558
+ return mutateReleaseClaims({ brainPath, home, now, mutate: ({ sessions, claims }) => {
1559
+ const mineIndex = claims.findIndex((c) => sessionOwnsClaim(c, sessions, ownerId));
1560
+ if (mineIndex < 0 && claims.length >= RELEASE_CLAIMS_MAX) {
1561
+ return { write: false, outcome: { ok: false, status: 'claims-full', limit: RELEASE_CLAIMS_MAX } };
1562
+ }
1563
+ const existing = mineIndex >= 0 ? claims[mineIndex] : null;
1564
+ // The union must never silently shed a promised sha (review blocker B2 —
1565
+ // executed: stake 15 + extend 10 reported 'extended' while 5 promised shas
1566
+ // vanished; the v1.3.120 silent-drop class recurring inside its own fix).
1567
+ const unionSize = new Set([...(existing?.shas || []), ...cleanShas]).size;
1568
+ if (unionSize > RELEASE_CLAIM_SHAS_MAX) {
1569
+ return { write: false, outcome: {
1570
+ ok: false, status: 'claim-would-overflow',
1571
+ limit: RELEASE_CLAIM_SHAS_MAX, existing: (existing?.shas || []).length, adding: cleanShas.length,
1572
+ } };
1573
+ }
1574
+ const merged = {
1575
+ ownerId,
1576
+ ownerClient: String(client || existing?.ownerClient || 'unknown').slice(0, 40),
1577
+ ownerLogicalId: recipientKey(logicalSessionId) || existing?.ownerLogicalId || null,
1578
+ branch: String(branch || existing?.branch || '').slice(0, 120) || null,
1579
+ note: String(note || existing?.note || '').replace(/\s+/g, ' ').trim().slice(0, 160) || null,
1580
+ shas: [...new Set([...(existing?.shas || []), ...cleanShas])],
1581
+ stakedAt: existing?.stakedAt || now,
1582
+ expiresAt: now + RELEASE_CLAIM_TTL_MS,
1583
+ };
1584
+ const next = mineIndex >= 0
1585
+ ? claims.map((c, i) => (i === mineIndex ? merged : c))
1586
+ : [...claims, merged];
1587
+ return { write: true, claims: next, outcome: { ok: true, status: mineIndex >= 0 ? 'extended' : 'staked', claim: merged } };
1588
+ } });
1589
+ }
1590
+
1591
+ // Withdraw shas from this session's claim (empty/omitted shas = the whole
1592
+ // claim). Only the owner's identity set can withdraw — a releasing session
1593
+ // must acknowledge a foreign claim through the gate, never delete it.
1594
+ export function withdrawReleaseClaim({ brainPath, sessionId, shas, home, now = Date.now() }) {
1595
+ const ownerId = recipientKey(sessionId);
1596
+ if (!ownerId) return { ok: false, status: 'no-session' };
1597
+ const drop = new Set((Array.isArray(shas) ? shas : []).map(claimSha).filter(Boolean));
1598
+ return mutateReleaseClaims({ brainPath, home, now, mutate: ({ sessions, claims }) => {
1599
+ const mineIndex = claims.findIndex((c) => sessionOwnsClaim(c, sessions, ownerId));
1600
+ if (mineIndex < 0) return { write: false, outcome: { ok: true, status: 'no-claim' } };
1601
+ const mine = claims[mineIndex];
1602
+ const kept = drop.size
1603
+ ? mine.shas.filter((s) => ![...drop].some((d) => s.startsWith(d) || d.startsWith(s)))
1604
+ : [];
1605
+ const next = kept.length
1606
+ ? claims.map((c, i) => (i === mineIndex ? { ...mine, shas: kept } : c))
1607
+ : claims.filter((_, i) => i !== mineIndex);
1608
+ return { write: true, claims: next, outcome: { ok: true, status: kept.length ? 'trimmed' : 'withdrawn', remaining: kept.length } };
1609
+ } });
1610
+ }
1611
+
1612
+ // Retire specific (claim owner, shas) pairs after a release FULFILLS them —
1613
+ // called by the gateway on a granted lease whose ref contains the shas. Whole
1614
+ // claims disappear only when every sha they carry is fulfilled.
1615
+ export function retireFulfilledClaims({ brainPath, fulfilled, home, now = Date.now() }) {
1616
+ const byOwner = new Map((Array.isArray(fulfilled) ? fulfilled : [])
1617
+ .map((f) => [recipientKey(f?.ownerId), new Set((f?.shas || []).map(claimSha).filter(Boolean))]));
1618
+ if (!byOwner.size) return { ok: true, status: 'nothing-to-retire' };
1619
+ return mutateReleaseClaims({ brainPath, home, now, mutate: ({ claims }) => {
1620
+ let changed = false;
1621
+ const next = claims.map((c) => {
1622
+ const done = byOwner.get(c.ownerId);
1623
+ if (!done || !done.size) return c;
1624
+ const kept = c.shas.filter((s) => !done.has(s));
1625
+ if (kept.length !== c.shas.length) changed = true;
1626
+ return kept.length ? { ...c, shas: kept } : null;
1627
+ }).filter(Boolean);
1628
+ if (!changed) return { write: false, outcome: { ok: true, status: 'nothing-to-retire' } };
1629
+ return { write: true, claims: next, outcome: { ok: true, status: 'retired' } };
1630
+ } });
1631
+ }
1632
+
1633
+ // Read-only: live claims. Lock-free like every other read surface.
1634
+ export function readReleaseClaims({ brainPath, home, now = Date.now() } = {}) {
1635
+ if (!brainPath) return [];
1636
+ return liveReleaseClaims(readLane(laneFileFor(brainPath, home)).releaseClaims, now);
1637
+ }
1638
+
1433
1639
  // A long-lived MCP worker can observe SessionEnd(A) and then receive the first
1434
1640
  // request for a brand-new Codex thread B. That is rotation, not a rekey: A's
1435
1641
  // tombstone, scope, message audience/receipts, and authorship must remain bound
@@ -2200,6 +2406,15 @@ export function postPresenceMessage({
2200
2406
  dedupeKey = '',
2201
2407
  home,
2202
2408
  now = Date.now(),
2409
+ // Machine-addressed notifications only (claim owners, ancestry owners): the
2410
+ // exact recipient id is KNOWN, and the recipient being offline is the very
2411
+ // case the notification exists for — a staked claim outlives its session, so
2412
+ // its owner may be gone when the release that drops their work is declared.
2413
+ // The message waits directed in the lane (24h TTL, sender-visible dead-letter
2414
+ // on expiry) and greets the owner's next session. NEVER set this for
2415
+ // hand-typed targets: the exactly-one-live-row refusal below is what keeps an
2416
+ // ambiguous prefix from queuing a note nobody will ever receive.
2417
+ allowOfflineTarget = false,
2203
2418
  }) {
2204
2419
  const body = neutralizeMarkers(String(text || '').replace(/\s+/g, ' ').trim().slice(0, 400));
2205
2420
  if (!brainPath || !from || !body) return { posted: false, message: null, reason: 'invalid-message' };
@@ -2238,7 +2453,11 @@ export function postPresenceMessage({
2238
2453
  // unsafe: it may be an ambiguous UUID prefix or duplicated branch. Refuse
2239
2454
  // instead of queuing a note whose visible `to` never had a recipient.
2240
2455
  if (!broadcast && candidateIds.length !== 1) {
2241
- return { posted: false, message: null, reason: 'target-not-unique' };
2456
+ if (!(allowOfflineTarget && candidateIds.length === 0)) {
2457
+ return { posted: false, message: null, reason: 'target-not-unique' };
2458
+ }
2459
+ // Offline machine-known recipient: address the exact id we were handed.
2460
+ candidateIds.push(String(target).slice(0, 160));
2242
2461
  }
2243
2462
  const message = {
2244
2463
  id: sha16(`${from}|${to}|${body}|${now}|${crypto.randomBytes(4).toString('hex')}`),
@@ -22,9 +22,14 @@ import {
22
22
  messageDecayInfo,
23
23
  peekMessages,
24
24
  pinLaneIdentity,
25
+ neutralizeMarkers,
25
26
  postPresenceMessage,
27
+ readReleaseClaims,
26
28
  readReleaseLease,
27
29
  receiveMessages,
30
+ retireFulfilledClaims,
31
+ stakeReleaseClaim,
32
+ withdrawReleaseClaim,
28
33
  refreshReleaseLease,
29
34
  rekeySessionIdentity,
30
35
  rotateEndedSessionIdentity,
@@ -40,7 +45,7 @@ import {
40
45
  // canonical copy lives in the pure module because that one is import-restricted
41
46
  // (crypto only), so it can never grow a dependency this file would inherit.
42
47
  import { normalizeFileKey } from './finding-routing.mjs';
43
- import { cmpSemver3, collectRepoState, commitFiles, releaseAncestry, releaseAncestryWarnings, repoStateWarnings } from './repo-state.mjs';
48
+ import { cmpSemver3, collectRepoState, commitFiles, releaseAncestry, releaseAncestryWarnings, repoStateWarnings, settleClaimsAgainstRef } from './repo-state.mjs';
44
49
  import { recordResultManifests } from './result-reconcile.mjs';
45
50
 
46
51
  export const MCP_HEARTBEAT_MS = 60_000;
@@ -273,6 +278,49 @@ export function validateReleaseIntent(value) {
273
278
  return { provided: true, ok: true, version: version.replace(/^v/i, ''), ref, acknowledge };
274
279
  }
275
280
 
281
+ /**
282
+ * Validate the releaseClaim input — the durable "my commits ride the next
283
+ * build" promise. Fail-closed like releaseIntent: a malformed claim must
284
+ * never half-sync, and never silently stake less than the caller asked
285
+ * (the 1.74.0 acknowledge-slice lesson, applied at the door).
286
+ * Shapes: { shas: [...], note? } stakes/extends; { withdraw: [...] } trims
287
+ * ({ withdraw: [] } or { withdraw: true } clears the whole claim).
288
+ */
289
+ export function validateReleaseClaim(value) {
290
+ if (value === undefined || value === null) return { provided: false, ok: true };
291
+ const errors = [];
292
+ if (typeof value !== 'object' || Array.isArray(value)) {
293
+ return { provided: true, ok: false, errors: ['releaseClaim must be an object: { shas: [...], note? } to stake, { withdraw: [...] } to withdraw'] };
294
+ }
295
+ const hasStake = value.shas !== undefined;
296
+ const hasWithdraw = value.withdraw !== undefined;
297
+ if (hasStake === hasWithdraw) {
298
+ errors.push('releaseClaim needs exactly one of `shas` (stake) or `withdraw`');
299
+ }
300
+ const parseShas = (raw, field) => {
301
+ if (!Array.isArray(raw)) { errors.push(`releaseClaim.${field} must be an array of commit shas`); return []; }
302
+ const clean = [...new Set(raw
303
+ .filter((x) => typeof x === 'string')
304
+ .map((x) => x.trim().toLowerCase())
305
+ .filter((x) => /^[0-9a-f]{4,40}$/.test(x)))];
306
+ if (raw.length && !clean.length) errors.push(`releaseClaim.${field} contained no valid shas (4-40 hex chars each)`);
307
+ if (clean.length > 20) errors.push(`releaseClaim.${field} carries ${clean.length} shas — the limit is 20; claim the branch-defining commits, not the whole history`);
308
+ return clean;
309
+ };
310
+ let shas = [];
311
+ let withdrawShas = [];
312
+ let withdrawAll = false;
313
+ if (hasStake) shas = parseShas(value.shas, 'shas');
314
+ if (hasWithdraw) {
315
+ if (value.withdraw === true || (Array.isArray(value.withdraw) && !value.withdraw.length)) withdrawAll = true;
316
+ else if (value.withdraw === false) errors.push('releaseClaim.withdraw: false does nothing — omit the field to keep the claim, or pass true / [] to withdraw it entirely');
317
+ else withdrawShas = parseShas(value.withdraw, 'withdraw');
318
+ }
319
+ const note = typeof value.note === 'string' ? value.note.replace(/\s+/g, ' ').trim().slice(0, 160) : null;
320
+ if (errors.length) return { provided: true, ok: false, errors };
321
+ return { provided: true, ok: true, stake: hasStake, shas, withdraw: hasWithdraw, withdrawShas, withdrawAll, note };
322
+ }
323
+
276
324
  /**
277
325
  * Does this acknowledgement actually name what the release would drop?
278
326
  *
@@ -2074,6 +2122,7 @@ export function createMcpPresence({
2074
2122
  phase = 'checkpoint',
2075
2123
  results,
2076
2124
  releaseIntent,
2125
+ releaseClaim,
2077
2126
  deliverMessages = true,
2078
2127
  include_context,
2079
2128
  actionId = '',
@@ -2103,6 +2152,23 @@ export function createMcpPresence({
2103
2152
  ].join('\n');
2104
2153
  return report;
2105
2154
  }
2155
+ const releaseClaimChecked = validateReleaseClaim(releaseClaim);
2156
+ if (releaseClaimChecked.provided && !releaseClaimChecked.ok) {
2157
+ const report = syncPreflightFailure({
2158
+ status: 'invalid-release-claim',
2159
+ reason: 'release-claim-malformed',
2160
+ requestedProject: typeof project === 'string' ? project.slice(0, 512) : null,
2161
+ });
2162
+ report.structured.phase = nextPhase;
2163
+ report.structured.errors = releaseClaimChecked.errors.map((message) => ({ message }));
2164
+ report.structured.timingMs.coordination = Math.max(0, Date.now() - syncStartedAt);
2165
+ report.text = [
2166
+ 'KLYPIX release claim was rejected; no task, lease, claim, presence, or message state changed.',
2167
+ ...releaseClaimChecked.errors.map((error) => `- ${error}`),
2168
+ 'Supply releaseClaim as { shas: ["<sha>", ...], note?: "why it matters" } to stake, or { withdraw: [...] } to withdraw.',
2169
+ ].join('\n');
2170
+ return report;
2171
+ }
2106
2172
  const preflightInput = {
2107
2173
  project,
2108
2174
  projectProvided: project !== undefined,
@@ -2648,9 +2714,36 @@ export function createMcpPresence({
2648
2714
  let releaseAdvisoryText = '';
2649
2715
  let workAtRisk = null;
2650
2716
  let workAtRiskText = '';
2717
+ let releaseClaimResult = null;
2651
2718
  {
2652
2719
  const leaseStamp = now();
2653
2720
  let outcome = null;
2721
+ // ── Release CLAIM: the durable "my commits ride the next build" ──────
2722
+ // Executed BEFORE any releaseIntent gating in the same call, so a
2723
+ // stake+declare combination sees its own fresh claim — symmetric with
2724
+ // every other session's, deliberately.
2725
+ if (releaseClaimChecked.provided) {
2726
+ const selfRow = (report.sessions || []).find((s) => s?.id === sessionId) || {};
2727
+ releaseClaimResult = releaseClaimChecked.withdraw
2728
+ ? withdrawReleaseClaim({
2729
+ brainPath,
2730
+ sessionId,
2731
+ shas: releaseClaimChecked.withdrawAll ? [] : releaseClaimChecked.withdrawShas,
2732
+ home,
2733
+ now: leaseStamp,
2734
+ })
2735
+ : stakeReleaseClaim({
2736
+ brainPath,
2737
+ sessionId,
2738
+ client: (preparedClientInfo || {}).client || selfRow.client || 'unknown',
2739
+ logicalSessionId: selfRow.logicalSessionId || null,
2740
+ branch: selfRow.branch || null,
2741
+ shas: releaseClaimChecked.shas,
2742
+ note: releaseClaimChecked.note,
2743
+ home,
2744
+ now: leaseStamp,
2745
+ });
2746
+ }
2654
2747
  // Set when the holder's own sync arrived with the lease already close to
2655
2748
  // lapsing; reported after the refresh so the holder learns the habit that
2656
2749
  // protects them, not merely that nothing broke this time.
@@ -2680,9 +2773,14 @@ export function createMcpPresence({
2680
2773
  // to make silence insufficient: a session that cannot proceed without
2681
2774
  // reproducing the missing commits has, in practice, had to surface them.
2682
2775
  //
2683
- // Only a NEW declaration is gated. A holder refreshing mid-release is
2684
- // never re-blocked — that would be an obstacle, not a gate — and a
2685
- // deliberate off-trunk hotfix is one acknowledged call away.
2776
+ // Only a NEW declaration is ANCESTRY-gated. A holder refreshing the
2777
+ // same ref is never re-blocked on ancestry — that would be an obstacle,
2778
+ // not a gate — and a deliberate off-trunk hotfix is one acknowledged
2779
+ // call away. CLAIMS are the one exception: they settle on every
2780
+ // declaration, because a claim staked after the lease was taken is
2781
+ // exactly the promise the window between lease and build would
2782
+ // otherwise swallow; acknowledgements persist on the lease so the
2783
+ // holder re-litigates only what is genuinely NEW.
2686
2784
  const existingLease = readReleaseLease({ brainPath, home, now: leaseStamp });
2687
2785
  // recipientKey is module-private in agent-presence; the same normalization
2688
2786
  // (trim + 160-char bound) reproduced here rather than widening its surface.
@@ -2717,9 +2815,55 @@ export function createMcpPresence({
2717
2815
  gateAncestry = { status: 'unknown', reason: 'git-unavailable', ref: releaseIntentChecked.ref, isDescendant: false, missingCount: 0, sources: [], missing: [] };
2718
2816
  }
2719
2817
  }
2720
- if (gateAncestry && !gateAncestry.isDescendant
2721
- && !ancestryAcknowledged(gateAncestry, releaseIntentChecked.acknowledge)) {
2722
- outcome = { ok: false, status: 'ancestry-unacknowledged', ancestry: gateAncestry };
2818
+ // ── STAKED CLAIMS gate — settled on EVERY declaration, including a
2819
+ // holder's same-ref refresh. Ancestry compares trunk and LIVE peer
2820
+ // branches; a claim covers exactly the hole that leaves: a commit whose
2821
+ // owner has closed their session and whose branch nobody is on any
2822
+ // more. A claim staked AFTER the lease was taken must still gate the
2823
+ // next sync, or the window between lease and build is where a promise
2824
+ // goes to die. Per-sha probe failures inside the settlement BLOCK
2825
+ // (missing), never pass; only a catastrophic throw degrades to empty,
2826
+ // and ancestry still stands guard on that path.
2827
+ let claimSettlement = [];
2828
+ try {
2829
+ const stakedClaims = readReleaseClaims({ brainPath, home, now: leaseStamp });
2830
+ if (stakedClaims.length) {
2831
+ claimSettlement = settleClaimsAgainstRef(path.dirname(brainPath), releaseIntentChecked.ref, stakedClaims);
2832
+ }
2833
+ } catch { claimSettlement = []; }
2834
+ const unmetClaims = claimSettlement.filter((entry) => !entry.contained);
2835
+ const claimShasRequired = [...new Set(unmetClaims
2836
+ .flatMap((entry) => [...entry.missing, ...entry.unresolvable, ...entry.unverified])
2837
+ .map((sha) => String(sha).toLowerCase()))];
2838
+ // The holder's PERSISTED acknowledgements count too (review blocker B1):
2839
+ // a refresh checkpoint re-attaching the same releaseIntent must not be
2840
+ // refused over a claim the holder already acknowledged at grant time.
2841
+ // A claim staked SINCE then still gates — its shas are in no lease
2842
+ // record — which is exactly the window the settle-on-every-declaration
2843
+ // rule exists for.
2844
+ const leaseAcks = sameRefAsHeld && Array.isArray(existingLease?.acknowledgedShas)
2845
+ ? existingLease.acknowledgedShas : [];
2846
+ const ackGiven = [...new Set([
2847
+ ...(releaseIntentChecked.acknowledge || []),
2848
+ ...leaseAcks,
2849
+ ].map((g) => String(g).toLowerCase()))];
2850
+ const claimsAcknowledged = claimShasRequired
2851
+ .every((sha) => ackGiven.some((g) => sha.startsWith(g) || g.startsWith(sha)));
2852
+ const ancestryBlocks = gateAncestry && !gateAncestry.isDescendant
2853
+ && !ancestryAcknowledged(gateAncestry, ackGiven);
2854
+ const claimsRefuse = claimShasRequired.length > 0 && !claimsAcknowledged;
2855
+ if (ancestryBlocks || claimsRefuse) {
2856
+ outcome = {
2857
+ ok: false,
2858
+ status: ancestryBlocks ? 'ancestry-unacknowledged' : 'claims-unacknowledged',
2859
+ ancestry: gateAncestry
2860
+ || { status: 'ok', ref: releaseIntentChecked.ref, isDescendant: true, missingCount: 0, sources: [], missing: [] },
2861
+ unmetClaims,
2862
+ // The refusal headline must not lie to a HOLDER: their held lease
2863
+ // was not revoked — it just was not refreshed by this sync, and it
2864
+ // lapses at TTL unless the new claim is acknowledged.
2865
+ holderRefusal: sameRefAsHeld,
2866
+ };
2723
2867
  } else {
2724
2868
  outcome = declareReleaseLease({
2725
2869
  brainPath,
@@ -2729,8 +2873,11 @@ export function createMcpPresence({
2729
2873
  client: (preparedClientInfo || {}).client || 'unknown',
2730
2874
  home,
2731
2875
  now: leaseStamp,
2876
+ acknowledgedShas: ackGiven,
2732
2877
  });
2733
2878
  if (gateAncestry && !gateAncestry.isDescendant) outcome = { ...outcome, acknowledgedAncestry: gateAncestry };
2879
+ if (unmetClaims.length) outcome = { ...outcome, acknowledgedClaims: unmetClaims };
2880
+ if (claimSettlement.length) outcome = { ...outcome, claimSettlement };
2734
2881
  }
2735
2882
  } else if (clearCompletionScope) {
2736
2883
  const freed = freeReleaseLease({ brainPath, sessionId, home, now: leaseStamp });
@@ -2766,8 +2913,23 @@ export function createMcpPresence({
2766
2913
  refreshedAt: active.refreshedAt,
2767
2914
  expiresAt: active.expiresAt,
2768
2915
  } : null;
2769
- if (outcome?.status === 'ancestry-unacknowledged') {
2916
+ if (outcome?.status === 'ancestry-unacknowledged' || outcome?.status === 'claims-unacknowledged') {
2770
2917
  const anc = outcome.ancestry;
2918
+ // Staked-claim requirement rides the SAME refusal and the SAME
2919
+ // acknowledge array: one gate, one handshake, whichever half tripped.
2920
+ const unmetClaims = Array.isArray(outcome.unmetClaims) ? outcome.unmetClaims : [];
2921
+ const claimShas = [...new Set(unmetClaims
2922
+ .flatMap((entry) => [...entry.missing, ...entry.unresolvable, ...entry.unverified])
2923
+ .map((sha) => String(sha).toLowerCase()))];
2924
+ const claimLines = unmetClaims.map((entry) => {
2925
+ const c = entry.claim;
2926
+ const ageDays = Math.max(0, Math.round((leaseStamp - (c.stakedAt || leaseStamp)) / 86_400_000));
2927
+ const parts = [];
2928
+ if (entry.missing.length) parts.push(`${entry.missing.length} NOT in ${releaseIntentChecked.ref}: ${entry.missing.slice(0, 4).map((x) => x.slice(0, 9)).join(', ')}${entry.missing.length > 4 ? ` +${entry.missing.length - 4}` : ''}`);
2929
+ if (entry.unresolvable.length) parts.push(`${entry.unresolvable.length} unresolvable (history rewritten? owner must re-stake): ${entry.unresolvable.slice(0, 3).map((x) => x.slice(0, 9)).join(', ')}`);
2930
+ if (entry.unverified.length) parts.push(`${entry.unverified.length} unverified (probe budget)`);
2931
+ return neutralizeMarkers(`STAKED CLAIM UNMET — session ${String(c.ownerId).slice(0, 8)} (${c.ownerClient}${c.branch ? `, ${c.branch}` : ''}) staked ${ageDays}d ago${c.note ? `: "${c.note}"` : ''} — ${parts.join(' · ')}. The owner may no longer be live; this claim is their voice.`);
2932
+ });
2771
2933
  // The COMPLETE set the gate will demand, not the subset the prose names.
2772
2934
  // These two used to be the same list, which is how a release dropping 71
2773
2935
  // commits was acknowledged by naming 10. The prose still shows 8 per
@@ -2807,20 +2969,41 @@ export function createMcpPresence({
2807
2969
  // (2026-08-17 review catch — the primary fix renders bare shas at the
2808
2970
  // source so 'unnameable' now truly means no shas at all; this guard is
2809
2971
  // the belt to that suspender).
2810
- acknowledgeRequired: anc.status === 'unnameable' ? [] : shas,
2972
+ acknowledgeRequired: [...new Set([...(anc.status === 'unnameable' ? [] : shas), ...claimShas])],
2973
+ ...(unmetClaims.length ? { stakedClaims: unmetClaims.map((entry) => ({
2974
+ owner: String(entry.claim.ownerId).slice(0, 8),
2975
+ ownerClient: entry.claim.ownerClient,
2976
+ branch: entry.claim.branch,
2977
+ note: entry.claim.note,
2978
+ stakedAt: entry.claim.stakedAt,
2979
+ missing: entry.missing,
2980
+ unresolvable: entry.unresolvable,
2981
+ unverified: entry.unverified,
2982
+ })) } : {}),
2811
2983
  };
2984
+ const claimsOnlyImperative = (!(anc && !anc.isDescendant) && unmetClaims.length)
2985
+ ? [
2986
+ '',
2987
+ 'WHAT THIS MEANS FOR THE USER: the branch history is clean, but a session STAKED A CLAIM that specific commits must ride this release, and they are not in it. That session may have already told the user this work would ship — the claim is the only voice it has left.',
2988
+ 'Say this to them in your own words, naming the claim(s) above, BEFORE going any further.',
2989
+ ]
2990
+ : [];
2812
2991
  releaseText = [
2813
- 'KLYPIX release lease REFUSED — the lease was not taken. No release state changed.',
2992
+ outcome.holderRefusal
2993
+ ? 'KLYPIX release refresh REFUSED — a claim staked SINCE your lease was granted is unmet. Your held lease was NOT revoked, but this sync did NOT refresh it: acknowledge the claim below or the lease lapses at its ~2h TTL.'
2994
+ : 'KLYPIX release lease REFUSED — the lease was not taken. No release state changed.',
2814
2995
  '',
2815
2996
  ...releaseAncestryWarnings(anc),
2997
+ ...(claimLines.length ? ['', ...claimLines] : []),
2998
+ ...claimsOnlyImperative,
2816
2999
  '',
2817
3000
  // Deliberately NOT a ready-to-paste call. Pre-rendering the exact
2818
3001
  // retry made the bypass the easiest thing on screen — an agent could
2819
3002
  // copy it and never say a word to anyone. The shas are listed above;
2820
3003
  // reproducing them is the work, and the work is the point.
2821
- (anc.status !== 'unnameable' && shas.length)
3004
+ ((anc.status !== 'unnameable' && shas.length) || claimShas.length)
2822
3005
  ? [
2823
- `To proceed anyway, re-send releaseIntent with an "acknowledge" array naming each of the ${shas.length} sha(s) in acknowledgeRequired.`,
3006
+ `To proceed anyway, re-send releaseIntent with an "acknowledge" array naming each of the ${new Set([...(anc.status === 'unnameable' ? [] : shas), ...claimShas]).size} sha(s) in acknowledgeRequired.`,
2824
3007
  unnamed
2825
3008
  ? `NOTE: only ${shas.length - unnamed} of those ${shas.length} are spelled out above — ${unnamed} more are in acknowledgeRequired and are NOT shown in this text. Read them before you decide; the prose is a sample, the requirement is the whole set.`
2826
3009
  : '',
@@ -2855,15 +3038,101 @@ export function createMcpPresence({
2855
3038
  releaseLease = { status: 'declare-failed', reason: outcome.status };
2856
3039
  releaseText = `KLYPIX release lease was not recorded (${outcome.status}); no lease state changed. Retry on the next sync.`;
2857
3040
  } else if (outcome?.status === 'taken' || outcome?.status === 'refreshed') {
3041
+ // ── Claim settlement on a GRANTED lease ──────────────────────────
3042
+ // Fulfilled claims retire (their shas are in the ref — the promise is
3043
+ // kept, with a courtesy note to the owner). Acknowledged-away claims
3044
+ // STAY STAKED — the work still is not shipping — and their owners are
3045
+ // notified NOW, at declare time, not after the build exists: today the
3046
+ // releaser sees the OWNED-BY warning but the owner learns only when
3047
+ // the installer is missing their feature (founder-surfaced 2026-08-17).
3048
+ const fulfilledClaims = (outcome.claimSettlement || []).filter((entry) => entry.contained);
3049
+ const awayClaims = Array.isArray(outcome.acknowledgedClaims) ? outcome.acknowledgedClaims : [];
3050
+ let claimsNotified = 0;
3051
+ if (fulfilledClaims.length) {
3052
+ try {
3053
+ retireFulfilledClaims({
3054
+ brainPath,
3055
+ fulfilled: fulfilledClaims.map((entry) => ({ ownerId: entry.claim.ownerId, shas: entry.claim.shas })),
3056
+ home,
3057
+ now: leaseStamp,
3058
+ });
3059
+ } catch { /* retirement is best-effort; a live claim re-settles next declare */ }
3060
+ for (const entry of fulfilledClaims) {
3061
+ if (entry.claim.ownerId === sessionId) continue;
3062
+ const posted = postPresenceMessage({
3063
+ brainPath,
3064
+ from: sessionId,
3065
+ // Logical identity preferred: a revived session matches it directly,
3066
+ // and a SINGLE candidate keeps the receipt denominator honest.
3067
+ to: entry.claim.ownerLogicalId || entry.claim.ownerId,
3068
+ text: `Your staked release claim is FULFILLED: v${releaseIntentChecked.version} declared from ${releaseIntentChecked.ref} CONTAINS your ${entry.claim.shas.length} claimed commit(s) (${entry.claim.shas.slice(0, 3).map((x) => x.slice(0, 9)).join(', ')}${entry.claim.shas.length > 3 ? '…' : ''}). The claim is retired.`,
3069
+ allowOfflineTarget: true,
3070
+ dedupeKey: `claim-fulfilled|${entry.claim.ownerId}|${releaseIntentChecked.version}`,
3071
+ home,
3072
+ now: leaseStamp,
3073
+ });
3074
+ if (posted.posted) claimsNotified++;
3075
+ }
3076
+ }
3077
+ const notifiedOwners = new Set();
3078
+ for (const entry of awayClaims) {
3079
+ if (entry.claim.ownerId === sessionId) continue;
3080
+ notifiedOwners.add(entry.claim.ownerId);
3081
+ const gone = [...entry.missing, ...entry.unresolvable, ...entry.unverified];
3082
+ const posted = postPresenceMessage({
3083
+ brainPath,
3084
+ from: sessionId,
3085
+ to: entry.claim.ownerLogicalId || entry.claim.ownerId,
3086
+ text: `Release v${releaseIntentChecked.version} from ${releaseIntentChecked.ref} was declared ACKNOWLEDGING AWAY your claimed commit(s) ${gone.slice(0, 4).map((x) => String(x).slice(0, 9)).join(', ')}${gone.length > 4 ? ` +${gone.length - 4}` : ''} — they will NOT be in this build. Your claim stays staked for the next release.`,
3087
+ allowOfflineTarget: true,
3088
+ dedupeKey: `claim-away|${entry.claim.ownerId}|${releaseIntentChecked.version}|${releaseIntentChecked.ref}`,
3089
+ home,
3090
+ now: leaseStamp,
3091
+ });
3092
+ if (posted.posted) claimsNotified++;
3093
+ }
3094
+ // LIVE owners named by the ancestry annotation get the same courtesy —
3095
+ // their commits were acknowledged away too, they just never staked.
3096
+ const ancOwners = new Map();
3097
+ for (const src of (outcome.acknowledgedAncestry?.sources || [])) {
3098
+ for (const c of (src.missing || [])) {
3099
+ for (const o of (c.owners || [])) {
3100
+ if (o.sharedScopeOnly || !o.sessionId || o.sessionId === sessionId) continue;
3101
+ if (notifiedOwners.has(o.sessionId)) continue;
3102
+ const cur = ancOwners.get(o.sessionId) || [];
3103
+ if (cur.length < 4) cur.push(`${c.sha} ${String(c.subject || '').slice(0, 60)}`.trim());
3104
+ ancOwners.set(o.sessionId, cur);
3105
+ }
3106
+ }
3107
+ }
3108
+ for (const [ownerId, commits] of ancOwners) {
3109
+ const posted = postPresenceMessage({
3110
+ brainPath,
3111
+ from: sessionId,
3112
+ to: ownerId,
3113
+ text: `Release v${releaseIntentChecked.version} from ${releaseIntentChecked.ref} was declared ACKNOWLEDGING AWAY commit(s) of yours: ${commits.join(' · ')} — they will NOT be in this build. Stake a releaseClaim if they must ride the next one.`,
3114
+ allowOfflineTarget: true,
3115
+ dedupeKey: `anc-away|${ownerId}|${releaseIntentChecked.version}|${releaseIntentChecked.ref}`,
3116
+ home,
3117
+ now: leaseStamp,
3118
+ });
3119
+ if (posted.posted) claimsNotified++;
3120
+ }
2858
3121
  releaseLease = {
2859
3122
  status: outcome.status,
2860
3123
  ...(outcome.reclaimed ? { reclaimed: outcome.reclaimed } : {}),
2861
3124
  ...(holderBlock ? { holder: holderBlock } : {}),
3125
+ ...(fulfilledClaims.length ? { claimsFulfilled: fulfilledClaims.length } : {}),
3126
+ ...(awayClaims.length ? { claimsAcknowledgedAway: awayClaims.length } : {}),
3127
+ ...(claimsNotified ? { claimOwnersNotified: claimsNotified } : {}),
2862
3128
  };
2863
3129
  if (releaseIntentChecked.provided) {
2864
3130
  releaseText = outcome.status === 'taken'
2865
3131
  ? `KLYPIX release lease taken: this session now EXCLUSIVELY holds release preparation for v${releaseIntentChecked.version} from ${releaseIntentChecked.ref}${outcome.reclaimed ? ` (reclaimed: the previous lease was ${outcome.reclaimed === 'expired' ? 'expired' : 'held by a session that is no longer live'})` : ''}. Checkpoints refresh the ~2h lease; phase "complete" frees it.`
2866
3132
  : `KLYPIX release lease refreshed: v${releaseIntentChecked.version} from ${releaseIntentChecked.ref}.`;
3133
+ if (fulfilledClaims.length || awayClaims.length) {
3134
+ releaseText += ` Claims: ${fulfilledClaims.length} fulfilled${awayClaims.length ? `, ${awayClaims.length} ACKNOWLEDGED AWAY (their owners were queued a notification — the work is NOT in this build)` : ''}.`;
3135
+ }
2867
3136
  }
2868
3137
  } else if (outcome?.status === 'lease-lost') {
2869
3138
  releaseLease = { status: 'lease-lost', reason: outcome.reason || null };
@@ -3042,6 +3311,8 @@ export function createMcpPresence({
3042
3311
  // refresh / conflict / release outcome plus the current holder — and the
3043
3312
  // zero-config checkout-ahead advisory when nothing is declared.
3044
3313
  ...(releaseLease ? { releaseLease } : {}),
3314
+ // Additive: outcome of a releaseClaim stake/withdraw carried in THIS call.
3315
+ ...(releaseClaimResult ? { releaseClaim: releaseClaimResult } : {}),
3045
3316
  ...(releaseAdvisory ? { releaseAdvisory } : {}),
3046
3317
  ...(workAtRisk ? { workAtRisk } : {}),
3047
3318
  ...(resultReconciliation ? { resultReconciliation: {
@@ -3091,6 +3362,11 @@ export function createMcpPresence({
3091
3362
  `KLYPIX Context Gateway: session ${sessionId} · phase ${nextPhase} · coordination ${durationMs}ms.`,
3092
3363
  deferralText,
3093
3364
  resultText,
3365
+ releaseClaimResult
3366
+ ? (releaseClaimResult.ok
3367
+ ? `KLYPIX release claim ${releaseClaimResult.status}${releaseClaimResult.claim ? `: ${releaseClaimResult.claim.shas.length} sha(s) staked — every future releaseIntent must contain them or acknowledge them BY NAME, even after this session ends (expires ${Math.round((releaseClaimResult.claim.expiresAt - syncStartedAt) / 86_400_000)}d)` : ''}${releaseClaimResult.status === 'trimmed' || releaseClaimResult.status === 'withdrawn' ? ` (${releaseClaimResult.remaining ?? 0} sha(s) remain staked)` : ''}.`
3368
+ : `KLYPIX release claim FAILED (${releaseClaimResult.status}${releaseClaimResult.limit ? `, limit ${releaseClaimResult.limit}` : ''}) — nothing was staked or withdrawn. ${releaseClaimResult.status === 'claims-full' ? 'The lane holds its maximum of staked claims; withdraw a stale one or raise it with the maintainers.' : ''}`)
3369
+ : '',
3094
3370
  releaseText,
3095
3371
  formatTaskPresence(snapshot, stamp),
3096
3372
  messagesText,
@@ -221,6 +221,71 @@ export function commitFiles(projectDir, shas, { execGit = defaultExecGit, timeou
221
221
  return out;
222
222
  }
223
223
 
224
+ /**
225
+ * Settle staked release claims against the release ref.
226
+ *
227
+ * A claim is the durable half of "you'll see it in the next build": shas an
228
+ * owner promised would ride the next release, surviving the owner's session.
229
+ * Ancestry compares trunk and LIVE peer branches; a claim covers exactly the
230
+ * hole that leaves — a commit on a branch nobody is on any more.
231
+ *
232
+ * Per sha, three honest outcomes: contained in the ref, MISSING from it, or
233
+ * UNRESOLVABLE (the sha is gone — rebase/squash rewrote it, or the branch was
234
+ * deleted). Unresolvable is reported as its own state, never silently treated
235
+ * as contained (that would let history rewriting clear a claim) and never as
236
+ * missing (the remedy differs: the owner must re-stake the rewritten shas).
237
+ *
238
+ * Bounded: at most `maxChecks` unique shas are probed; anything beyond is
239
+ * marked unverified and SAID so — a capped check must never read as a full one.
240
+ */
241
+ export function settleClaimsAgainstRef(projectDir, ref, claims, { execGit = defaultExecGit, maxChecks = 64 } = {}) {
242
+ const git = makeGit(execGit);
243
+ const dir = String(projectDir || '');
244
+ const target = String(ref || '').trim();
245
+ const list = Array.isArray(claims) ? claims : [];
246
+ if (!dir || !target || !list.length) return [];
247
+ const verdictCache = new Map(); // sha -> 'contained' | 'missing' | 'unresolvable' | 'unverified'
248
+ let checks = 0;
249
+ const verdictFor = (sha) => {
250
+ if (verdictCache.has(sha)) return verdictCache.get(sha);
251
+ let verdict;
252
+ if (checks >= maxChecks) {
253
+ verdict = 'unverified';
254
+ } else {
255
+ checks++;
256
+ if (git(dir, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) === null) {
257
+ verdict = 'unresolvable';
258
+ } else {
259
+ // makeGit maps a non-zero exit to null, so "not an ancestor" and a
260
+ // failed spawn look identical here — both must BLOCK (missing), never
261
+ // pass: over-reporting is a conversation, under-reporting ships a
262
+ // build without the claimed work.
263
+ verdict = git(dir, ['merge-base', '--is-ancestor', sha, target]) === null ? 'missing' : 'contained';
264
+ }
265
+ }
266
+ verdictCache.set(sha, verdict);
267
+ return verdict;
268
+ };
269
+ return list.map((claim) => {
270
+ const missing = [];
271
+ const unresolvable = [];
272
+ const unverified = [];
273
+ for (const sha of claim.shas || []) {
274
+ const verdict = verdictFor(String(sha).toLowerCase());
275
+ if (verdict === 'missing') missing.push(sha);
276
+ else if (verdict === 'unresolvable') unresolvable.push(sha);
277
+ else if (verdict === 'unverified') unverified.push(sha);
278
+ }
279
+ return {
280
+ claim,
281
+ missing,
282
+ unresolvable,
283
+ unverified,
284
+ contained: !missing.length && !unresolvable.length && !unverified.length,
285
+ };
286
+ });
287
+ }
288
+
224
289
  export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, peerBranches = [] } = {}) {
225
290
  const git = makeGit(execGit);
226
291
  const dir = String(projectDir || '');