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.
- package/bin/klypix-install.mjs +44 -6
- package/bin/klypix-worker.mjs +2 -1
- package/package.json +2 -2
- package/src/mcp-presence.mjs +252 -14
- package/src/repo-state.mjs +273 -4
package/bin/klypix-install.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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"
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
?
|
|
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
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
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 === '
|
|
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');
|
package/src/repo-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|