nawabari 0.3.0 → 0.5.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.
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readGitPathStats, readCurrentHead, resolveRepositoryContext, verifyPhysicalExecutionContext, } from "./git.js";
3
+ import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readChangedPathNames, readGitPathStats, readCurrentHead, readTreePathStates, resolveRepositoryContext, treeEntriesEqual, verifyPhysicalExecutionContext, } from "./git.js";
4
4
  import { SessionRegistryError } from "./errors.js";
5
5
  import { generateSessionId, isSessionId } from "./session-id.js";
6
+ import { isPostRenameFailure, writeJsonAtomicallySync } from "./registry/atomic.js";
6
7
  import { RegistryLockError, RepositoryLock } from "./registry/lock.js";
7
8
  import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput, canonicalizeConcretePath, claimError, claimsConflict, claimsOverlap, cloneResourceClaim, compareCodePointStrings, createResourceClaim, isResourceClaimMode, LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION, resourceClaimConflictsWithAccess, resourceMatchesClaim, RESOURCE_CLAIM_SCHEMA_VERSION, sortResourceClaims, } from "./resource-claims.js";
8
9
  import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
@@ -16,9 +17,13 @@ export const REGISTRY_LOCK_FILE_NAME = "session-registry.lock";
16
17
  export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
17
18
  export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
18
19
  export const RECONCILIATION_SCHEMA_VERSION = 1;
20
+ export const SESSION_DIAGNOSTIC_SCHEMA_VERSION = 1;
19
21
  const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
20
22
  /** Bounds a caller-declared commit-message pattern before it is compiled. */
21
23
  export const MAX_MESSAGE_PATTERN_LENGTH = 512;
24
+ function isCloseSessionOptions(value) {
25
+ return typeof value === "object" && value !== null;
26
+ }
22
27
  export { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, OPERATION_REQUIRED_ACCESS, OPERATION_VOCABULARY, } from "./operation-authorization.js";
23
28
  export const GOVERNED_GIT_OPERATION_SCHEMA_VERSION = 1;
24
29
  const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
@@ -166,7 +171,7 @@ export class SessionRegistry {
166
171
  const current = state.claims.filter((claim) => claim.sessionId === sessionId);
167
172
  const currentById = new Map(current.map((claim) => [claim.claimId, claim]));
168
173
  const timestamp = toTimestamp(this.clock());
169
- const next = this.validateRequestedClaims(requested.map((input) => createResourceClaim(input, owner, timestamp)), owner, state.claims.filter((claim) => claim.sessionId !== sessionId));
174
+ const next = this.validateRequestedClaims(requested.map((input) => createResourceClaim(input, owner, timestamp)), owner, state.claims.filter((claim) => claim.sessionId !== sessionId), state.sessions);
170
175
  const nextIds = new Set(next.map((claim) => claim.claimId));
171
176
  const released = current.filter((claim) => !nextIds.has(claim.claimId));
172
177
  const added = next.filter((claim) => !currentById.has(claim.claimId));
@@ -312,13 +317,40 @@ export class SessionRegistry {
312
317
  return cloneSessionRecord(record);
313
318
  }
314
319
  catch (error) {
315
- if (gitProvisioned) {
320
+ if (gitProvisioned && this.absenceProvenAfterProvisioningFailure(error, sessionId)) {
316
321
  rollbackProvisionedResources(this.git, this.repository.worktreePath, resources);
317
322
  }
318
323
  throw classifyProvisioningFailure(error, this.git, this.repository.worktreePath, resources);
319
324
  }
320
325
  });
321
326
  }
327
+ /**
328
+ * Decide whether it is safe to destroy the just-created worktree/branch
329
+ * after a provisioning failure. Only an ordinary (non-durability-uncertain)
330
+ * failure, or a durability-uncertain failure whose registry re-read
331
+ * positively proves the session record absent, licenses a rollback.
332
+ *
333
+ * A durability-uncertain write means the rename may have already
334
+ * committed this session as the registry's visible ownership record:
335
+ * rolling back the worktree/branch would then strand a registry entry
336
+ * pointing at resources we just deleted. Absence must be *proven*, not
337
+ * merely unconfirmed — if the reconciliation read itself cannot complete
338
+ * (I/O failure, corrupt registry state, or any other exception), that is
339
+ * not proof of absence, so no rollback happens and the original
340
+ * durability-uncertain outcome propagates unchanged.
341
+ */
342
+ absenceProvenAfterProvisioningFailure(error, sessionId) {
343
+ const durabilityUncertain = error instanceof SessionRegistryError && error.code === "REGISTRY_DURABILITY_UNCERTAIN";
344
+ if (!durabilityUncertain) {
345
+ return true;
346
+ }
347
+ try {
348
+ return !this.readUnsafe().some((candidate) => candidate.sessionId === sessionId);
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
322
354
  provisionSession(options = {}) {
323
355
  return this.provision(options);
324
356
  }
@@ -410,18 +442,46 @@ export class SessionRegistry {
410
442
  git: this.git,
411
443
  });
412
444
  if (this.protectedWorktree(this.repository.worktreePath, physical.worktrees)) {
413
- throw new SessionRegistryError("PROTECTED_WORKTREE", "The current worktree is protected", {
414
- worktree: this.repository.worktreePath,
415
- });
445
+ throw this.protectedExecutionContextError("PROTECTED_WORKTREE", "The current worktree is protected", { worktree: this.repository.worktreePath }, requestedSessionId);
416
446
  }
417
447
  if (this.protectedBranch(physical.branchName, physical.worktrees)) {
418
- throw new SessionRegistryError("PROTECTED_BRANCH", "The current branch is protected", {
419
- branch: physical.branchName,
420
- });
448
+ throw this.protectedExecutionContextError("PROTECTED_BRANCH", "The current branch is protected", { branch: physical.branchName }, requestedSessionId);
421
449
  }
422
450
  const session = this.resolveSessionOwnership(physical, requestedSessionId);
423
451
  return Object.freeze({ ...physical, session: cloneSessionRecord(session) });
424
452
  }
453
+ /**
454
+ * Distinguish the current (protected) command execution context from the
455
+ * referenced/target session, so a caller does not have to separately
456
+ * re-discover where the operation should actually run. `phase: "execution"`
457
+ * marks this live-command rejection distinctly from the cleanup-time
458
+ * PROTECTED_WORKTREE/PROTECTED_BRANCH block, which `safeActionsForCode`
459
+ * and `recoveryHintsForCode` key on for a different, non-mutating safe
460
+ * action set.
461
+ */
462
+ protectedExecutionContextError(code, message, contextDetails, requestedSessionId) {
463
+ const target = requestedSessionId === null
464
+ ? undefined
465
+ : this.readUnsafe().find((record) => record.sessionId === requestedSessionId);
466
+ const details = {
467
+ ...contextDetails,
468
+ phase: "execution",
469
+ ...(requestedSessionId === null ? {} : { requestedSessionId }),
470
+ ...(target === undefined
471
+ ? {}
472
+ : {
473
+ targetWorktree: target.worktreePath,
474
+ targetBranch: target.branchName,
475
+ targetState: target.state,
476
+ ...(target.label === undefined ? {} : { targetLabel: target.label }),
477
+ }),
478
+ };
479
+ return new SessionRegistryError(code, message, {
480
+ ...details,
481
+ safeActions: [...safeActionsForCode(code, details)],
482
+ recoveryHints: recoveryHintsForCode(code, details),
483
+ });
484
+ }
425
485
  resolveSessionOwnership(physical, requestedSessionId) {
426
486
  const records = this.readUnsafe();
427
487
  const currentRecords = records.filter((record) => ACTIVE_STATES.has(record.state) && samePath(record.worktreeId, physical.worktreePath));
@@ -628,12 +688,11 @@ export class SessionRegistry {
628
688
  const conflictingClaim = activeClaims.find((claim) => claim.sessionId !== currentRecord.sessionId &&
629
689
  resourceClaimConflictsWithAccess(claim, resource, requiredAccess));
630
690
  if (conflictingClaim !== undefined) {
691
+ const conflictDetails = resourceClaimConflictDetails({ resource, mode: requiredAccess }, conflictingClaim, state.sessions);
631
692
  return deniedOperation("RESOURCE_CLAIM_CONFLICT", verified, {
632
- resource,
633
- ownerSessionId: conflictingClaim.sessionId,
634
- ownerClaimId: conflictingClaim.claimId,
635
- ownerResource: conflictingClaim.resource,
636
- ownerMode: conflictingClaim.mode,
693
+ ...conflictDetails,
694
+ safeActions: [...safeActionsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails)],
695
+ recoveryHints: recoveryHintsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails),
637
696
  });
638
697
  }
639
698
  if (ownClaims.length > 0) {
@@ -1033,6 +1092,32 @@ export class SessionRegistry {
1033
1092
  assessCleanup(sessionIdOrOptions) {
1034
1093
  return this.cleanupDecision(sessionIdOrOptions);
1035
1094
  }
1095
+ /**
1096
+ * Side-effect-free close/cleanup readiness diagnostic for one session. This
1097
+ * observes the exact same authoritative Git/session/claim truth `close()`
1098
+ * uses (including the #123 non-ancestry integration proof contract when
1099
+ * `integratedRevision` evidence is supplied) without mutating any session,
1100
+ * claim, Git, or registry state.
1101
+ */
1102
+ diagnose(sessionIdOrOptions) {
1103
+ return this.withLock(() => {
1104
+ const state = this.readStateUnsafe();
1105
+ const requestedSessionId = isCloseSessionOptions(sessionIdOrOptions)
1106
+ ? (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id)
1107
+ : sessionIdOrOptions;
1108
+ const sessionId = requestedSessionId ?? this.resolveOwnerSession(state.sessions).sessionId;
1109
+ assertSessionId(sessionId);
1110
+ const record = state.sessions.find((candidate) => candidate.sessionId === sessionId);
1111
+ if (record === undefined) {
1112
+ throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
1113
+ }
1114
+ const integratedRevision = isCloseSessionOptions(sessionIdOrOptions)
1115
+ ? (sessionIdOrOptions.integratedRevision ?? sessionIdOrOptions.integrated_revision ?? null)
1116
+ : null;
1117
+ const evidence = integratedRevision === null || integratedRevision === undefined ? undefined : { integratedRevision };
1118
+ return this.diagnoseUnsafe(record, state, evidence);
1119
+ });
1120
+ }
1036
1121
  /**
1037
1122
  * Reconcile registry ownership with Git's physical worktree inventory.
1038
1123
  * This is deliberately diagnostic-only: no stale metadata is repaired and
@@ -1095,12 +1180,16 @@ export class SessionRegistry {
1095
1180
  /** Close one session only after its ownership and recoverability are proven safe. */
1096
1181
  close(sessionIdOrOptions) {
1097
1182
  return this.withLock(() => {
1098
- const sessionId = typeof sessionIdOrOptions === "object" && sessionIdOrOptions !== null
1183
+ const sessionId = isCloseSessionOptions(sessionIdOrOptions)
1099
1184
  ? (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id)
1100
1185
  : sessionIdOrOptions;
1101
1186
  const selectedSessionId = sessionId ?? this.resolveCurrentSession().sessionId;
1102
1187
  assertSessionId(selectedSessionId);
1103
- return this.closeUnsafe(selectedSessionId);
1188
+ const integratedRevision = isCloseSessionOptions(sessionIdOrOptions)
1189
+ ? (sessionIdOrOptions.integratedRevision ?? sessionIdOrOptions.integrated_revision ?? null)
1190
+ : null;
1191
+ const evidence = integratedRevision === null || integratedRevision === undefined ? undefined : { integratedRevision };
1192
+ return this.closeUnsafe(selectedSessionId, evidence);
1104
1193
  });
1105
1194
  }
1106
1195
  closeSession(sessionIdOrOptions) {
@@ -1254,7 +1343,7 @@ export class SessionRegistry {
1254
1343
  const candidates = requested.map((input) => createResourceClaim(input, owner, timestamp));
1255
1344
  const existing = state.claims.filter((claim) => claim.sessionId !== owner.sessionId);
1256
1345
  const current = state.claims.filter((claim) => claim.sessionId === owner.sessionId);
1257
- const added = this.validateRequestedClaims(candidates, owner, [...existing, ...current]);
1346
+ const added = this.validateRequestedClaims(candidates, owner, [...existing, ...current], state.sessions);
1258
1347
  const nextClaims = sortResourceClaims([
1259
1348
  ...state.claims,
1260
1349
  ...added.filter((candidate) => !current.some((claim) => claim.claimId === candidate.claimId)),
@@ -1266,7 +1355,7 @@ export class SessionRegistry {
1266
1355
  added: added.filter((candidate) => !current.some((claim) => claim.claimId === candidate.claimId)),
1267
1356
  };
1268
1357
  }
1269
- validateRequestedClaims(candidates, owner, existing) {
1358
+ validateRequestedClaims(candidates, owner, existing, sessions = []) {
1270
1359
  for (const candidate of candidates) {
1271
1360
  const exact = existing.find((claim) => claim.claimId === candidate.claimId);
1272
1361
  if (exact !== undefined) {
@@ -1299,21 +1388,19 @@ export class SessionRegistry {
1299
1388
  });
1300
1389
  }
1301
1390
  if (claimsConflict(candidate, current)) {
1391
+ const conflictDetails = resourceClaimConflictDetails(candidate, current, sessions);
1302
1392
  throw claimError("RESOURCE_CLAIM_CONFLICT", "Resource claim conflicts with an active session claim", {
1303
1393
  claimId: candidate.claimId,
1304
- resource: candidate.resource,
1305
- mode: candidate.mode,
1306
- ownerSessionId: current.sessionId,
1307
- ownerClaimId: current.claimId,
1308
- ownerResource: current.resource,
1309
- ownerMode: current.mode,
1394
+ ...conflictDetails,
1395
+ safeActions: [...safeActionsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails)],
1396
+ recoveryHints: recoveryHintsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails),
1310
1397
  });
1311
1398
  }
1312
1399
  }
1313
1400
  }
1314
1401
  return candidates;
1315
1402
  }
1316
- closeUnsafe(sessionId) {
1403
+ closeUnsafe(sessionId, evidence) {
1317
1404
  const state = this.readStateUnsafe();
1318
1405
  const records = state.sessions;
1319
1406
  const record = records.find((candidate) => candidate.sessionId === sessionId);
@@ -1330,15 +1417,22 @@ export class SessionRegistry {
1330
1417
  idempotent: true,
1331
1418
  };
1332
1419
  }
1333
- const resources = this.inspectCleanupResources(record);
1420
+ let resources;
1421
+ try {
1422
+ resources = this.inspectCleanupResources(record, undefined, evidence);
1423
+ }
1424
+ catch (error) {
1425
+ throw enrichCloseBlockerError(error);
1426
+ }
1334
1427
  const closingRecord = record.state === "closing" ? record : transitionSessionState(record, "closing", this.clock);
1335
1428
  let closingRecords = replaceRecord(records, closingRecord);
1336
1429
  validateRecords(closingRecords, this.repository.repositoryId);
1337
1430
  this.writeUnsafe(closingRecords, state.claims);
1338
1431
  let worktreeRemoved = false;
1339
1432
  let branchRemoved = false;
1433
+ let integrationProof;
1340
1434
  if (resources.removeWorktree) {
1341
- const revalidated = this.inspectCleanupResources(record);
1435
+ const revalidated = this.inspectCleanupResources(record, undefined, evidence);
1342
1436
  assertSameCleanupObservation(resources, revalidated, "worktree-remove");
1343
1437
  if (revalidated.removeWorktree) {
1344
1438
  removeSessionWorktree(this.git, revalidated.gitCwd, record.worktreePath);
@@ -1349,13 +1443,18 @@ export class SessionRegistry {
1349
1443
  // no longer exist. Reobserve Git from the stable integration/peer cwd that
1350
1444
  // was selected during the authoritative preflight.
1351
1445
  const branchWorktrees = listGitWorktrees(this.git, resources.gitCwd);
1352
- const branchResources = this.inspectCleanupResources(record, branchWorktrees);
1446
+ const branchResources = this.inspectCleanupResources(record, branchWorktrees, evidence);
1353
1447
  if (branchResources.branchPresent && branchResources.removeBranch) {
1354
- const branchRevalidated = this.inspectCleanupResources(record, listGitWorktrees(this.git, branchResources.gitCwd));
1448
+ const branchRevalidated = this.inspectCleanupResources(record, listGitWorktrees(this.git, branchResources.gitCwd), evidence);
1355
1449
  assertSameCleanupObservation(branchResources, branchRevalidated, "branch-remove");
1356
1450
  if (branchRevalidated.branchPresent && branchRevalidated.removeBranch) {
1357
- removeSessionBranch(this.git, branchRevalidated.gitCwd, record.branchName);
1451
+ // Git's own "-d" safety check is ancestry-only and cannot see a
1452
+ // tree-equivalence proof. Force deletion is only ever selected after
1453
+ // Nawabari has independently re-derived that proof itself.
1454
+ const forceDelete = branchRevalidated.integrationProof?.method === "tree-equivalence";
1455
+ removeSessionBranch(this.git, branchRevalidated.gitCwd, record.branchName, forceDelete);
1358
1456
  branchRemoved = true;
1457
+ integrationProof = branchRevalidated.integrationProof;
1359
1458
  }
1360
1459
  }
1361
1460
  const closedRecord = transitionSessionState(closingRecord, "closed", this.clock);
@@ -1367,9 +1466,10 @@ export class SessionRegistry {
1367
1466
  worktreeRemoved,
1368
1467
  branchRemoved,
1369
1468
  idempotent: false,
1469
+ ...(integrationProof === undefined ? {} : { integrationProof }),
1370
1470
  };
1371
1471
  }
1372
- inspectCleanupResources(record, observedWorktrees = listGitWorktrees(this.git, this.repository.worktreePath)) {
1472
+ inspectCleanupResources(record, observedWorktrees = listGitWorktrees(this.git, this.repository.worktreePath), evidence) {
1373
1473
  const git = this.git;
1374
1474
  const worktrees = observedWorktrees;
1375
1475
  const gitCwd = worktrees.find((worktree) => !worktree.prunable && !samePath(worktree.worktreePath, record.worktreePath))
@@ -1410,7 +1510,12 @@ export class SessionRegistry {
1410
1510
  assertNoRecoverableStashes(git, gitCwd, record.branchName);
1411
1511
  const worktreeProtection = this.protectedWorktree(record.worktreePath, worktrees);
1412
1512
  const branchProtection = this.protectedBranch(record.branchName, worktrees, gitCwd);
1413
- const removeBranch = branchPresent && !branchProtection && this.branchIsReachableFromIntegration(record, gitCwd);
1513
+ let removeBranch = false;
1514
+ let integrationProof;
1515
+ if (branchPresent && !branchProtection) {
1516
+ integrationProof = this.branchIsReachableFromIntegration(record, gitCwd, evidence);
1517
+ removeBranch = true;
1518
+ }
1414
1519
  return {
1415
1520
  gitCwd,
1416
1521
  physicalState: worktreeState.kind,
@@ -1421,6 +1526,7 @@ export class SessionRegistry {
1421
1526
  branchPresent,
1422
1527
  removeWorktree: registeredWorktree !== undefined && !worktreeProtection,
1423
1528
  removeBranch,
1529
+ integrationProof,
1424
1530
  };
1425
1531
  }
1426
1532
  cleanupDecisionUnsafe(record, state) {
@@ -1451,6 +1557,80 @@ export class SessionRegistry {
1451
1557
  recoveryHints: Object.freeze(recoveryHints),
1452
1558
  });
1453
1559
  }
1560
+ diagnoseUnsafe(record, state, evidence) {
1561
+ const claims = state.claims.filter((claim) => claim.sessionId === record.sessionId).map(cloneResourceClaim);
1562
+ // Used only to evaluate staleness below; deliberately not part of the
1563
+ // returned diagnostic so repeated inspection of unchanged state is
1564
+ // byte-identical, not just semantically equivalent.
1565
+ const now = toTimestamp(this.clock());
1566
+ const integrationEvidence = {
1567
+ supplied: evidence !== undefined,
1568
+ ...(evidence === undefined ? {} : { integratedRevision: evidence.integratedRevision }),
1569
+ };
1570
+ if (record.state === "closed") {
1571
+ return Object.freeze({
1572
+ schemaVersion: SESSION_DIAGNOSTIC_SCHEMA_VERSION,
1573
+ operation: "diagnostic",
1574
+ repositoryId: this.repository.repositoryId,
1575
+ worktreePath: record.worktreePath,
1576
+ branchName: record.branchName,
1577
+ session: cloneSessionRecord(record),
1578
+ claims: Object.freeze(claims),
1579
+ physicalState: "closed",
1580
+ closeReadiness: "ready",
1581
+ cleanupReadiness: "ready",
1582
+ resultState: "complete",
1583
+ idempotent: true,
1584
+ blockers: Object.freeze([]),
1585
+ safeActions: Object.freeze([]),
1586
+ integrationEvidence: Object.freeze(integrationEvidence),
1587
+ });
1588
+ }
1589
+ let physicalState = "unavailable";
1590
+ let blockers = [];
1591
+ let integrationProof;
1592
+ let staleCandidate = false;
1593
+ try {
1594
+ const worktrees = listGitWorktrees(this.git, this.repository.worktreePath);
1595
+ physicalState = inspectWorktreeState(record.worktreePath, worktrees).kind;
1596
+ staleCandidate = isStaleCandidate(record, now, this.staleAfterMs, worktrees);
1597
+ const resources = this.inspectCleanupResources(record, worktrees, evidence);
1598
+ integrationProof = resources.integrationProof;
1599
+ }
1600
+ catch (error) {
1601
+ blockers = [toDiagnosticBlocker(error)];
1602
+ }
1603
+ const closeReadiness = closeReadinessForBlockers(blockers);
1604
+ const cleanupReadiness = closeReadiness !== "ready" ? closeReadiness : staleCandidate ? "ready" : "not_due";
1605
+ const resultState = resultStateForDiagnostic(closeReadiness, blockers);
1606
+ const safeActions = blockers.length > 0
1607
+ ? sortStrings(Array.from(new Set(blockers.flatMap((blocker) => blocker.safeActions))))
1608
+ : closeReadiness === "ready"
1609
+ ? cleanupReadiness === "ready"
1610
+ ? ["close-session", "run-garbage-collect"]
1611
+ : ["close-session"]
1612
+ : [];
1613
+ return Object.freeze({
1614
+ schemaVersion: SESSION_DIAGNOSTIC_SCHEMA_VERSION,
1615
+ operation: "diagnostic",
1616
+ repositoryId: this.repository.repositoryId,
1617
+ worktreePath: record.worktreePath,
1618
+ branchName: record.branchName,
1619
+ session: cloneSessionRecord(record),
1620
+ claims: Object.freeze(claims),
1621
+ physicalState,
1622
+ closeReadiness,
1623
+ cleanupReadiness,
1624
+ resultState,
1625
+ idempotent: false,
1626
+ blockers: Object.freeze(blockers),
1627
+ safeActions: Object.freeze(safeActions),
1628
+ integrationEvidence: Object.freeze({
1629
+ ...integrationEvidence,
1630
+ ...(integrationProof === undefined ? {} : { proof: integrationProof }),
1631
+ }),
1632
+ });
1633
+ }
1454
1634
  assertCleanupAllowed(record, state) {
1455
1635
  const decision = this.cleanupDecisionUnsafe(record, state);
1456
1636
  if (decision.allowed)
@@ -1538,7 +1718,15 @@ export class SessionRegistry {
1538
1718
  ];
1539
1719
  return protectedBranchIds.includes(normalizeBranchId(branchName));
1540
1720
  }
1541
- branchIsReachableFromIntegration(record, gitCwd = this.repository.worktreePath) {
1721
+ /**
1722
+ * Prove a session branch is safe to reclaim. Ordinary ancestry is the
1723
+ * cheap/default path. When ancestry fails, a caller may supply bounded
1724
+ * non-ancestry integration evidence (e.g. a squash/rebase-merge commit);
1725
+ * Nawabari never trusts that assertion blindly and instead independently
1726
+ * re-derives a deterministic, byte-exact Git tree-object equivalence proof
1727
+ * before treating the branch as safely integrated.
1728
+ */
1729
+ branchIsReachableFromIntegration(record, gitCwd = this.repository.worktreePath, evidence) {
1542
1730
  const git = this.git;
1543
1731
  const worktrees = listGitWorktrees(git, gitCwd);
1544
1732
  const defaultBranchName = resolveDefaultBranchName(git, gitCwd, worktrees, this.defaultBranchName);
@@ -1546,27 +1734,79 @@ export class SessionRegistry {
1546
1734
  throw new SessionRegistryError("RECOVERABLE_COMMITS", `Cannot prove that commits on ${record.branchName} are safely retained: no integration branch is known`, { branch: record.branchName, recoveryHints: recoveryHintsForCode("RECOVERABLE_COMMITS") });
1547
1735
  }
1548
1736
  if (normalizeBranchId(defaultBranchName) === record.branchId)
1549
- return true;
1737
+ return { method: "ancestor" };
1550
1738
  try {
1551
1739
  git.run(["merge-base", "--is-ancestor", record.branchId, normalizeBranchId(defaultBranchName)], gitCwd);
1552
- return true;
1740
+ return { method: "ancestor" };
1553
1741
  }
1554
1742
  catch (error) {
1555
- if (isExpectedGitLookupFailure(error)) {
1556
- throw new SessionRegistryError("RECOVERABLE_COMMITS", `Commits on ${record.branchName} are not proven reachable from ${defaultBranchName}`, {
1743
+ if (!isExpectedGitLookupFailure(error))
1744
+ throw error;
1745
+ if (evidence !== undefined) {
1746
+ const proof = this.proveNonAncestryIntegration(record, gitCwd, defaultBranchName, evidence);
1747
+ if (proof !== undefined)
1748
+ return proof;
1749
+ throw new SessionRegistryError("RECOVERABLE_COMMITS", `Commits on ${record.branchName} could not be proven integrated via the supplied non-ancestry evidence`, {
1557
1750
  branch: record.branchName,
1558
1751
  integrationBranch: defaultBranchName,
1752
+ proofMethod: "tree-equivalence",
1753
+ proofResult: "unproven",
1754
+ integratedRevision: evidence.integratedRevision,
1559
1755
  recoveryHints: recoveryHintsForCode("RECOVERABLE_COMMITS"),
1560
1756
  }, error);
1561
1757
  }
1758
+ throw new SessionRegistryError("RECOVERABLE_COMMITS", `Commits on ${record.branchName} are not proven reachable from ${defaultBranchName}`, {
1759
+ branch: record.branchName,
1760
+ integrationBranch: defaultBranchName,
1761
+ proofMethod: "ancestry",
1762
+ proofResult: "unproven",
1763
+ recoveryHints: recoveryHintsForCode("RECOVERABLE_COMMITS"),
1764
+ }, error);
1765
+ }
1766
+ }
1767
+ /**
1768
+ * Independently verify caller-supplied non-ancestry integration evidence.
1769
+ * The caller's assertion only selects which revision to compare; the
1770
+ * proof itself is re-derived from local Git truth. For every path the
1771
+ * session branch touched relative to its own base, the exact tree-object
1772
+ * identity (file mode, object type, and content-addressed blob/tree SHA)
1773
+ * at the branch head must match the same path's identity at the supplied
1774
+ * integrated revision — both absent, or byte-identical. Blob SHAs are
1775
+ * content hashes, so this can never mistake a whitespace-only or other
1776
+ * textual difference for equivalence the way a patch/diff comparison
1777
+ * could; it is exact Git tree semantics, not textual normalization.
1778
+ * Returns undefined, never false, so a missing proof always falls through
1779
+ * to a fail-closed RECOVERABLE_COMMITS at the call site.
1780
+ */
1781
+ proveNonAncestryIntegration(record, gitCwd, defaultBranchName, evidence) {
1782
+ const git = this.git;
1783
+ const integratedRevision = resolveBaseRef(git, gitCwd, evidence.integratedRevision).revision;
1784
+ let branchBase;
1785
+ try {
1786
+ branchBase = git.run(["merge-base", record.branchId, normalizeBranchId(defaultBranchName)], gitCwd);
1787
+ }
1788
+ catch (error) {
1789
+ if (isExpectedGitLookupFailure(error))
1790
+ return undefined;
1562
1791
  throw error;
1563
1792
  }
1793
+ const changedPaths = readChangedPathNames(git, gitCwd, branchBase, record.branchId);
1794
+ if (changedPaths.length === 0)
1795
+ return undefined;
1796
+ const branchStates = readTreePathStates(git, gitCwd, record.branchId, changedPaths);
1797
+ const integratedStates = readTreePathStates(git, gitCwd, integratedRevision, changedPaths);
1798
+ const proven = changedPaths.every((changedPath) => treeEntriesEqual(branchStates.get(changedPath), integratedStates.get(changedPath)));
1799
+ return proven ? { method: "tree-equivalence", integratedRevision } : undefined;
1564
1800
  }
1565
1801
  resolveProvisioningResources(options, sessionId) {
1566
1802
  const git = this.git;
1567
1803
  const worktrees = listGitWorktrees(git, this.repository.worktreePath);
1568
- const requestedWorktreePath = resolveProvisionedWorktreePath(options.worktreePath ??
1569
- path.join(this.worktreeRoot, `${path.basename(this.repository.worktreePath)}-${sessionId}`), this.worktreeRoot);
1804
+ const effectiveRoot = options.worktreePath !== undefined
1805
+ ? this.worktreeRoot
1806
+ : options.worktreeRoot !== undefined
1807
+ ? resolveManagedWorktreeRoot(options.worktreeRoot)
1808
+ : this.worktreeRoot;
1809
+ const requestedWorktreePath = resolveProvisionedWorktreePath(options.worktreePath ?? path.join(effectiveRoot, `${path.basename(this.repository.worktreePath)}-${sessionId}`), effectiveRoot);
1570
1810
  const defaultWorktreePath = worktrees.find((worktree) => !worktree.prunable)?.worktreePath ?? this.repository.worktreePath;
1571
1811
  const configuredProtectedWorktrees = [
1572
1812
  ...this.protectedWorktreePaths,
@@ -1643,6 +1883,15 @@ export class SessionRegistry {
1643
1883
  readUnsafe() {
1644
1884
  return this.readStateUnsafe().sessions;
1645
1885
  }
1886
+ /**
1887
+ * The single authoritative persistence path for session/claim registry
1888
+ * state, sharing its durability policy with `registry/atomic.ts`: the
1889
+ * temporary file and target directory are fsynced around an atomic
1890
+ * rename, and only known unsupported-directory-fsync conditions are
1891
+ * tolerated. A failure observed after rename is reported as a distinct
1892
+ * durability-uncertain outcome rather than an ordinary write failure,
1893
+ * since the renamed document may already be the one readers observe.
1894
+ */
1646
1895
  writeUnsafe(records, claims) {
1647
1896
  const registry = {
1648
1897
  schema_version: REGISTRY_SCHEMA_VERSION,
@@ -1651,24 +1900,19 @@ export class SessionRegistry {
1651
1900
  claims_schema_version: RESOURCE_CLAIM_SCHEMA_VERSION,
1652
1901
  claims: sortResourceClaims(claims).map((claim) => toPersistedResourceClaim(claim, this.repository.repositoryId)),
1653
1902
  };
1654
- const contents = `${JSON.stringify(registry, null, 2)}\n`;
1655
- const temporaryPath = `${this.paths.registry}.tmp-${process.pid}-${generateSessionId()}`;
1656
- let descriptor;
1657
1903
  try {
1658
- fs.mkdirSync(this.paths.directory, { recursive: true, mode: 0o700 });
1659
- descriptor = fs.openSync(temporaryPath, "wx", 0o600);
1660
- fs.writeFileSync(descriptor, contents, "utf8");
1661
- fs.fsyncSync(descriptor);
1662
- fs.closeSync(descriptor);
1663
- descriptor = undefined;
1664
- fs.renameSync(temporaryPath, this.paths.registry);
1665
- syncDirectory(this.paths.directory);
1904
+ writeJsonAtomicallySync(this.paths.registry, registry);
1666
1905
  }
1667
1906
  catch (error) {
1668
- if (descriptor !== undefined) {
1669
- closeQuietly(descriptor);
1907
+ if (isPostRenameFailure(error)) {
1908
+ throw new SessionRegistryError("REGISTRY_DURABILITY_UNCERTAIN", `Registry rename to ${this.paths.registry} may have already committed, but durable persistence could not be proven`, {
1909
+ path: this.paths.registry,
1910
+ recoveryHints: [
1911
+ "Re-read the registry to check whether the mutation is already visible before retrying.",
1912
+ "Do not assume this operation did not happen.",
1913
+ ],
1914
+ }, error);
1670
1915
  }
1671
- unlinkQuietly(temporaryPath);
1672
1916
  throw new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not atomically write ${this.paths.registry}`, {
1673
1917
  path: this.paths.registry,
1674
1918
  }, error);
@@ -1763,6 +2007,32 @@ function inspectWorktreeState(worktreePath, worktrees) {
1763
2007
  function replaceRecord(records, replacement) {
1764
2008
  return records.map((record) => (record.sessionId === replacement.sessionId ? replacement : record));
1765
2009
  }
2010
+ /**
2011
+ * Bounded, deterministic RESOURCE_CLAIM_CONFLICT evidence: the blocking
2012
+ * session's canonical identity plus the exact conflicting claim/overlap.
2013
+ * Every RESOURCE_CLAIM_CONFLICT throw site builds its details through this
2014
+ * one function so a caller never has to run a second repository-wide scan
2015
+ * just to identify the owner.
2016
+ */
2017
+ function resourceClaimConflictDetails(requested, owner, sessions) {
2018
+ const ownerSession = sessions.find((record) => record.sessionId === owner.sessionId);
2019
+ return {
2020
+ resource: requested.resource,
2021
+ mode: requested.mode,
2022
+ ownerSessionId: owner.sessionId,
2023
+ ownerClaimId: owner.claimId,
2024
+ ownerResource: owner.resource,
2025
+ ownerMode: owner.mode,
2026
+ ...(ownerSession === undefined
2027
+ ? {}
2028
+ : {
2029
+ ownerWorktree: ownerSession.worktreePath,
2030
+ ownerBranch: ownerSession.branchName,
2031
+ ownerState: ownerSession.state,
2032
+ ...(ownerSession.label === undefined ? {} : { ownerLabel: ownerSession.label }),
2033
+ }),
2034
+ };
2035
+ }
1766
2036
  function transitionSessionState(record, state, clock) {
1767
2037
  const clockTimestamp = toTimestamp(clock());
1768
2038
  const updatedAt = new Date(Math.max(Date.parse(record.updatedAt), Date.parse(clockTimestamp))).toISOString();
@@ -1777,14 +2047,17 @@ function ownershipMismatch(record, message, details = {}) {
1777
2047
  ...(details.recoveryHints === undefined ? { recoveryHints: recoveryHintsForCode("OWNERSHIP_MISMATCH") } : {}),
1778
2048
  });
1779
2049
  }
1780
- function recoveryHintsForCode(code) {
2050
+ function recoveryHintsForCode(code, details = {}) {
1781
2051
  switch (code) {
1782
2052
  case "DIRTY_WORKTREE":
1783
2053
  return ["Preserve, commit, or explicitly recover tracked and untracked changes, then retry cleanup."];
1784
2054
  case "NESTED_REPOSITORY":
1785
2055
  return ["Inspect and explicitly preserve or remove the nested repository before retrying cleanup."];
1786
2056
  case "RECOVERABLE_COMMITS":
1787
- return ["Retain or merge the session branch commits into an authoritative local branch before retrying cleanup."];
2057
+ return [
2058
+ "Retain or merge the session branch commits into an authoritative local branch before retrying cleanup.",
2059
+ "If the branch was integrated via squash/rebase merge, retry close with an --integrated-revision proof instead.",
2060
+ ];
1788
2061
  case "RECOVERABLE_STASHES":
1789
2062
  return ["Inspect and explicitly apply, export, or drop the session stash before retrying cleanup."];
1790
2063
  case "MISSING_WORKTREE":
@@ -1796,7 +2069,17 @@ function recoveryHintsForCode(code) {
1796
2069
  return ["Restore a stable, observable Git/worktree state and rerun reconciliation before cleanup."];
1797
2070
  case "PROTECTED_WORKTREE":
1798
2071
  case "PROTECTED_BRANCH":
1799
- return ["Use the owner of the protected integration resource; do not delete it as a session resource."];
2072
+ return details.phase === "execution"
2073
+ ? [
2074
+ "Run this command from the managed session worktree it targets, or pass --session to select it explicitly.",
2075
+ "Use `session list` or `status` to find the managed worktree for the intended session.",
2076
+ ]
2077
+ : ["Use the owner of the protected integration resource; do not delete it as a session resource."];
2078
+ case "RESOURCE_CLAIM_CONFLICT":
2079
+ return [
2080
+ "Inspect the blocking session's conflicting claim, then either wait for its release or coordinate with its owner.",
2081
+ "Do not force-release another session's claim; claim release is scoped to the owning session only.",
2082
+ ];
1800
2083
  case "STALE_REGISTRY":
1801
2084
  return ["Reconcile the lifecycle record under the repository mutation lock before retrying cleanup."];
1802
2085
  default:
@@ -1817,6 +2100,117 @@ function toCleanupBlocker(error) {
1817
2100
  recoveryHints: Object.freeze(recoveryHints),
1818
2101
  });
1819
2102
  }
2103
+ function toDiagnosticBlocker(error) {
2104
+ const blocker = toCleanupBlocker(error);
2105
+ return Object.freeze({
2106
+ ...blocker,
2107
+ safeActions: Object.freeze(safeActionsForCode(blocker.code, blocker.details)),
2108
+ });
2109
+ }
2110
+ /**
2111
+ * Stable, kebab-case next-action identifiers per blocker code. This is the
2112
+ * one place that maps a registry blocker to actionable next steps, so a
2113
+ * later actionable-error surface (e.g. for RESOURCE_CLAIM_CONFLICT) can
2114
+ * extend the same table instead of diverging from it.
2115
+ */
2116
+ function safeActionsForCode(code, details) {
2117
+ switch (code) {
2118
+ case "DIRTY_WORKTREE":
2119
+ return ["commit-or-discard-changes", "retain-session"];
2120
+ case "NESTED_REPOSITORY":
2121
+ return ["resolve-nested-repository", "retain-session"];
2122
+ case "RECOVERABLE_COMMITS":
2123
+ return details.proofMethod === "tree-equivalence"
2124
+ ? ["retain-session", "supply-different-integrated-revision"]
2125
+ : ["provide-integration-evidence", "merge-or-retain-branch", "retain-session"];
2126
+ case "RECOVERABLE_STASHES":
2127
+ return ["apply-or-export-stash", "retain-session"];
2128
+ case "MISSING_WORKTREE":
2129
+ return ["run-garbage-collect"];
2130
+ case "OWNERSHIP_MISMATCH":
2131
+ case "DUPLICATE_WORKTREE_OWNERSHIP":
2132
+ case "DUPLICATE_BRANCH_OWNERSHIP":
2133
+ return ["reconcile-ownership", "retain-session"];
2134
+ case "PROTECTED_WORKTREE":
2135
+ case "PROTECTED_BRANCH":
2136
+ return details.phase === "execution"
2137
+ ? ["run-from-managed-session-worktree", "select-target-session-explicitly"]
2138
+ : ["retain-session"];
2139
+ case "RESOURCE_CLAIM_CONFLICT":
2140
+ return ["inspect-blocking-session", "wait-for-conflicting-claim-release", "retain-session"];
2141
+ case "GIT_STATE_AMBIGUOUS":
2142
+ case "PHYSICAL_OBSERVATION_UNAVAILABLE":
2143
+ case "REPOSITORY_IDENTITY_AMBIGUOUS":
2144
+ case "WORKTREE_IDENTITY_AMBIGUOUS":
2145
+ case "GIT_COMMAND_FAILED":
2146
+ case "GIT_SPAWN_FAILED":
2147
+ case "GIT_TIMEOUT":
2148
+ case "GIT_OUTPUT_LIMIT":
2149
+ return ["stabilize-repository-state", "retry-diagnostic"];
2150
+ case "STALE_REGISTRY":
2151
+ case "RECONCILIATION_DRIFT":
2152
+ return ["retry-diagnostic"];
2153
+ default:
2154
+ return ["retry-diagnostic"];
2155
+ }
2156
+ }
2157
+ function isAmbiguousObservationCode(code) {
2158
+ return (code === "GIT_STATE_AMBIGUOUS" ||
2159
+ code === "PHYSICAL_OBSERVATION_UNAVAILABLE" ||
2160
+ code === "REPOSITORY_IDENTITY_AMBIGUOUS" ||
2161
+ code === "WORKTREE_IDENTITY_AMBIGUOUS" ||
2162
+ code === "GIT_COMMAND_FAILED" ||
2163
+ code === "GIT_SPAWN_FAILED" ||
2164
+ code === "GIT_TIMEOUT" ||
2165
+ code === "GIT_OUTPUT_LIMIT");
2166
+ }
2167
+ /** #123 non-ancestry integration: RECOVERABLE_COMMITS with no ancestry proof and no evidence attempted yet. */
2168
+ function isExternalEvidenceEligible(blocker) {
2169
+ return blocker.code === "RECOVERABLE_COMMITS" && blocker.details.proofMethod === "ancestry";
2170
+ }
2171
+ function closeReadinessForBlockers(blockers) {
2172
+ const blocker = blockers[0];
2173
+ if (blocker === undefined)
2174
+ return "ready";
2175
+ if (isAmbiguousObservationCode(blocker.code))
2176
+ return "ambiguous";
2177
+ if (isExternalEvidenceEligible(blocker))
2178
+ return "external_evidence_required";
2179
+ return "blocked";
2180
+ }
2181
+ function resultStateForDiagnostic(closeReadiness, blockers) {
2182
+ if (closeReadiness === "ambiguous")
2183
+ return "ambiguous";
2184
+ if (closeReadiness === "external_evidence_required")
2185
+ return "external_evidence_required";
2186
+ const blocker = blockers[0];
2187
+ if (blocker !== undefined && (blocker.code === "STALE_REGISTRY" || blocker.code === "RECONCILIATION_DRIFT")) {
2188
+ return "stale";
2189
+ }
2190
+ return "complete";
2191
+ }
2192
+ /**
2193
+ * Enrich a direct `close()` rejection with the exact same #124 diagnostic
2194
+ * classification `session inspect` would report for the identical blocked
2195
+ * state (readiness, result state, stable safe actions), so a raw close
2196
+ * failure and the diagnostic authority can never drift apart. This never
2197
+ * mutates session/claim/Git/worktree state; it only reclassifies an error
2198
+ * that `inspectCleanupResources` already raised before any mutation ran.
2199
+ */
2200
+ function enrichCloseBlockerError(error) {
2201
+ if (!(error instanceof SessionRegistryError))
2202
+ throw error;
2203
+ const blocker = toDiagnosticBlocker(error);
2204
+ const closeReadiness = closeReadinessForBlockers([blocker]);
2205
+ const resultState = resultStateForDiagnostic(closeReadiness, [blocker]);
2206
+ return new SessionRegistryError(error.code, error.message, {
2207
+ ...blocker.details,
2208
+ safeActions: [...blocker.safeActions],
2209
+ recoveryHints: [...blocker.recoveryHints],
2210
+ closeReadiness,
2211
+ resultState,
2212
+ }, error.cause);
2213
+ }
1820
2214
  function toGarbageCollectBlocked(sessionId, error) {
1821
2215
  const blocker = toCleanupBlocker(error);
1822
2216
  return Object.freeze({
@@ -2246,8 +2640,8 @@ function removeSessionWorktree(git, cwd, worktreePath) {
2246
2640
  const force = !fs.existsSync(worktreePath);
2247
2641
  git.run(["worktree", "remove", ...(force ? ["--force"] : []), worktreePath], cwd);
2248
2642
  }
2249
- function removeSessionBranch(git, cwd, branchName) {
2250
- git.run(["branch", "-d", "--", branchName], cwd);
2643
+ function removeSessionBranch(git, cwd, branchName, force = false) {
2644
+ git.run(["branch", force ? "-D" : "-d", "--", branchName], cwd);
2251
2645
  }
2252
2646
  function generateUniqueSessionId(records, idGenerator) {
2253
2647
  for (let attempt = 0; attempt < MAX_ID_GENERATION_ATTEMPTS; attempt += 1) {
@@ -2739,10 +3133,12 @@ function validateRegistryClaims(records, claims, expectedRepositoryId) {
2739
3133
  }
2740
3134
  if (!claimsConflict(current, prior))
2741
3135
  continue;
3136
+ const conflictDetails = resourceClaimConflictDetails(current, prior, records);
2742
3137
  throw claimError("RESOURCE_CLAIM_CONFLICT", "Persisted claims contain an unresolved conflict", {
2743
3138
  claimId: current.claimId,
2744
- ownerClaimId: prior.claimId,
2745
- ownerSessionId: prior.sessionId,
3139
+ ...conflictDetails,
3140
+ safeActions: [...safeActionsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails)],
3141
+ recoveryHints: recoveryHintsForCode("RESOURCE_CLAIM_CONFLICT", conflictDetails),
2746
3142
  });
2747
3143
  }
2748
3144
  }
@@ -2992,36 +3388,4 @@ function toSessionRegistryLockError(error, lockPath) {
2992
3388
  }
2993
3389
  return new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not operate on ${lockPath}`, { path: lockPath }, error);
2994
3390
  }
2995
- function closeQuietly(descriptor) {
2996
- try {
2997
- fs.closeSync(descriptor);
2998
- }
2999
- catch {
3000
- // The original operation's error is more useful than a best-effort close error.
3001
- }
3002
- }
3003
- function unlinkQuietly(filePath) {
3004
- try {
3005
- fs.unlinkSync(filePath);
3006
- }
3007
- catch {
3008
- // A missing temporary file is already the desired state.
3009
- }
3010
- }
3011
- function syncDirectory(directory) {
3012
- let descriptor;
3013
- try {
3014
- descriptor = fs.openSync(directory, "r");
3015
- fs.fsyncSync(descriptor);
3016
- }
3017
- catch {
3018
- // Directory fsync is not available on every supported filesystem. The
3019
- // file itself was fsynced before rename, so continue on that limitation.
3020
- }
3021
- finally {
3022
- if (descriptor !== undefined) {
3023
- closeQuietly(descriptor);
3024
- }
3025
- }
3026
- }
3027
3391
  //# sourceMappingURL=session-registry.js.map