klypix-mcp 1.74.0 → 1.76.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,15 @@ 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
+ publish: z.boolean().optional().describe('Also write the claim as .klypix/claims/<owner>.json in the project (or delete that file when withdrawing). Commit it and the promise TRAVELS WITH THE REPO: every clone\'s release gate reads it, it is reviewable in PRs, and its history is auditable — team-wide claims over plain git, zero infrastructure.'),
800
+ }).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
801
  },
796
- }, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
802
+ }, async ({ project, intent, files, phase, include_context, results, releaseIntent, releaseClaim }, extra) => {
797
803
  const totalStartedAt = Date.now();
798
804
  const report = mcpPresence.sync({
799
805
  project,
@@ -802,6 +808,7 @@ server.registerTool('brain_sync', {
802
808
  phase,
803
809
  results,
804
810
  releaseIntent,
811
+ releaseClaim,
805
812
  deliverMessages: include_context !== false,
806
813
  actionId: extra?.klypixRequestIdentity?.actionId || '',
807
814
  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.76.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, deleteCommittedClaim, readCommittedClaims, releaseAncestry, releaseAncestryWarnings, repoStateWarnings, settleClaimsAgainstRef, writeCommittedClaim } 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,52 @@ 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 (value.publish !== undefined && typeof value.publish !== 'boolean') {
321
+ errors.push('releaseClaim.publish must be a boolean');
322
+ }
323
+ if (errors.length) return { provided: true, ok: false, errors };
324
+ return { provided: true, ok: true, stake: hasStake, shas, withdraw: hasWithdraw, withdrawShas, withdrawAll, note, publish: value.publish === true };
325
+ }
326
+
276
327
  /**
277
328
  * Does this acknowledgement actually name what the release would drop?
278
329
  *
@@ -2074,6 +2125,7 @@ export function createMcpPresence({
2074
2125
  phase = 'checkpoint',
2075
2126
  results,
2076
2127
  releaseIntent,
2128
+ releaseClaim,
2077
2129
  deliverMessages = true,
2078
2130
  include_context,
2079
2131
  actionId = '',
@@ -2103,6 +2155,23 @@ export function createMcpPresence({
2103
2155
  ].join('\n');
2104
2156
  return report;
2105
2157
  }
2158
+ const releaseClaimChecked = validateReleaseClaim(releaseClaim);
2159
+ if (releaseClaimChecked.provided && !releaseClaimChecked.ok) {
2160
+ const report = syncPreflightFailure({
2161
+ status: 'invalid-release-claim',
2162
+ reason: 'release-claim-malformed',
2163
+ requestedProject: typeof project === 'string' ? project.slice(0, 512) : null,
2164
+ });
2165
+ report.structured.phase = nextPhase;
2166
+ report.structured.errors = releaseClaimChecked.errors.map((message) => ({ message }));
2167
+ report.structured.timingMs.coordination = Math.max(0, Date.now() - syncStartedAt);
2168
+ report.text = [
2169
+ 'KLYPIX release claim was rejected; no task, lease, claim, presence, or message state changed.',
2170
+ ...releaseClaimChecked.errors.map((error) => `- ${error}`),
2171
+ 'Supply releaseClaim as { shas: ["<sha>", ...], note?: "why it matters" } to stake, or { withdraw: [...] } to withdraw.',
2172
+ ].join('\n');
2173
+ return report;
2174
+ }
2106
2175
  const preflightInput = {
2107
2176
  project,
2108
2177
  projectProvided: project !== undefined,
@@ -2648,9 +2717,56 @@ export function createMcpPresence({
2648
2717
  let releaseAdvisoryText = '';
2649
2718
  let workAtRisk = null;
2650
2719
  let workAtRiskText = '';
2720
+ let releaseClaimResult = null;
2721
+ let committedClaimProblems = [];
2651
2722
  {
2652
2723
  const leaseStamp = now();
2653
2724
  let outcome = null;
2725
+ // ── Release CLAIM: the durable "my commits ride the next build" ──────
2726
+ // Executed BEFORE any releaseIntent gating in the same call, so a
2727
+ // stake+declare combination sees its own fresh claim — symmetric with
2728
+ // every other session's, deliberately.
2729
+ if (releaseClaimChecked.provided) {
2730
+ const selfRow = (report.sessions || []).find((s) => s?.id === sessionId) || {};
2731
+ releaseClaimResult = releaseClaimChecked.withdraw
2732
+ ? withdrawReleaseClaim({
2733
+ brainPath,
2734
+ sessionId,
2735
+ shas: releaseClaimChecked.withdrawAll ? [] : releaseClaimChecked.withdrawShas,
2736
+ home,
2737
+ now: leaseStamp,
2738
+ })
2739
+ : stakeReleaseClaim({
2740
+ brainPath,
2741
+ sessionId,
2742
+ client: (preparedClientInfo || {}).client || selfRow.client || 'unknown',
2743
+ logicalSessionId: selfRow.logicalSessionId || null,
2744
+ branch: selfRow.branch || null,
2745
+ shas: releaseClaimChecked.shas,
2746
+ note: releaseClaimChecked.note,
2747
+ home,
2748
+ now: leaseStamp,
2749
+ });
2750
+ // publish:true makes the promise TRAVEL: the claim is also written as
2751
+ // .klypix/claims/<owner>.json in the project, which the session commits
2752
+ // like any other file — from then on every clone's release gate reads
2753
+ // it, it is reviewable in a PR, and its whole history is auditable.
2754
+ // The write is derived from the OWNER id, so a session can only ever
2755
+ // occupy (or delete) its own slot.
2756
+ if (releaseClaimChecked.publish && releaseClaimResult?.ok) {
2757
+ try {
2758
+ if (releaseClaimChecked.withdraw) {
2759
+ const removed = deleteCommittedClaim(path.dirname(brainPath), sessionId);
2760
+ if (removed) releaseClaimResult = { ...releaseClaimResult, unpublished: removed.relPath };
2761
+ } else if (releaseClaimResult.claim) {
2762
+ const written = writeCommittedClaim(path.dirname(brainPath), releaseClaimResult.claim);
2763
+ releaseClaimResult = { ...releaseClaimResult, published: written.relPath };
2764
+ }
2765
+ } catch (err) {
2766
+ releaseClaimResult = { ...releaseClaimResult, publishError: String(err?.message || err).slice(0, 120) };
2767
+ }
2768
+ }
2769
+ }
2654
2770
  // Set when the holder's own sync arrived with the lease already close to
2655
2771
  // lapsing; reported after the refresh so the holder learns the habit that
2656
2772
  // protects them, not merely that nothing broke this time.
@@ -2680,9 +2796,14 @@ export function createMcpPresence({
2680
2796
  // to make silence insufficient: a session that cannot proceed without
2681
2797
  // reproducing the missing commits has, in practice, had to surface them.
2682
2798
  //
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.
2799
+ // Only a NEW declaration is ANCESTRY-gated. A holder refreshing the
2800
+ // same ref is never re-blocked on ancestry — that would be an obstacle,
2801
+ // not a gate — and a deliberate off-trunk hotfix is one acknowledged
2802
+ // call away. CLAIMS are the one exception: they settle on every
2803
+ // declaration, because a claim staked after the lease was taken is
2804
+ // exactly the promise the window between lease and build would
2805
+ // otherwise swallow; acknowledgements persist on the lease so the
2806
+ // holder re-litigates only what is genuinely NEW.
2686
2807
  const existingLease = readReleaseLease({ brainPath, home, now: leaseStamp });
2687
2808
  // recipientKey is module-private in agent-presence; the same normalization
2688
2809
  // (trim + 160-char bound) reproduced here rather than widening its surface.
@@ -2717,9 +2838,80 @@ export function createMcpPresence({
2717
2838
  gateAncestry = { status: 'unknown', reason: 'git-unavailable', ref: releaseIntentChecked.ref, isDescendant: false, missingCount: 0, sources: [], missing: [] };
2718
2839
  }
2719
2840
  }
2720
- if (gateAncestry && !gateAncestry.isDescendant
2721
- && !ancestryAcknowledged(gateAncestry, releaseIntentChecked.acknowledge)) {
2722
- outcome = { ok: false, status: 'ancestry-unacknowledged', ancestry: gateAncestry };
2841
+ // ── STAKED CLAIMS gate — settled on EVERY declaration, including a
2842
+ // holder's same-ref refresh. Ancestry compares trunk and LIVE peer
2843
+ // branches; a claim covers exactly the hole that leaves: a commit whose
2844
+ // owner has closed their session and whose branch nobody is on any
2845
+ // more. A claim staked AFTER the lease was taken must still gate the
2846
+ // next sync, or the window between lease and build is where a promise
2847
+ // goes to die. Per-sha probe failures inside the settlement BLOCK
2848
+ // (missing), never pass; only a catastrophic throw degrades to empty,
2849
+ // and ancestry still stands guard on that path.
2850
+ let claimSettlement = [];
2851
+ committedClaimProblems = [];
2852
+ try {
2853
+ const laneClaims = readReleaseClaims({ brainPath, home, now: leaseStamp });
2854
+ // COMMITTED claims ride the repository itself — a teammate's promise
2855
+ // arrives with ordinary `git pull`, and this gate reads it on every
2856
+ // clone with zero infrastructure. Malformed files are surfaced, not
2857
+ // skipped: a gate input that silently drops entries is the recurring
2858
+ // defect this whole subsystem exists to end.
2859
+ const committed = readCommittedClaims(path.dirname(brainPath), { now: leaseStamp });
2860
+ committedClaimProblems = committed.problems;
2861
+ if (committed.truncated) committedClaimProblems = [...committedClaimProblems, { file: 'directory', problem: 'claim directory exceeds the scan cap; entries beyond it were NOT settled' }];
2862
+ // Dedupe: a lane claim and a committed claim from the SAME owner with
2863
+ // the same shas are one promise, not two. Lane wins (fresher TTL).
2864
+ const claimKey = (c) => `${c.ownerId}|${[...c.shas].sort().join(',')}`;
2865
+ const laneOwners = new Set(laneClaims.map(claimKey));
2866
+ const committedByKey = new Map(committed.claims.map((c) => [claimKey(c), c]));
2867
+ // When a lane claim and a committed file are the same promise, the
2868
+ // lane entry wins (fresher TTL) but must CARRY the file marker —
2869
+ // otherwise fulfilment retires the lane copy and strands the file,
2870
+ // and every clone keeps refusing on a promise already kept.
2871
+ const merged = [
2872
+ ...laneClaims.map((c) => {
2873
+ const twin = committedByKey.get(claimKey(c));
2874
+ return twin ? { ...c, committed: twin.committed } : c;
2875
+ }),
2876
+ ...committed.claims.filter((c) => !laneOwners.has(claimKey(c))),
2877
+ ];
2878
+ if (merged.length) {
2879
+ claimSettlement = settleClaimsAgainstRef(path.dirname(brainPath), releaseIntentChecked.ref, merged);
2880
+ }
2881
+ } catch { claimSettlement = []; }
2882
+ const unmetClaims = claimSettlement.filter((entry) => !entry.contained);
2883
+ const claimShasRequired = [...new Set(unmetClaims
2884
+ .flatMap((entry) => [...entry.missing, ...entry.unresolvable, ...entry.unverified])
2885
+ .map((sha) => String(sha).toLowerCase()))];
2886
+ // The holder's PERSISTED acknowledgements count too (review blocker B1):
2887
+ // a refresh checkpoint re-attaching the same releaseIntent must not be
2888
+ // refused over a claim the holder already acknowledged at grant time.
2889
+ // A claim staked SINCE then still gates — its shas are in no lease
2890
+ // record — which is exactly the window the settle-on-every-declaration
2891
+ // rule exists for.
2892
+ const leaseAcks = sameRefAsHeld && Array.isArray(existingLease?.acknowledgedShas)
2893
+ ? existingLease.acknowledgedShas : [];
2894
+ const ackGiven = [...new Set([
2895
+ ...(releaseIntentChecked.acknowledge || []),
2896
+ ...leaseAcks,
2897
+ ].map((g) => String(g).toLowerCase()))];
2898
+ const claimsAcknowledged = claimShasRequired
2899
+ .every((sha) => ackGiven.some((g) => sha.startsWith(g) || g.startsWith(sha)));
2900
+ const ancestryBlocks = gateAncestry && !gateAncestry.isDescendant
2901
+ && !ancestryAcknowledged(gateAncestry, ackGiven);
2902
+ const claimsRefuse = claimShasRequired.length > 0 && !claimsAcknowledged;
2903
+ if (ancestryBlocks || claimsRefuse) {
2904
+ outcome = {
2905
+ ok: false,
2906
+ status: ancestryBlocks ? 'ancestry-unacknowledged' : 'claims-unacknowledged',
2907
+ ancestry: gateAncestry
2908
+ || { status: 'ok', ref: releaseIntentChecked.ref, isDescendant: true, missingCount: 0, sources: [], missing: [] },
2909
+ unmetClaims,
2910
+ // The refusal headline must not lie to a HOLDER: their held lease
2911
+ // was not revoked — it just was not refreshed by this sync, and it
2912
+ // lapses at TTL unless the new claim is acknowledged.
2913
+ holderRefusal: sameRefAsHeld,
2914
+ };
2723
2915
  } else {
2724
2916
  outcome = declareReleaseLease({
2725
2917
  brainPath,
@@ -2729,8 +2921,11 @@ export function createMcpPresence({
2729
2921
  client: (preparedClientInfo || {}).client || 'unknown',
2730
2922
  home,
2731
2923
  now: leaseStamp,
2924
+ acknowledgedShas: ackGiven,
2732
2925
  });
2733
2926
  if (gateAncestry && !gateAncestry.isDescendant) outcome = { ...outcome, acknowledgedAncestry: gateAncestry };
2927
+ if (unmetClaims.length) outcome = { ...outcome, acknowledgedClaims: unmetClaims };
2928
+ if (claimSettlement.length) outcome = { ...outcome, claimSettlement };
2734
2929
  }
2735
2930
  } else if (clearCompletionScope) {
2736
2931
  const freed = freeReleaseLease({ brainPath, sessionId, home, now: leaseStamp });
@@ -2766,8 +2961,24 @@ export function createMcpPresence({
2766
2961
  refreshedAt: active.refreshedAt,
2767
2962
  expiresAt: active.expiresAt,
2768
2963
  } : null;
2769
- if (outcome?.status === 'ancestry-unacknowledged') {
2964
+ if (outcome?.status === 'ancestry-unacknowledged' || outcome?.status === 'claims-unacknowledged') {
2770
2965
  const anc = outcome.ancestry;
2966
+ // Staked-claim requirement rides the SAME refusal and the SAME
2967
+ // acknowledge array: one gate, one handshake, whichever half tripped.
2968
+ const unmetClaims = Array.isArray(outcome.unmetClaims) ? outcome.unmetClaims : [];
2969
+ const claimShas = [...new Set(unmetClaims
2970
+ .flatMap((entry) => [...entry.missing, ...entry.unresolvable, ...entry.unverified])
2971
+ .map((sha) => String(sha).toLowerCase()))];
2972
+ const claimLines = unmetClaims.map((entry) => {
2973
+ const c = entry.claim;
2974
+ const ageDays = Math.max(0, Math.round((leaseStamp - (c.stakedAt || leaseStamp)) / 86_400_000));
2975
+ const parts = [];
2976
+ 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}` : ''}`);
2977
+ 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(', ')}`);
2978
+ if (entry.unverified.length) parts.push(`${entry.unverified.length} unverified (probe budget)`);
2979
+ const provenance = c.committed ? ` [committed: ${c.committed.file} — travels with the repo, withdraw by deleting the file in a commit]` : '';
2980
+ 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.${provenance}`);
2981
+ });
2771
2982
  // The COMPLETE set the gate will demand, not the subset the prose names.
2772
2983
  // These two used to be the same list, which is how a release dropping 71
2773
2984
  // commits was acknowledged by naming 10. The prose still shows 8 per
@@ -2807,20 +3018,45 @@ export function createMcpPresence({
2807
3018
  // (2026-08-17 review catch — the primary fix renders bare shas at the
2808
3019
  // source so 'unnameable' now truly means no shas at all; this guard is
2809
3020
  // the belt to that suspender).
2810
- acknowledgeRequired: anc.status === 'unnameable' ? [] : shas,
3021
+ acknowledgeRequired: [...new Set([...(anc.status === 'unnameable' ? [] : shas), ...claimShas])],
3022
+ ...(unmetClaims.length ? { stakedClaims: unmetClaims.map((entry) => ({
3023
+ owner: String(entry.claim.ownerId).slice(0, 8),
3024
+ ownerClient: entry.claim.ownerClient,
3025
+ branch: entry.claim.branch,
3026
+ note: entry.claim.note,
3027
+ stakedAt: entry.claim.stakedAt,
3028
+ missing: entry.missing,
3029
+ unresolvable: entry.unresolvable,
3030
+ unverified: entry.unverified,
3031
+ })) } : {}),
2811
3032
  };
3033
+ const claimsOnlyImperative = (!(anc && !anc.isDescendant) && unmetClaims.length)
3034
+ ? [
3035
+ '',
3036
+ '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.',
3037
+ 'Say this to them in your own words, naming the claim(s) above, BEFORE going any further.',
3038
+ ]
3039
+ : [];
2812
3040
  releaseText = [
2813
- 'KLYPIX release lease REFUSED — the lease was not taken. No release state changed.',
3041
+ outcome.holderRefusal
3042
+ ? '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.'
3043
+ : 'KLYPIX release lease REFUSED — the lease was not taken. No release state changed.',
2814
3044
  '',
2815
3045
  ...releaseAncestryWarnings(anc),
3046
+ ...(claimLines.length ? ['', ...claimLines] : []),
3047
+ ...(committedClaimProblems.length ? [
3048
+ '',
3049
+ `⚠ ${committedClaimProblems.length} committed claim file(s) could NOT be settled and are NOT covered by this gate: ${committedClaimProblems.slice(0, 3).map((p) => `${p.file} (${p.problem})`).join('; ')}${committedClaimProblems.length > 3 ? ` +${committedClaimProblems.length - 3} more` : ''}. Fix or remove them — an unreadable promise protects nobody.`,
3050
+ ] : []),
3051
+ ...claimsOnlyImperative,
2816
3052
  '',
2817
3053
  // Deliberately NOT a ready-to-paste call. Pre-rendering the exact
2818
3054
  // retry made the bypass the easiest thing on screen — an agent could
2819
3055
  // copy it and never say a word to anyone. The shas are listed above;
2820
3056
  // reproducing them is the work, and the work is the point.
2821
- (anc.status !== 'unnameable' && shas.length)
3057
+ ((anc.status !== 'unnameable' && shas.length) || claimShas.length)
2822
3058
  ? [
2823
- `To proceed anyway, re-send releaseIntent with an "acknowledge" array naming each of the ${shas.length} sha(s) in acknowledgeRequired.`,
3059
+ `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
3060
  unnamed
2825
3061
  ? `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
3062
  : '',
@@ -2855,15 +3091,115 @@ export function createMcpPresence({
2855
3091
  releaseLease = { status: 'declare-failed', reason: outcome.status };
2856
3092
  releaseText = `KLYPIX release lease was not recorded (${outcome.status}); no lease state changed. Retry on the next sync.`;
2857
3093
  } else if (outcome?.status === 'taken' || outcome?.status === 'refreshed') {
3094
+ // ── Claim settlement on a GRANTED lease ──────────────────────────
3095
+ // Fulfilled claims retire (their shas are in the ref — the promise is
3096
+ // kept, with a courtesy note to the owner). Acknowledged-away claims
3097
+ // STAY STAKED — the work still is not shipping — and their owners are
3098
+ // notified NOW, at declare time, not after the build exists: today the
3099
+ // releaser sees the OWNED-BY warning but the owner learns only when
3100
+ // the installer is missing their feature (founder-surfaced 2026-08-17).
3101
+ const fulfilledClaims = (outcome.claimSettlement || []).filter((entry) => entry.contained);
3102
+ const awayClaims = Array.isArray(outcome.acknowledgedClaims) ? outcome.acknowledgedClaims : [];
3103
+ let claimsNotified = 0;
3104
+ const retiredFiles = [];
3105
+ if (fulfilledClaims.length) {
3106
+ try {
3107
+ retireFulfilledClaims({
3108
+ brainPath,
3109
+ fulfilled: fulfilledClaims.map((entry) => ({ ownerId: entry.claim.ownerId, shas: entry.claim.shas })),
3110
+ home,
3111
+ now: leaseStamp,
3112
+ });
3113
+ } catch { /* retirement is best-effort; a live claim re-settles next declare */ }
3114
+ for (const entry of fulfilledClaims) {
3115
+ if (entry.claim.committed) {
3116
+ // The file lives in the WORKING TREE — deleting it here and
3117
+ // committing the deletion alongside the release is the repo-side
3118
+ // twin of lane retirement. If the delete fails the claim simply
3119
+ // re-settles as contained next time; never fatal.
3120
+ const removed = deleteCommittedClaim(path.dirname(brainPath), entry.claim.ownerId);
3121
+ if (removed) retiredFiles.push(removed.relPath);
3122
+ }
3123
+ }
3124
+ for (const entry of fulfilledClaims) {
3125
+ if (entry.claim.ownerId === sessionId) continue;
3126
+ const posted = postPresenceMessage({
3127
+ brainPath,
3128
+ from: sessionId,
3129
+ // Logical identity preferred: a revived session matches it directly,
3130
+ // and a SINGLE candidate keeps the receipt denominator honest.
3131
+ to: entry.claim.ownerLogicalId || entry.claim.ownerId,
3132
+ 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.`,
3133
+ allowOfflineTarget: true,
3134
+ dedupeKey: `claim-fulfilled|${entry.claim.ownerId}|${releaseIntentChecked.version}`,
3135
+ home,
3136
+ now: leaseStamp,
3137
+ });
3138
+ if (posted.posted) claimsNotified++;
3139
+ }
3140
+ }
3141
+ const notifiedOwners = new Set();
3142
+ for (const entry of awayClaims) {
3143
+ if (entry.claim.ownerId === sessionId) continue;
3144
+ notifiedOwners.add(entry.claim.ownerId);
3145
+ const gone = [...entry.missing, ...entry.unresolvable, ...entry.unverified];
3146
+ const posted = postPresenceMessage({
3147
+ brainPath,
3148
+ from: sessionId,
3149
+ to: entry.claim.ownerLogicalId || entry.claim.ownerId,
3150
+ 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.`,
3151
+ allowOfflineTarget: true,
3152
+ dedupeKey: `claim-away|${entry.claim.ownerId}|${releaseIntentChecked.version}|${releaseIntentChecked.ref}`,
3153
+ home,
3154
+ now: leaseStamp,
3155
+ });
3156
+ if (posted.posted) claimsNotified++;
3157
+ }
3158
+ // LIVE owners named by the ancestry annotation get the same courtesy —
3159
+ // their commits were acknowledged away too, they just never staked.
3160
+ const ancOwners = new Map();
3161
+ for (const src of (outcome.acknowledgedAncestry?.sources || [])) {
3162
+ for (const c of (src.missing || [])) {
3163
+ for (const o of (c.owners || [])) {
3164
+ if (o.sharedScopeOnly || !o.sessionId || o.sessionId === sessionId) continue;
3165
+ if (notifiedOwners.has(o.sessionId)) continue;
3166
+ const cur = ancOwners.get(o.sessionId) || [];
3167
+ if (cur.length < 4) cur.push(`${c.sha} ${String(c.subject || '').slice(0, 60)}`.trim());
3168
+ ancOwners.set(o.sessionId, cur);
3169
+ }
3170
+ }
3171
+ }
3172
+ for (const [ownerId, commits] of ancOwners) {
3173
+ const posted = postPresenceMessage({
3174
+ brainPath,
3175
+ from: sessionId,
3176
+ to: ownerId,
3177
+ 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.`,
3178
+ allowOfflineTarget: true,
3179
+ dedupeKey: `anc-away|${ownerId}|${releaseIntentChecked.version}|${releaseIntentChecked.ref}`,
3180
+ home,
3181
+ now: leaseStamp,
3182
+ });
3183
+ if (posted.posted) claimsNotified++;
3184
+ }
2858
3185
  releaseLease = {
2859
3186
  status: outcome.status,
2860
3187
  ...(outcome.reclaimed ? { reclaimed: outcome.reclaimed } : {}),
2861
3188
  ...(holderBlock ? { holder: holderBlock } : {}),
3189
+ ...(fulfilledClaims.length ? { claimsFulfilled: fulfilledClaims.length } : {}),
3190
+ ...(awayClaims.length ? { claimsAcknowledgedAway: awayClaims.length } : {}),
3191
+ ...(claimsNotified ? { claimOwnersNotified: claimsNotified } : {}),
2862
3192
  };
2863
3193
  if (releaseIntentChecked.provided) {
2864
3194
  releaseText = outcome.status === 'taken'
2865
3195
  ? `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
3196
  : `KLYPIX release lease refreshed: v${releaseIntentChecked.version} from ${releaseIntentChecked.ref}.`;
3197
+ if (fulfilledClaims.length || awayClaims.length) {
3198
+ releaseText += ` Claims: ${fulfilledClaims.length} fulfilled${awayClaims.length ? `, ${awayClaims.length} ACKNOWLEDGED AWAY (their owners were queued a notification — the work is NOT in this build)` : ''}.`;
3199
+ if (retiredFiles.length) {
3200
+ releaseText += ` Retired committed claim file(s): ${retiredFiles.join(', ')} — commit the deletion with the release so every clone sees the promise as kept.`;
3201
+ }
3202
+ }
2867
3203
  }
2868
3204
  } else if (outcome?.status === 'lease-lost') {
2869
3205
  releaseLease = { status: 'lease-lost', reason: outcome.reason || null };
@@ -3042,6 +3378,8 @@ export function createMcpPresence({
3042
3378
  // refresh / conflict / release outcome plus the current holder — and the
3043
3379
  // zero-config checkout-ahead advisory when nothing is declared.
3044
3380
  ...(releaseLease ? { releaseLease } : {}),
3381
+ // Additive: outcome of a releaseClaim stake/withdraw carried in THIS call.
3382
+ ...(releaseClaimResult ? { releaseClaim: releaseClaimResult } : {}),
3045
3383
  ...(releaseAdvisory ? { releaseAdvisory } : {}),
3046
3384
  ...(workAtRisk ? { workAtRisk } : {}),
3047
3385
  ...(resultReconciliation ? { resultReconciliation: {
@@ -3091,6 +3429,11 @@ export function createMcpPresence({
3091
3429
  `KLYPIX Context Gateway: session ${sessionId} · phase ${nextPhase} · coordination ${durationMs}ms.`,
3092
3430
  deferralText,
3093
3431
  resultText,
3432
+ releaseClaimResult
3433
+ ? (releaseClaimResult.ok
3434
+ ? `KLYPIX release claim ${releaseClaimResult.status}${releaseClaimResult.published ? ` — PUBLISHED to ${releaseClaimResult.published}: commit that file so the claim travels with the repo (every clone's release gate reads it; reviewable in PRs)` : ''}${releaseClaimResult.unpublished ? ` — committed file ${releaseClaimResult.unpublished} deleted; commit the deletion to withdraw it everywhere` : ''}${releaseClaimResult.publishError ? ` — WARNING: the lane claim stands but the committed file failed: ${releaseClaimResult.publishError}` : ''}${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)` : ''}.`
3435
+ : `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.' : ''}`)
3436
+ : '',
3094
3437
  releaseText,
3095
3438
  formatTaskPresence(snapshot, stamp),
3096
3439
  messagesText,
@@ -26,6 +26,7 @@
26
26
  // so a fresh session on an already-drifted repo still sees the drift, and
27
27
  // the ship-observation baseline is never perturbed.
28
28
 
29
+ import crypto from 'crypto';
29
30
  import fs from 'fs';
30
31
  import path from 'path';
31
32
  import { execFileSync } from 'child_process';
@@ -221,6 +222,183 @@ export function commitFiles(projectDir, shas, { execGit = defaultExecGit, timeou
221
222
  return out;
222
223
  }
223
224
 
225
+ // ── Committed claims — the promise that travels with the repository ─────────
226
+ //
227
+ // A lane claim protects a machine; a COMMITTED claim protects a team. The file
228
+ // lives at .klypix/claims/<owner>.json, rides ordinary git push/pull, is
229
+ // reviewable in a PR and auditable in history — so the release gate on ANY
230
+ // clone reads promises made on any other machine, with zero infrastructure
231
+ // beyond the version control the team already has. One file per owner keeps
232
+ // merges trivial: two teammates staking concurrently touch different paths.
233
+ export const COMMITTED_CLAIMS_DIR = path.join('.klypix', 'claims');
234
+ const COMMITTED_CLAIMS_MAX_FILES = 64;
235
+ const COMMITTED_CLAIM_MAX_BYTES = 8 * 1024;
236
+
237
+ const committedClaimFileName = (ownerId) => {
238
+ const slug = String(ownerId || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'owner';
239
+ const hash = crypto.createHash('sha1').update(String(ownerId || '')).digest('hex').slice(0, 12);
240
+ return `${slug}-${hash}.json`;
241
+ };
242
+
243
+ function normalizeCommittedClaim(raw, file, now) {
244
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { problem: 'not an object' };
245
+ if (raw.schemaVersion !== 1) return { problem: `unknown schemaVersion ${raw.schemaVersion}` };
246
+ const owner = raw.owner && typeof raw.owner === 'object' ? raw.owner : {};
247
+ const ownerId = String(owner.id || '').trim().slice(0, 160);
248
+ const shas = [...new Set((Array.isArray(raw.shas) ? raw.shas : [])
249
+ .map((s) => String(s || '').trim().toLowerCase())
250
+ .filter((s) => /^[0-9a-f]{4,40}$/.test(s)))].slice(0, 20);
251
+ const stakedAt = Number(raw.stakedAt || 0);
252
+ const expiresAt = Number(raw.expiresAt || 0);
253
+ if (!ownerId) return { problem: 'missing owner.id' };
254
+ if (!shas.length) return { problem: 'no valid shas' };
255
+ if (!stakedAt || !expiresAt) return { problem: 'missing stakedAt/expiresAt' };
256
+ if (now >= expiresAt) return { expired: true };
257
+ return {
258
+ claim: {
259
+ ownerId,
260
+ ownerClient: String(owner.client || 'unknown').slice(0, 40),
261
+ ownerLogicalId: String(owner.logicalId || '').trim().slice(0, 160) || null,
262
+ branch: String(raw.branch || '').slice(0, 120) || null,
263
+ note: String(raw.note || '').replace(/\s+/g, ' ').trim().slice(0, 160) || null,
264
+ shas,
265
+ stakedAt,
266
+ expiresAt,
267
+ committed: { file },
268
+ },
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Read every committed claim in the working tree. Bounded, schema-validated,
274
+ * and LOUD about what it skipped: `problems` names each malformed file, and
275
+ * `truncated` says the directory exceeded the scan cap — a gate input that
276
+ * silently drops entries is the recurring defect this module exists to end.
277
+ */
278
+ export function readCommittedClaims(projectDir, { now = Date.now() } = {}) {
279
+ const dir = path.join(String(projectDir || ''), COMMITTED_CLAIMS_DIR);
280
+ const out = { claims: [], problems: [], truncated: false };
281
+ let names;
282
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort(); }
283
+ catch { return out; } // no directory = no committed claims
284
+ if (names.length > COMMITTED_CLAIMS_MAX_FILES) {
285
+ out.truncated = true;
286
+ names = names.slice(0, COMMITTED_CLAIMS_MAX_FILES);
287
+ }
288
+ for (const name of names) {
289
+ const rel = path.join(COMMITTED_CLAIMS_DIR, name);
290
+ try {
291
+ const stat = fs.statSync(path.join(dir, name));
292
+ if (stat.size > COMMITTED_CLAIM_MAX_BYTES) { out.problems.push({ file: rel, problem: `over ${COMMITTED_CLAIM_MAX_BYTES} bytes` }); continue; }
293
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
294
+ const result = normalizeCommittedClaim(parsed, rel, now);
295
+ if (result.claim) out.claims.push(result.claim);
296
+ else if (!result.expired) out.problems.push({ file: rel, problem: result.problem });
297
+ } catch (err) {
298
+ out.problems.push({ file: rel, problem: `unreadable: ${String(err?.message || err).slice(0, 80)}` });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * Write (or overwrite) the caller's own committed claim. Atomic tmp+rename;
306
+ * the filename derives from the owner id, so a session can only ever occupy
307
+ * its own slot and a re-stake replaces rather than accumulates.
308
+ */
309
+ export function writeCommittedClaim(projectDir, claim) {
310
+ const dir = path.join(String(projectDir || ''), COMMITTED_CLAIMS_DIR);
311
+ fs.mkdirSync(dir, { recursive: true });
312
+ const name = committedClaimFileName(claim.ownerId);
313
+ const file = path.join(dir, name);
314
+ const payload = JSON.stringify({
315
+ schemaVersion: 1,
316
+ owner: { id: claim.ownerId, client: claim.ownerClient, ...(claim.ownerLogicalId ? { logicalId: claim.ownerLogicalId } : {}) },
317
+ shas: claim.shas,
318
+ ...(claim.branch ? { branch: claim.branch } : {}),
319
+ ...(claim.note ? { note: claim.note } : {}),
320
+ stakedAt: claim.stakedAt,
321
+ expiresAt: claim.expiresAt,
322
+ }, null, 2) + '\n';
323
+ const tmp = `${file}.tmp-${process.pid}`;
324
+ fs.writeFileSync(tmp, payload, 'utf8');
325
+ fs.renameSync(tmp, file);
326
+ return { file, relPath: path.join(COMMITTED_CLAIMS_DIR, name).replace(/\\/g, '/') };
327
+ }
328
+
329
+ /** Delete the caller's own committed claim slot. Returns the relPath it removed, or null. */
330
+ export function deleteCommittedClaim(projectDir, ownerId) {
331
+ const name = committedClaimFileName(ownerId);
332
+ const file = path.join(String(projectDir || ''), COMMITTED_CLAIMS_DIR, name);
333
+ try { fs.unlinkSync(file); } catch { return null; }
334
+ return { relPath: path.join(COMMITTED_CLAIMS_DIR, name).replace(/\\/g, '/') };
335
+ }
336
+
337
+ /**
338
+ * Settle staked release claims against the release ref.
339
+ *
340
+ * A claim is the durable half of "you'll see it in the next build": shas an
341
+ * owner promised would ride the next release, surviving the owner's session.
342
+ * Ancestry compares trunk and LIVE peer branches; a claim covers exactly the
343
+ * hole that leaves — a commit on a branch nobody is on any more.
344
+ *
345
+ * Per sha, three honest outcomes: contained in the ref, MISSING from it, or
346
+ * UNRESOLVABLE (the sha is gone — rebase/squash rewrote it, or the branch was
347
+ * deleted). Unresolvable is reported as its own state, never silently treated
348
+ * as contained (that would let history rewriting clear a claim) and never as
349
+ * missing (the remedy differs: the owner must re-stake the rewritten shas).
350
+ *
351
+ * Bounded: at most `maxChecks` unique shas are probed; anything beyond is
352
+ * marked unverified and SAID so — a capped check must never read as a full one.
353
+ */
354
+ export function settleClaimsAgainstRef(projectDir, ref, claims, { execGit = defaultExecGit, maxChecks = 64 } = {}) {
355
+ const git = makeGit(execGit);
356
+ const dir = String(projectDir || '');
357
+ const target = String(ref || '').trim();
358
+ const list = Array.isArray(claims) ? claims : [];
359
+ if (!dir || !target || !list.length) return [];
360
+ const verdictCache = new Map(); // sha -> 'contained' | 'missing' | 'unresolvable' | 'unverified'
361
+ let checks = 0;
362
+ const verdictFor = (sha) => {
363
+ if (verdictCache.has(sha)) return verdictCache.get(sha);
364
+ let verdict;
365
+ if (checks >= maxChecks) {
366
+ verdict = 'unverified';
367
+ } else {
368
+ checks++;
369
+ if (git(dir, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) === null) {
370
+ verdict = 'unresolvable';
371
+ } else {
372
+ // makeGit maps a non-zero exit to null, so "not an ancestor" and a
373
+ // failed spawn look identical here — both must BLOCK (missing), never
374
+ // pass: over-reporting is a conversation, under-reporting ships a
375
+ // build without the claimed work.
376
+ verdict = git(dir, ['merge-base', '--is-ancestor', sha, target]) === null ? 'missing' : 'contained';
377
+ }
378
+ }
379
+ verdictCache.set(sha, verdict);
380
+ return verdict;
381
+ };
382
+ return list.map((claim) => {
383
+ const missing = [];
384
+ const unresolvable = [];
385
+ const unverified = [];
386
+ for (const sha of claim.shas || []) {
387
+ const verdict = verdictFor(String(sha).toLowerCase());
388
+ if (verdict === 'missing') missing.push(sha);
389
+ else if (verdict === 'unresolvable') unresolvable.push(sha);
390
+ else if (verdict === 'unverified') unverified.push(sha);
391
+ }
392
+ return {
393
+ claim,
394
+ missing,
395
+ unresolvable,
396
+ unverified,
397
+ contained: !missing.length && !unresolvable.length && !unverified.length,
398
+ };
399
+ });
400
+ }
401
+
224
402
  export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, peerBranches = [] } = {}) {
225
403
  const git = makeGit(execGit);
226
404
  const dir = String(projectDir || '');