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