klypix-mcp 1.71.0 → 1.72.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.
@@ -71,7 +71,7 @@ const CODEX_CONFIG = path.join(HOME, '.codex', 'config.toml');
71
71
  const HOOK_MARK = 'global-brain-hook';
72
72
  const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
73
73
  const fwd = (p) => p.replace(/\\/g, '/');
74
- const copyRetrySleepSync = (ms) => {
74
+ const retrySleepSync = (ms) => {
75
75
  try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
76
76
  catch { /* best effort */ }
77
77
  };
@@ -81,7 +81,7 @@ function copyFileRobust(src, dest, tries = 8) {
81
81
  catch (error) {
82
82
  const retryable = ['EBUSY', 'EPERM', 'EACCES'].includes(error?.code);
83
83
  if (!retryable || attempt === tries) throw error;
84
- copyRetrySleepSync(20 * attempt);
84
+ retrySleepSync(20 * attempt);
85
85
  }
86
86
  }
87
87
  }
@@ -174,6 +174,44 @@ function reportCodex(result) {
174
174
  // update hook runs install per project, so every project heals once). Only the
175
175
  // known npx/node launch is migrated; a hand-customized command / invalid JSON is
176
176
  // left untouched, and the original is backed up. Best-effort, never throws.
177
+ // ── Windows rename hardening (1.71.1) ───────────────────────────────────────
178
+ // Every atomic commit in this installer is a rename-over-destination, and on
179
+ // Windows that throws EPERM/EBUSY/EACCES while ANY process briefly holds the
180
+ // target — an AV scan, an indexer, or, routinely on a developer machine, the
181
+ // live MCP servers reading the very bundle we are replacing. Measured 2026-08-15:
182
+ // three failures in one session with 7-14 servers live, on .mcp-runtime.json and
183
+ // brain-history.mjs; each retry succeeded immediately.
184
+ //
185
+ // The failure is loud rather than silent, and a manual re-run fixes it — but
186
+ // this is the FIRST command a new user types, and a raw EPERM stack at that
187
+ // moment is a bad first contact. So the installer now outlasts a transient
188
+ // holder using the SAME bounded backoff the brain write funnel has used since
189
+ // 1.68.0 (klypix-format.mjs atomicWrite): ~2.7s total, then rethrow. A
190
+ // persistent holder still fails loudly — delayed is acceptable, silently wrong
191
+ // is not, and pretending we wrote a file we did not would be far worse.
192
+ const RENAME_RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
193
+ const RENAME_BACKOFF_MS = [40, 120, 300, 700, 1500];
194
+ /** fs.renameSync that outlasts a transient Windows lock on the destination. */
195
+ function renameSyncWithBackoff(from, to) {
196
+ for (let attempt = 0; ; attempt++) {
197
+ try { return fs.renameSync(from, to); }
198
+ catch (e) {
199
+ if (attempt >= RENAME_BACKOFF_MS.length || !RENAME_RETRYABLE_CODES.has(e?.code)) {
200
+ // Name the real cause: "EPERM: operation not permitted" tells a
201
+ // user nothing actionable, and the fix is usually to close the
202
+ // editors whose servers hold the bundle.
203
+ if (RENAME_RETRYABLE_CODES.has(e?.code)) {
204
+ e.message = `${e.message}\n ↳ ${path.basename(to)} is held by another process after ~2.7s of retries.`
205
+ + '\n On Windows this is normally a running MCP server, an antivirus scan, or an indexer.'
206
+ + '\n Close your editors (or quit KLYPIX) and run the command again — nothing was left half-written.';
207
+ }
208
+ throw e;
209
+ }
210
+ retrySleepSync(RENAME_BACKOFF_MS[attempt]);
211
+ }
212
+ }
213
+ }
214
+
177
215
  function migrateProjectMcpConfig() {
178
216
  try {
179
217
  const file = path.join(process.cwd(), '.mcp.json');
@@ -362,7 +400,7 @@ try {
362
400
  } catch { /* .prev rollback snapshot is best-effort */ }
363
401
  const renameOrder = staged.slice().sort((a, b) => (a.dst === 'global-brain-hook.mjs' ? 1 : 0) - (b.dst === 'global-brain-hook.mjs' ? 1 : 0));
364
402
  let n = 0;
365
- for (const st of renameOrder) { fs.renameSync(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
403
+ for (const st of renameOrder) { renameSyncWithBackoff(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
366
404
 
367
405
  // 3) mark the dir an ESM package
368
406
  fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({ name: 'klypix-project-brain', private: true, type: 'module' }, null, 2));
@@ -397,7 +435,7 @@ try {
397
435
  const tmp = SETTINGS + '.klypix-tmp';
398
436
  fs.writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8');
399
437
  JSON.parse(fs.readFileSync(tmp, 'utf8')); // verify before swap
400
- fs.renameSync(tmp, SETTINGS);
438
+ renameSyncWithBackoff(tmp, SETTINGS);
401
439
  }
402
440
 
403
441
  // 6) Commit the runtime pointer and version receipt atomically while the
@@ -424,7 +462,7 @@ try {
424
462
  };
425
463
  const runtimePath = path.join(BRAIN_DIR, '.mcp-runtime.json');
426
464
  fs.writeFileSync(runtimePath + '.klypix-new', JSON.stringify(runtime, null, 2) + '\n', 'utf8');
427
- fs.renameSync(runtimePath + '.klypix-new', runtimePath);
465
+ renameSyncWithBackoff(runtimePath + '.klypix-new', runtimePath);
428
466
  // A tagged-but-DIRTY checkout keeps via:'npm' (the tag still names the
429
467
  // payload identity, and dev:true would stop auto-update from healing the
430
468
  // machine back to clean released bytes) but stamps dirty:true + the audit
@@ -437,7 +475,7 @@ try {
437
475
  : { brainVersion: VERSION, via: 'npm', dirty: false, installedAt };
438
476
  const versionPath = path.join(BRAIN_DIR, '.brain-version.json');
439
477
  fs.writeFileSync(versionPath + '.klypix-new', JSON.stringify(versionStamp, null, 2), 'utf8');
440
- fs.renameSync(versionPath + '.klypix-new', versionPath);
478
+ renameSyncWithBackoff(versionPath + '.klypix-new', versionPath);
441
479
 
442
480
  // 7) migrate THIS project's .mcp.json off npx onto the now-installed local bundle
443
481
  // (heals an existing stale config so the next MCP server spawn runs current).
@@ -876,7 +876,8 @@ server.registerTool('brain_sync', {
876
876
  releaseIntent: z.object({
877
877
  version: z.string().max(64).describe('The version this session intends to release (e.g. "1.70.0").'),
878
878
  ref: z.string().max(200).describe('The git ref (branch or tag) the release will be cut from.'),
879
- }).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.'),
879
+ acknowledge: z.array(z.string().max(40)).max(64).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.'),
880
+ }).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.'),
880
881
  },
881
882
  }, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
882
883
  const totalStartedAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.71.0",
3
+ "version": "1.72.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",
@@ -84,7 +84,7 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/remote-client.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/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/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
+ "test": "node test/publish-verdict.mjs && node test/remote-client.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/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-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",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
90
  "runtime": "node bin/klypix-runtime.mjs"
@@ -40,7 +40,7 @@ import {
40
40
  // canonical copy lives in the pure module because that one is import-restricted
41
41
  // (crypto only), so it can never grow a dependency this file would inherit.
42
42
  import { normalizeFileKey } from './finding-routing.mjs';
43
- import { cmpSemver3, collectRepoState, repoStateWarnings } from './repo-state.mjs';
43
+ import { cmpSemver3, collectRepoState, releaseAncestry, releaseAncestryWarnings, repoStateWarnings } from './repo-state.mjs';
44
44
  import { recordResultManifests } from './result-reconcile.mjs';
45
45
 
46
46
  export const MCP_HEARTBEAT_MS = 60_000;
@@ -242,8 +242,61 @@ export function validateReleaseIntent(value) {
242
242
  if (!ref || ref.length > 200 || /[\u0000-\u001f\u007f\s]/.test(ref)) {
243
243
  errors.push('releaseIntent.ref must be a nonempty bounded git ref (branch or tag) with no whitespace or control characters');
244
244
  }
245
+ // acknowledge: the commits this release KNOWINGLY leaves behind. Optional,
246
+ // and only ever consulted when the ancestry gate found something — see
247
+ // ancestryAcknowledged. Bounded like every other caller-supplied list.
248
+ let acknowledge = [];
249
+ if (value.acknowledge !== undefined && value.acknowledge !== null) {
250
+ if (!Array.isArray(value.acknowledge)) {
251
+ errors.push('releaseIntent.acknowledge must be an array of commit shas the release deliberately leaves behind');
252
+ } else {
253
+ acknowledge = value.acknowledge
254
+ .filter((s) => typeof s === 'string')
255
+ .map((s) => s.trim().toLowerCase())
256
+ .filter((s) => /^[0-9a-f]{4,40}$/.test(s))
257
+ .slice(0, 64);
258
+ }
259
+ }
245
260
  if (errors.length) return { provided: true, ok: false, errors };
246
- return { provided: true, ok: true, version: version.replace(/^v/i, ''), ref };
261
+ return { provided: true, ok: true, version: version.replace(/^v/i, ''), ref, acknowledge };
262
+ }
263
+
264
+ /**
265
+ * Does this acknowledgement actually name what the release would drop?
266
+ *
267
+ * The whole point of the handshake is that a session cannot take the lease
268
+ * without REPRODUCING the shas it is choosing to leave behind — and a model
269
+ * that has to reproduce them has, in practice, had to read and relay them.
270
+ * Nothing in MCP can force an agent to speak to its human; the closest
271
+ * available thing is to make silence insufficient to proceed.
272
+ *
273
+ * Prefix-tolerant in both directions so a caller may echo the short shas we
274
+ * printed or the full ones from their own `git log`.
275
+ */
276
+ export function ancestryAcknowledged(ancestry, acknowledge = []) {
277
+ if (!ancestry) return true;
278
+ if (ancestry.isDescendant || ancestry.status === 'ok') return true;
279
+ // WHICH unknowns block, and which do not — the line matters in both
280
+ // directions, and getting it wrong is expensive either way.
281
+ //
282
+ // BLOCKS. 'target-unresolved' means we ARE in a git repo and the ref you
283
+ // named is not in it: the cheapest bypass in the whole design was a mistyped
284
+ // or invented ref name, which used to return null and grant the lease in
285
+ // silence. 'unnameable' means a divergence exists whose commits could not be
286
+ // listed — an empty list must never satisfy the gate.
287
+ //
288
+ // DOES NOT BLOCK. A project with no git at all, or a repo with no trunk and
289
+ // no peer to compare against, is not an unanswered question — it is a
290
+ // MEANINGLESS one. Blocking there would stop a designer's brain in a plain
291
+ // folder, or a brand-new repo with a single branch, from ever declaring a
292
+ // release. That is a false positive of exactly the kind that teaches people
293
+ // to ignore the gate.
294
+ if (ancestry.status === 'unnameable') return false;
295
+ if (ancestry.status === 'unknown') return ancestry.reason !== 'target-unresolved';
296
+ const listed = (ancestry.sources || []).flatMap((s) => s.missing || []).map((c) => String(c.sha || '').toLowerCase());
297
+ if (!listed.length) return false;
298
+ const given = (acknowledge || []).map((s) => String(s || '').toLowerCase()).filter(Boolean);
299
+ return listed.every((sha) => given.some((g) => g.startsWith(sha) || sha.startsWith(g)));
247
300
  }
248
301
 
249
302
  const canonicalPathKey = (value) => {
@@ -908,6 +961,38 @@ function gitBranch(cwd) {
908
961
  }
909
962
  }
910
963
 
964
+ /**
965
+ * The branch THIS SESSION is actually on — the neutral-vendor fix.
966
+ *
967
+ * Until 1.72.0 every MCP host reported the branch of the VAULT directory,
968
+ * because the seam records `cwd: path.dirname(brainPath)`. Only Claude Code
969
+ * and hooked Codex — the two hosts with lifecycle hooks — supplied a true
970
+ * per-session branch, so the product was measurably better on those two. For a
971
+ * layer whose entire claim is that every coding agent is served equally, that
972
+ * asymmetry is the one defect that cannot be argued away.
973
+ *
974
+ * The MCP server is launched BY the host, so its own process.cwd() is the
975
+ * workspace the user opened for every host that sets it — Cursor, Cline,
976
+ * Windsurf, VS Code, Codex, Kimi, OpenCode, and anything else that speaks MCP,
977
+ * with no hook and no host-specific code. A host that launches from its own
978
+ * install directory simply yields no branch there, and the vault answer stands
979
+ * exactly as before: strictly more signal, never less.
980
+ *
981
+ * Deliberately NOT written into the presence row's `cwd`. That field is
982
+ * session IDENTITY, and the overlap matcher normalizes declared file paths
983
+ * against it — moving it would silently change which files count as the same
984
+ * file, which is a far larger blast radius than this fix is worth.
985
+ */
986
+ const hostCwdBranch = (vaultDir) => {
987
+ let hostCwd = null;
988
+ try { hostCwd = process.cwd(); } catch { hostCwd = null; }
989
+ if (hostCwd && path.resolve(hostCwd) !== path.resolve(vaultDir || '')) {
990
+ const fromHost = gitBranch(hostCwd);
991
+ if (fromHost) return fromHost;
992
+ }
993
+ return gitBranch(vaultDir);
994
+ };
995
+
911
996
  const peerFingerprint = (sessions, selfId) => (Array.isArray(sessions) ? sessions : [])
912
997
  .filter((session) => session.id !== selfId)
913
998
  .map((session) => [
@@ -1646,7 +1731,7 @@ export function createMcpPresence({
1646
1731
  surface,
1647
1732
  branch: branchPrepared !== undefined
1648
1733
  ? branchPrepared
1649
- : gitBranch(path.dirname(brainPath)),
1734
+ : hostCwdBranch(path.dirname(brainPath)),
1650
1735
  intent,
1651
1736
  intentSource: intent !== undefined ? 'declared' : null,
1652
1737
  files,
@@ -2030,7 +2115,7 @@ export function createMcpPresence({
2030
2115
  // be redirected by a lexical junction retarget.
2031
2116
  if (!preparedClientInfo) preparedClientInfo = clientInfo();
2032
2117
  const preparedBranch = resultBrainPath
2033
- ? gitBranch(path.dirname(resultBrainPath))
2118
+ ? hostCwdBranch(path.dirname(resultBrainPath))
2034
2119
  : null;
2035
2120
  if (!verifiedBinding()) {
2036
2121
  const report = syncPreflightFailure({
@@ -2364,6 +2449,8 @@ export function createMcpPresence({
2364
2449
  let releaseFooterLine = '';
2365
2450
  let releaseAdvisory = null;
2366
2451
  let releaseAdvisoryText = '';
2452
+ let workAtRisk = null;
2453
+ let workAtRiskText = '';
2367
2454
  {
2368
2455
  const leaseStamp = now();
2369
2456
  let outcome = null;
@@ -2380,15 +2467,63 @@ export function createMcpPresence({
2380
2467
  if (freed.status === 'released') outcome = freed;
2381
2468
  }
2382
2469
  } else if (releaseIntentChecked.provided) {
2383
- outcome = declareReleaseLease({
2384
- brainPath,
2385
- sessionId,
2386
- version: releaseIntentChecked.version,
2387
- ref: releaseIntentChecked.ref,
2388
- client: (preparedClientInfo || {}).client || 'unknown',
2389
- home,
2390
- now: leaseStamp,
2391
- });
2470
+ // ── THE HANDSHAKE (1.72.0) ────────────────────────────────────────
2471
+ // Before a lease is granted, ask the one question nothing used to ask:
2472
+ // would this release leave finished work behind? If it would, REFUSE —
2473
+ // and require the next attempt to name, sha by sha, exactly what it is
2474
+ // choosing to drop.
2475
+ //
2476
+ // A warning attached to a granted lease is one a model may relay or may
2477
+ // not; the founder's correction was that the USER has to be informed.
2478
+ // MCP has no channel to a human, so the strongest honest mechanism is
2479
+ // to make silence insufficient: a session that cannot proceed without
2480
+ // reproducing the missing commits has, in practice, had to surface them.
2481
+ //
2482
+ // Only a NEW declaration is gated. A holder refreshing mid-release is
2483
+ // never re-blocked — that would be an obstacle, not a gate — and a
2484
+ // deliberate off-trunk hotfix is one acknowledged call away.
2485
+ const existingLease = readReleaseLease({ brainPath, home, now: leaseStamp });
2486
+ // recipientKey is module-private in agent-presence; the same normalization
2487
+ // (trim + 160-char bound) reproduced here rather than widening its surface.
2488
+ const holderKey = (v) => String(v || '').trim().slice(0, 160);
2489
+ // EXEMPT THE REF, NOT THE HOLDER. Exempting whoever held the lease let a
2490
+ // session declare a clean ref, take the lease, then re-declare pointing
2491
+ // at a DIRTY one and sail through — the gate exempted them for being the
2492
+ // holder. What deserves exemption is re-declaring the SAME ref that was
2493
+ // already gated; anything else is a new release decision.
2494
+ const sameRefAsHeld = !!existingLease
2495
+ && holderKey(existingLease.holderId) === holderKey(sessionId)
2496
+ && String(existingLease.ref || '') === String(releaseIntentChecked.ref || '');
2497
+ let gateAncestry = null;
2498
+ if (!sameRefAsHeld) {
2499
+ try {
2500
+ const peerBranches = [
2501
+ ...new Set((report.sessions || [])
2502
+ .filter((s) => s?.id !== sessionId)
2503
+ .map((s) => String(s?.branch || '').trim())
2504
+ .filter(Boolean)),
2505
+ ];
2506
+ gateAncestry = releaseAncestry(path.dirname(brainPath), releaseIntentChecked.ref, { peerBranches });
2507
+ } catch {
2508
+ // A probe that throws is not an all-clear either.
2509
+ gateAncestry = { status: 'unknown', reason: 'git-unavailable', ref: releaseIntentChecked.ref, isDescendant: false, missingCount: 0, sources: [], missing: [] };
2510
+ }
2511
+ }
2512
+ if (gateAncestry && !gateAncestry.isDescendant
2513
+ && !ancestryAcknowledged(gateAncestry, releaseIntentChecked.acknowledge)) {
2514
+ outcome = { ok: false, status: 'ancestry-unacknowledged', ancestry: gateAncestry };
2515
+ } else {
2516
+ outcome = declareReleaseLease({
2517
+ brainPath,
2518
+ sessionId,
2519
+ version: releaseIntentChecked.version,
2520
+ ref: releaseIntentChecked.ref,
2521
+ client: (preparedClientInfo || {}).client || 'unknown',
2522
+ home,
2523
+ now: leaseStamp,
2524
+ });
2525
+ if (gateAncestry && !gateAncestry.isDescendant) outcome = { ...outcome, acknowledgedAncestry: gateAncestry };
2526
+ }
2392
2527
  } else if (clearCompletionScope) {
2393
2528
  const freed = freeReleaseLease({ brainPath, sessionId, home, now: leaseStamp });
2394
2529
  if (freed.status === 'released') outcome = freed;
@@ -2412,7 +2547,38 @@ export function createMcpPresence({
2412
2547
  refreshedAt: active.refreshedAt,
2413
2548
  expiresAt: active.expiresAt,
2414
2549
  } : null;
2415
- if (outcome?.status === 'conflict') {
2550
+ if (outcome?.status === 'ancestry-unacknowledged') {
2551
+ const anc = outcome.ancestry;
2552
+ const shas = (anc.sources || []).flatMap((s) => s.missing || []).map((c) => c.sha);
2553
+ releaseLease = {
2554
+ status: 'refused',
2555
+ kind: 'release-would-leave-work-behind',
2556
+ severity: 'blocking',
2557
+ requested: { version: releaseIntentChecked.version, ref: releaseIntentChecked.ref },
2558
+ ancestry: {
2559
+ trunk: anc.trunk,
2560
+ ref: anc.ref,
2561
+ missingCount: anc.missingCount,
2562
+ sources: anc.sources,
2563
+ },
2564
+ // Exactly what the retry must echo. Named so a caller never has to
2565
+ // guess the shape of the second call.
2566
+ acknowledgeRequired: shas,
2567
+ };
2568
+ releaseText = [
2569
+ 'KLYPIX release lease REFUSED — the lease was not taken. No release state changed.',
2570
+ '',
2571
+ ...releaseAncestryWarnings(anc),
2572
+ '',
2573
+ // Deliberately NOT a ready-to-paste call. Pre-rendering the exact
2574
+ // retry made the bypass the easiest thing on screen — an agent could
2575
+ // copy it and never say a word to anyone. The shas are listed above;
2576
+ // reproducing them is the work, and the work is the point.
2577
+ shas.length
2578
+ ? `To proceed anyway, re-send releaseIntent with an "acknowledge" array naming each of the ${shas.length} sha(s) listed above. Only do that after the user has been told and has decided.`
2579
+ : 'This release cannot be acknowledged automatically — the missing work could not be listed. Resolve it with the user before continuing.',
2580
+ ].join('\n');
2581
+ } else if (outcome?.status === 'conflict') {
2416
2582
  const holder = outcome.holder;
2417
2583
  const holderPrefix = prefixFor(holder.holderId);
2418
2584
  releaseLease = {
@@ -2461,8 +2627,78 @@ export function createMcpPresence({
2461
2627
  } else if (active) {
2462
2628
  releaseLease = { status: 'held', holder: holderBlock };
2463
2629
  }
2630
+ // ── Would this release leave finished work behind? (1.72.0) ──────────
2631
+ // Coordination in this engine had always been about files two sessions
2632
+ // are editing RIGHT NOW. Nothing ever asked whether finished work was
2633
+ // actually IN the build being cut — so on 2026-08-15 a release was
2634
+ // prepared from a branch that could not contain three completed commits,
2635
+ // and the FOUNDER noticed, not the tooling.
2636
+ //
2637
+ // It rides the LEASE because that is the one moment a release announces
2638
+ // itself, and it is reported at blocking severity so the declaring
2639
+ // session cannot take the lease and stay quiet: the founder's own
2640
+ // correction was "at least the user should be informed/asked about it".
2641
+ // Advisory in effect, unmissable in delivery — a hotfix cut from a tag is
2642
+ // legitimate, so the human decides, but never unknowingly.
2643
+ if (active && releaseIntentChecked.provided && (outcome?.status === 'taken' || outcome?.status === 'refreshed')) {
2644
+ try {
2645
+ const peerBranches = [
2646
+ ...new Set((report.sessions || [])
2647
+ .filter((s) => s?.id !== sessionId)
2648
+ .map((s) => String(s?.branch || '').trim())
2649
+ .filter(Boolean)),
2650
+ ];
2651
+ const ancestry = releaseAncestry(path.dirname(brainPath), active.ref, { peerBranches });
2652
+ // Only a genuinely DIRTY ancestry is worth repeating on a granted
2653
+ // lease. An unknown that the gate deliberately chose not to block on
2654
+ // (no git, nothing to compare) must not leak a scary CHECK COULD NOT
2655
+ // RUN line into every sync of a plain-folder project — that is the
2656
+ // alarm fatigue this whole design is trying to avoid.
2657
+ const warnings = ancestry && ancestry.status !== 'unknown' ? releaseAncestryWarnings(ancestry) : [];
2658
+ if (warnings.length) {
2659
+ releaseLease = {
2660
+ ...releaseLease,
2661
+ ancestry: {
2662
+ kind: 'release-would-leave-work-behind',
2663
+ severity: 'blocking',
2664
+ trunk: ancestry.trunk,
2665
+ ref: ancestry.ref,
2666
+ missingCount: ancestry.missingCount,
2667
+ sources: ancestry.sources,
2668
+ },
2669
+ };
2670
+ releaseText = `${releaseText}\n\n${warnings.join('\n')}`;
2671
+ }
2672
+ } catch { /* a git probe must never break a sync */ }
2673
+ }
2464
2674
  if (active) {
2465
2675
  releaseFooterLine = `release in preparation: v${active.version} from ${active.ref} (session ${prefixFor(active.holderId)})`;
2676
+ // ── THE THIRD QUESTION (1.72.0) ──────────────────────────────────
2677
+ // The ancestry gate protects the session CUTTING the release. This is
2678
+ // its mirror, for everyone else: "is my finished work going to make it
2679
+ // into the build somebody is preparing right now?"
2680
+ //
2681
+ // Unpushed commits are the common way the answer is no, and the
2682
+ // situation is invisible from both sides — the releaser cannot see a
2683
+ // branch that was never pushed, and the author has no reason to think
2684
+ // about a release they are not cutting. Both halves of the 2026-08-15
2685
+ // miss are this, seen from the two ends.
2686
+ //
2687
+ // Free: aheadBehindOrigin is already collected. Advisory, and only
2688
+ // while a release is genuinely in flight, so it can never become
2689
+ // ambient noise.
2690
+ const ahead = Number(repoState?.aheadBehindOrigin?.ahead || 0);
2691
+ const holderIsSelf = holderBlock && holderBlock.sessionId === sessionId;
2692
+ if (!completing && ahead > 0 && !holderIsSelf) {
2693
+ workAtRisk = {
2694
+ kind: 'unpushed-work-during-release',
2695
+ aheadCount: ahead,
2696
+ upstream: repoState.aheadBehindOrigin.upstream,
2697
+ branch: repoState.branch || null,
2698
+ release: { version: active.version, ref: active.ref },
2699
+ };
2700
+ workAtRiskText = `KLYPIX work-at-risk: a release is being prepared right now (v${active.version} from ${active.ref}), and this checkout has ${ahead} commit(s) on ${repoState.branch || 'HEAD'} that are NOT pushed to ${repoState.aheadBehindOrigin.upstream}. Work that never reached the remote cannot be in that build. If any of it belongs in v${active.version}, say so to the user and coordinate with the release session now (brain_message) — after the cut is far more expensive than before it.`;
2701
+ }
2466
2702
  } else if (!completing && repoState?.packageVersion && repoState?.latestReleaseTag?.version
2467
2703
  && cmpSemver3(repoState.packageVersion, repoState.latestReleaseTag.version) > 0) {
2468
2704
  // Zero-config visibility: nobody declared anything, but this checkout's
@@ -2537,6 +2773,7 @@ export function createMcpPresence({
2537
2773
  // zero-config checkout-ahead advisory when nothing is declared.
2538
2774
  ...(releaseLease ? { releaseLease } : {}),
2539
2775
  ...(releaseAdvisory ? { releaseAdvisory } : {}),
2776
+ ...(workAtRisk ? { workAtRisk } : {}),
2540
2777
  ...(resultReconciliation ? { resultReconciliation: {
2541
2778
  status: resultReconciliation.status,
2542
2779
  claims: resultReconciliation.claims || [],
@@ -2591,6 +2828,7 @@ export function createMcpPresence({
2591
2828
  conflictText,
2592
2829
  repoStateWarning,
2593
2830
  releaseAdvisoryText,
2831
+ workAtRiskText,
2594
2832
  // The one-line footer every peer sees while any release lease is active.
2595
2833
  releaseFooterLine,
2596
2834
  ].filter(Boolean).join('\n\n');
@@ -45,18 +45,19 @@ const CACHE_MS = 60_000;
45
45
  // worth caching too, or every sync in a non-git project re-pays the spawn).
46
46
  const repoStateCache = new Map();
47
47
 
48
- function defaultExecGit(args, cwd) {
48
+ function defaultExecGit(args, cwd, timeoutMs) {
49
49
  return execFileSync('git', args, {
50
50
  cwd,
51
51
  encoding: 'utf8',
52
52
  stdio: ['ignore', 'pipe', 'ignore'],
53
- timeout: GIT_TIMEOUT_MS,
53
+ // Most probes are rev-parse-fast; patch-id work needs its own budget.
54
+ timeout: Number(timeoutMs) > 0 ? Number(timeoutMs) : GIT_TIMEOUT_MS,
54
55
  });
55
56
  }
56
57
 
57
- const makeGit = (execGit) => (cwd, args) => {
58
+ const makeGit = (execGit) => (cwd, args, timeoutMs) => {
58
59
  try {
59
- const out = execGit(args, cwd);
60
+ const out = execGit(args, cwd, timeoutMs);
60
61
  return typeof out === 'string' ? out.trim() : null;
61
62
  } catch {
62
63
  return null; // no git on PATH, not a repo, timeout — all the same: no data
@@ -139,6 +140,274 @@ function repoStateForDir(git, dir) {
139
140
  };
140
141
  }
141
142
 
143
+ // Trunk candidates, most-authoritative first. The remote tracking ref beats a
144
+ // local branch: a local `master` can itself be stale, and the question a
145
+ // release must answer is "does this carry everything the TEAM has landed".
146
+ const TRUNK_REFS = ['origin/master', 'origin/main', 'master', 'main'];
147
+ const MAX_MISSING_LISTED = 8;
148
+ // git cherry has no --max-count, so a pathological divergence is bounded here
149
+ // rather than parsed forever.
150
+ const MAX_CHERRY_SCAN = 500;
151
+ // Measured 670ms on a real 200-commit divergence; the shared 1500ms budget was
152
+ // already failing intermittently and reporting the release as clean.
153
+ const CHERRY_TIMEOUT_MS = 10_000;
154
+ const UNIT_SEP = '';
155
+
156
+ /**
157
+ * Would this release leave finished work behind?
158
+ *
159
+ * The recorded rule is "never ship a build that is not a descendant of trunk —
160
+ * the check is ANCESTRY, not version number", and it exists because desktop
161
+ * 1.3.107 was the highest version number KLYPIX had ever produced while missing
162
+ * 211 trunk commits and an entire feature. A higher number on an off-trunk
163
+ * branch is perfect camouflage.
164
+ *
165
+ * Until now that rule lived only as a card — recall, which works exactly as
166
+ * often as somebody exercises it. On 2026-08-15 nobody did: three finished
167
+ * commits sat on master while a release was cut from a branch that could never
168
+ * contain them, and the FOUNDER caught it, not the tooling. Coordination in
169
+ * this engine has always been about files two sessions are editing RIGHT NOW;
170
+ * nothing ever asked whether finished work was actually IN the build. This is
171
+ * that missing axis.
172
+ *
173
+ * Advisory data, never a refusal — a hotfix cut from a tag is a legitimate
174
+ * off-trunk release. The caller decides; this only makes it impossible to be
175
+ * unaware.
176
+ *
177
+ * @returns {null|{trunk, ref, isDescendant, missingCount, missing:Array<{sha,subject}>}}
178
+ * null when the question cannot be answered (no git, no trunk, unknown ref).
179
+ */
180
+ export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, peerBranches = [] } = {}) {
181
+ const git = makeGit(execGit);
182
+ const dir = String(projectDir || '');
183
+ if (!dir) return { status: 'unknown', reason: 'no-project-dir', isDescendant: false, missingCount: 0, sources: [], missing: [] };
184
+ const target = String(ref || '').trim() || 'HEAD';
185
+ const shaOf = (r) => git(dir, ['rev-parse', '--verify', `${r}^{commit}`]);
186
+ const resolves = (r) => shaOf(r) !== null;
187
+ // A gate that cannot run must SAY SO. Returning null here made "the ref does
188
+ // not exist" and "git timed out" indistinguishable from "clean" — and the
189
+ // caller granted the lease silently, which an agent could trigger with a
190
+ // single mistyped ref name. Unknown is now its own state, and the gateway
191
+ // treats it as blocking.
192
+ if (git(dir, ['rev-parse', '--git-dir']) === null) {
193
+ return { status: 'unknown', reason: 'git-unavailable', ref: target, isDescendant: false, missingCount: 0, sources: [], missing: [] };
194
+ }
195
+ const targetSha = shaOf(target);
196
+ if (targetSha === null) {
197
+ return { status: 'unknown', reason: 'target-unresolved', ref: target, isDescendant: false, missingCount: 0, sources: [], missing: [] };
198
+ }
199
+
200
+ // ALL trunk candidates, not the first that resolves. First-match-wins picked
201
+ // origin/master in every normal clone and therefore NEVER compared LOCAL
202
+ // master — which is exactly where the 2026-08-15 commits sat, unpushed. The
203
+ // feature was blind to its own motivating incident for any session without a
204
+ // live peer on that branch. Local trunk ahead of a release ref is precise,
205
+ // not heuristic, so this adds no squash-merge noise beyond what origin/master
206
+ // already carries. `origin/HEAD` is probed too, so a repo whose trunk is
207
+ // `develop` or `trunk` is not left with no gate at all.
208
+ const trunkRefs = TRUNK_REFS.filter(resolves);
209
+ const originHead = git(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
210
+ if (originHead && !trunkRefs.includes(originHead) && resolves(originHead)) trunkRefs.push(originHead);
211
+ const trunk = trunkRefs[0] || null;
212
+
213
+ // WHY PEER BRANCHES, and not trunk alone — learned the hard way on
214
+ // 2026-08-15. The trunk check alone said release/1.3.113 was fine: it IS a
215
+ // descendant of origin/master. But origin/master was 214 commits behind the
216
+ // LOCAL master where the finished work actually sat, unpushed. Trunk ancestry
217
+ // catches the 1.3.107 class (a branch forked off trunk long ago); it cannot
218
+ // catch work that has not reached trunk yet.
219
+ //
220
+ // The presence lane is the only thing that knows which branches are live,
221
+ // which is precisely why this belongs in the coordination engine and not in
222
+ // one repo's prebuild script. A prebuild gate can only see refs; the lane
223
+ // sees WHO IS WORKING WHERE.
224
+ const seen = new Set();
225
+ const candidates = [];
226
+ const trunkSet = new Set(trunkRefs);
227
+ for (const r of [...trunkRefs, ...peerBranches]) {
228
+ const name = String(r || '').trim();
229
+ if (!name || name === target || seen.has(name)) continue;
230
+ // Unresolvable refs are dropped; SAME-SHA refs are deliberately kept. A
231
+ // release branch cut from trunk and declared immediately is the single most
232
+ // common legitimate case, and dropping the equal ref left zero candidates —
233
+ // which the new "cannot answer" path then refused. Kept, the ancestry check
234
+ // finds it is trivially an ancestor, contributes no source, and the release
235
+ // reads clean, which is the truth.
236
+ if (shaOf(name) === null) continue;
237
+ seen.add(name);
238
+ candidates.push({ ref: name, kind: trunkSet.has(name) ? 'trunk' : 'peer-branch' });
239
+ }
240
+
241
+ // NO third source, deliberately — and this is a decision, not an omission.
242
+ // A scan of recently-committed local branches was built and MEASURED against
243
+ // this repo: it produced 30-62 "missing" commits across 6-14 branches, most of
244
+ // them work already squash-merged into trunk under different SHAs. Ancestry
245
+ // cannot see through a squash, so the heuristic manufactures false positives
246
+ // at exactly the moment someone is trying to ship — and this project has the
247
+ // scar for that: five consecutive releases reported red over perfect publishes,
248
+ // and the recorded lesson is that alarm fatigue is itself a release-integrity
249
+ // defect. Trunk and live-peer branches are both PRECISE (no false positives by
250
+ // construction), so the gate keeps only those. The cost is stated honestly in
251
+ // the doc comment above rather than papered over with a noisy proxy.
252
+
253
+ if (!candidates.length) {
254
+ return { status: 'unknown', reason: 'no-comparable-refs', ref: target, trunk, isDescendant: false, missingCount: 0, sources: [], missing: [] };
255
+ }
256
+
257
+ const sources = [];
258
+ const claimed = new Set(); // shas already attributed to an earlier source
259
+ for (const cand of candidates) {
260
+ // `merge-base --is-ancestor` exits 0/1, and makeGit turns a non-zero exit
261
+ // into null — so null here means "not an ancestor", not "unknown".
262
+ if (git(dir, ['merge-base', '--is-ancestor', cand.ref, target]) !== null) continue;
263
+ // `git cherry` INSTEAD OF `rev-list`, and this is the single most important
264
+ // line in the module. rev-list compares SHAS, so it reported as "missing"
265
+ // every commit that had been rebased, cherry-picked, or squash-merged into
266
+ // the target under a new sha — measured on the real repo, that was 30-62
267
+ // false positives across 6-14 branches, and it forced an otherwise-good
268
+ // source to be deleted rather than tuned.
269
+ //
270
+ // `git cherry <target> <source>` compares PATCH-IDs: `+` means the change
271
+ // genuinely is not there, `-` means an equivalent change already is.
272
+ // Verified on real repos: it sees through a rebase, a cherry-pick, and a
273
+ // single-commit squash. It does NOT see through a squash of several commits
274
+ // into one (the combined diff cannot match any individual patch-id) — an
275
+ // honest, documented residue rather than a silent one.
276
+ //
277
+ // It also fixes the merge over-count for free: where rev-list said 2 for a
278
+ // divergence carrying one real change plus its merge commit, cherry says 1.
279
+ // Count and listing now come from the SAME source, so they can no longer
280
+ // disagree in the caller's favour — which is what let a merge-only
281
+ // divergence satisfy the handshake with an empty list.
282
+ //
283
+ // TIMEOUT DISCIPLINE, learned the hard way one commit before this shipped:
284
+ // patch-id computation is far slower than the rev-parse probes this module
285
+ // was built around, and at the shared 1500ms budget `git cherry` returned
286
+ // null on the real repo — which the loop read as "nothing missing" and the
287
+ // gate reported the release as CLEAN. A false negative here is strictly
288
+ // worse than every false positive this module has ever produced, so cherry
289
+ // gets its own budget AND a failure is never allowed to mean "clean".
290
+ const cherry = git(dir, ['cherry', target, cand.ref], CHERRY_TIMEOUT_MS);
291
+ let count;
292
+ let shown;
293
+ let approximate = false;
294
+ if (cherry !== null) {
295
+ const missingShas = cherry.split('\n')
296
+ .filter((l) => l.startsWith('+ '))
297
+ .map((l) => l.slice(2).trim())
298
+ .filter(Boolean)
299
+ .slice(0, MAX_CHERRY_SCAN);
300
+ count = missingShas.length;
301
+ if (count <= 0) continue; // every change is already present
302
+ shown = missingShas.slice(0, MAX_MISSING_LISTED);
303
+ } else {
304
+ // FALL BACK, never skip. rev-list is sha-based so it over-reports
305
+ // rebases and squashes, but over-reporting is a conversation and
306
+ // under-reporting is a lost feature. The source is marked approximate so
307
+ // the text can say the equivalence check could not run.
308
+ const countRaw = git(dir, ['rev-list', '--count', `${target}..${cand.ref}`]);
309
+ count = Number(countRaw);
310
+ if (!Number.isFinite(count) || count <= 0) continue;
311
+ approximate = true;
312
+ const fallbackLog = git(dir, ['log', `--max-count=${MAX_MISSING_LISTED}`, '--format=%H', `${target}..${cand.ref}`]);
313
+ shown = (fallbackLog || '').split('\n').map((s) => s.trim()).filter(Boolean);
314
+ }
315
+ if (!shown.length) continue;
316
+ // One bounded call for the subjects of just what will be shown.
317
+ const log = git(dir, ['log', '--no-walk', `--format=%h%x1f%s`, ...shown]);
318
+ const parsed = (log || '').split('\n').filter(Boolean).map((line) => {
319
+ const sep = line.indexOf(UNIT_SEP);
320
+ return sep < 0
321
+ ? { sha: line.trim(), subject: '' }
322
+ : { sha: line.slice(0, sep).trim(), subject: line.slice(sep + 1).slice(0, 120) };
323
+ });
324
+ // Trunk and a peer sitting on trunk are the DEFAULT configuration, so the
325
+ // same commits would otherwise be counted twice and demanded twice. Each
326
+ // sha is attributed to the first source that reports it.
327
+ const missing = parsed.filter((c) => c.sha && !claimed.has(c.sha));
328
+ for (const c of missing) claimed.add(c.sha);
329
+ if (!missing.length && count > 0 && parsed.length) continue; // wholly duplicated source
330
+ // `approximate` = the patch-id equivalence check could not run for this
331
+ // source, so the list may include work that is already present under a
332
+ // different sha. Carried on the source so the text can say so rather than
333
+ // asserting a precision it does not have.
334
+ sources.push({ ...cand, missingCount: count, missing, ...(approximate ? { approximate: true } : {}) });
335
+ }
336
+
337
+ if (!sources.length) {
338
+ return { status: 'ok', trunk, ref: target, isDescendant: true, missingCount: 0, sources: [], missing: [] };
339
+ }
340
+ const missingCount = sources.reduce((n, s) => n + s.missingCount, 0);
341
+ const listed = sources.flatMap((s) => s.missing);
342
+ return {
343
+ // A divergence we can count but cannot NAME is still a refusal — the caller
344
+ // must not be able to acknowledge an empty list.
345
+ status: listed.length ? 'dirty' : 'unnameable',
346
+ trunk,
347
+ ref: target,
348
+ isDescendant: false,
349
+ missingCount,
350
+ sources,
351
+ // Flattened for the common case of one diverged source.
352
+ missing: listed.slice(0, MAX_MISSING_LISTED),
353
+ };
354
+ }
355
+
356
+ /**
357
+ * The half that actually matters: lines a HUMAN reads.
358
+ *
359
+ * Founder's correction, verbatim — "at least the user should be
360
+ * informed/asked about it". A structured field only a model consumes is not a
361
+ * gate, because a model can decline to mention it. These strings ride the
362
+ * release-lease response at blocking severity, so a session cannot take the
363
+ * lease and stay quiet about what the release would drop.
364
+ */
365
+ export function releaseAncestryWarnings(ancestry) {
366
+ if (!ancestry || ancestry.isDescendant) return [];
367
+
368
+ // A gate that could not run is a finding, not a pass. Silence here was how a
369
+ // mistyped ref, a missing git, or a 1.5s timeout turned the whole check off
370
+ // while looking exactly like a clean release.
371
+ if (ancestry.status === 'unknown') {
372
+ const why = {
373
+ 'git-unavailable': 'git could not be read in this project',
374
+ 'target-unresolved': `the ref "${ancestry.ref}" does not exist here`,
375
+ 'no-comparable-refs': 'there is no trunk or peer branch to compare against',
376
+ 'no-project-dir': 'no project directory was supplied',
377
+ }[ancestry.reason] || ancestry.reason || 'the check could not run';
378
+ return [
379
+ `RELEASE CHECK COULD NOT RUN — ${why}.`,
380
+ 'This is not an all-clear: nothing was verified about what this release would leave out.',
381
+ 'Tell the user, and check by hand that the release branch contains everything it should.',
382
+ ];
383
+ }
384
+
385
+ const { ref, missingCount, sources } = ancestry;
386
+ const named = sources.flatMap((s) => s.missing).length;
387
+ const lines = [
388
+ `RELEASE WOULD LEAVE WORK BEHIND — ${missingCount} finished commit(s) already exist that this release does not include.`,
389
+ ];
390
+ for (const s of sources) {
391
+ lines.push(s.kind === 'trunk'
392
+ ? ` ${s.missingCount} commit(s) on ${s.ref} (the main line) are not in ${ref}:`
393
+ : ` ${s.missingCount} commit(s) on ${s.ref} — a branch someone is working on right now — are not in ${ref}:`);
394
+ for (const c of s.missing) lines.push(` · ${c.sha} ${c.subject}`);
395
+ if (s.missingCount > s.missing.length) lines.push(` · …and ${s.missingCount - s.missing.length} more not listed`);
396
+ if (s.approximate) {
397
+ lines.push(' (this list is approximate — the equivalence check could not run, so some of');
398
+ lines.push(' these may already be in the release under a different commit id)');
399
+ }
400
+ }
401
+ if (!named) {
402
+ lines.push(' (none of them could be listed individually — they are merge commits; inspect the range by hand)');
403
+ }
404
+ lines.push('');
405
+ lines.push('WHAT THIS MEANS FOR THE USER: if this release is built now, that work will not be in it,');
406
+ lines.push('and nobody will notice until someone looks for a feature that is missing.');
407
+ lines.push('Say this to them in your own words, listing what is above, BEFORE going any further.');
408
+ return lines;
409
+ }
410
+
142
411
  // Released means "a tag anywhere in the repo names this version" — not only at
143
412
  // HEAD, because a checkout routinely moves past the commit it tagged while the
144
413
  // version itself stays published.