klypix-mcp 1.75.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.
@@ -796,6 +796,7 @@ server.registerTool('brain_sync', {
796
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
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
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.'),
799
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.'),
800
801
  },
801
802
  }, async ({ project, intent, files, phase, include_context, results, releaseIntent, releaseClaim }, extra) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.75.0",
3
+ "version": "1.76.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -45,7 +45,7 @@ import {
45
45
  // canonical copy lives in the pure module because that one is import-restricted
46
46
  // (crypto only), so it can never grow a dependency this file would inherit.
47
47
  import { normalizeFileKey } from './finding-routing.mjs';
48
- import { cmpSemver3, collectRepoState, commitFiles, releaseAncestry, releaseAncestryWarnings, repoStateWarnings, settleClaimsAgainstRef } from './repo-state.mjs';
48
+ import { cmpSemver3, collectRepoState, commitFiles, deleteCommittedClaim, readCommittedClaims, releaseAncestry, releaseAncestryWarnings, repoStateWarnings, settleClaimsAgainstRef, writeCommittedClaim } from './repo-state.mjs';
49
49
  import { recordResultManifests } from './result-reconcile.mjs';
50
50
 
51
51
  export const MCP_HEARTBEAT_MS = 60_000;
@@ -317,8 +317,11 @@ export function validateReleaseClaim(value) {
317
317
  else withdrawShas = parseShas(value.withdraw, 'withdraw');
318
318
  }
319
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
+ }
320
323
  if (errors.length) return { provided: true, ok: false, errors };
321
- return { provided: true, ok: true, stake: hasStake, shas, withdraw: hasWithdraw, withdrawShas, withdrawAll, note };
324
+ return { provided: true, ok: true, stake: hasStake, shas, withdraw: hasWithdraw, withdrawShas, withdrawAll, note, publish: value.publish === true };
322
325
  }
323
326
 
324
327
  /**
@@ -2715,6 +2718,7 @@ export function createMcpPresence({
2715
2718
  let workAtRisk = null;
2716
2719
  let workAtRiskText = '';
2717
2720
  let releaseClaimResult = null;
2721
+ let committedClaimProblems = [];
2718
2722
  {
2719
2723
  const leaseStamp = now();
2720
2724
  let outcome = null;
@@ -2743,6 +2747,25 @@ export function createMcpPresence({
2743
2747
  home,
2744
2748
  now: leaseStamp,
2745
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
+ }
2746
2769
  }
2747
2770
  // Set when the holder's own sync arrived with the lease already close to
2748
2771
  // lapsing; reported after the refresh so the holder learns the habit that
@@ -2825,10 +2848,35 @@ export function createMcpPresence({
2825
2848
  // (missing), never pass; only a catastrophic throw degrades to empty,
2826
2849
  // and ancestry still stands guard on that path.
2827
2850
  let claimSettlement = [];
2851
+ committedClaimProblems = [];
2828
2852
  try {
2829
- const stakedClaims = readReleaseClaims({ brainPath, home, now: leaseStamp });
2830
- if (stakedClaims.length) {
2831
- claimSettlement = settleClaimsAgainstRef(path.dirname(brainPath), releaseIntentChecked.ref, stakedClaims);
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);
2832
2880
  }
2833
2881
  } catch { claimSettlement = []; }
2834
2882
  const unmetClaims = claimSettlement.filter((entry) => !entry.contained);
@@ -2928,7 +2976,8 @@ export function createMcpPresence({
2928
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}` : ''}`);
2929
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(', ')}`);
2930
2978
  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.`);
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}`);
2932
2981
  });
2933
2982
  // The COMPLETE set the gate will demand, not the subset the prose names.
2934
2983
  // These two used to be the same list, which is how a release dropping 71
@@ -2995,6 +3044,10 @@ export function createMcpPresence({
2995
3044
  '',
2996
3045
  ...releaseAncestryWarnings(anc),
2997
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
+ ] : []),
2998
3051
  ...claimsOnlyImperative,
2999
3052
  '',
3000
3053
  // Deliberately NOT a ready-to-paste call. Pre-rendering the exact
@@ -3048,6 +3101,7 @@ export function createMcpPresence({
3048
3101
  const fulfilledClaims = (outcome.claimSettlement || []).filter((entry) => entry.contained);
3049
3102
  const awayClaims = Array.isArray(outcome.acknowledgedClaims) ? outcome.acknowledgedClaims : [];
3050
3103
  let claimsNotified = 0;
3104
+ const retiredFiles = [];
3051
3105
  if (fulfilledClaims.length) {
3052
3106
  try {
3053
3107
  retireFulfilledClaims({
@@ -3057,6 +3111,16 @@ export function createMcpPresence({
3057
3111
  now: leaseStamp,
3058
3112
  });
3059
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
+ }
3060
3124
  for (const entry of fulfilledClaims) {
3061
3125
  if (entry.claim.ownerId === sessionId) continue;
3062
3126
  const posted = postPresenceMessage({
@@ -3132,6 +3196,9 @@ export function createMcpPresence({
3132
3196
  : `KLYPIX release lease refreshed: v${releaseIntentChecked.version} from ${releaseIntentChecked.ref}.`;
3133
3197
  if (fulfilledClaims.length || awayClaims.length) {
3134
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
+ }
3135
3202
  }
3136
3203
  }
3137
3204
  } else if (outcome?.status === 'lease-lost') {
@@ -3364,7 +3431,7 @@ export function createMcpPresence({
3364
3431
  resultText,
3365
3432
  releaseClaimResult
3366
3433
  ? (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)` : ''}.`
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)` : ''}.`
3368
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.' : ''}`)
3369
3436
  : '',
3370
3437
  releaseText,
@@ -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,118 @@ 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
+
224
337
  /**
225
338
  * Settle staked release claims against the release ref.
226
339
  *