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.
- package/bin/klypix-worker.mjs +8 -2
- package/package.json +2 -2
- package/src/agent-presence.mjs +220 -1
- package/src/mcp-presence.mjs +288 -12
- package/src/repo-state.mjs +65 -0
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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.
|
|
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"
|
package/src/agent-presence.mjs
CHANGED
|
@@ -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
|
-
|
|
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')}`),
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -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
|
|
2684
|
-
// never re-blocked — that would be an obstacle,
|
|
2685
|
-
// deliberate off-trunk hotfix is one acknowledged
|
|
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
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
package/src/repo-state.mjs
CHANGED
|
@@ -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 || '');
|