nawabari 0.2.0 → 0.4.1

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.
Files changed (50) hide show
  1. package/README.md +230 -15
  2. package/dist/cli.d.ts +5 -0
  3. package/dist/cli.js +704 -115
  4. package/dist/cli.js.map +1 -1
  5. package/dist/contract.js +135 -2
  6. package/dist/contract.js.map +1 -1
  7. package/dist/domain/errors.d.ts +1 -1
  8. package/dist/domain/errors.js +4 -1
  9. package/dist/domain/errors.js.map +1 -1
  10. package/dist/domain/sandbox-launcher.d.ts +35 -0
  11. package/dist/domain/sandbox-launcher.js +679 -0
  12. package/dist/domain/sandbox-launcher.js.map +1 -0
  13. package/dist/domain/sandbox.d.ts +190 -0
  14. package/dist/domain/sandbox.js +363 -0
  15. package/dist/domain/sandbox.js.map +1 -0
  16. package/dist/domain/session-backend.d.ts +5 -3
  17. package/dist/domain/session-backend.js +102 -4
  18. package/dist/domain/session-backend.js.map +1 -1
  19. package/dist/domain/session.d.ts +116 -2
  20. package/dist/domain/session.js +36 -0
  21. package/dist/domain/session.js.map +1 -1
  22. package/dist/errors.d.ts +1 -1
  23. package/dist/errors.js.map +1 -1
  24. package/dist/git.d.ts +62 -0
  25. package/dist/git.js +299 -0
  26. package/dist/git.js.map +1 -1
  27. package/dist/operation-authorization.d.ts +5 -2
  28. package/dist/operation-authorization.js +6 -7
  29. package/dist/operation-authorization.js.map +1 -1
  30. package/dist/output-budget.d.ts +16 -0
  31. package/dist/output-budget.js +58 -0
  32. package/dist/output-budget.js.map +1 -0
  33. package/dist/presentation.js +28 -4
  34. package/dist/presentation.js.map +1 -1
  35. package/dist/registry/atomic.d.ts +24 -0
  36. package/dist/registry/atomic.js +35 -2
  37. package/dist/registry/atomic.js.map +1 -1
  38. package/dist/registry/errors.d.ts +1 -1
  39. package/dist/registry/errors.js +1 -0
  40. package/dist/registry/errors.js.map +1 -1
  41. package/dist/repository-evidence.d.ts +74 -0
  42. package/dist/repository-evidence.js +7 -0
  43. package/dist/repository-evidence.js.map +1 -0
  44. package/dist/resource-claims.d.ts +25 -3
  45. package/dist/resource-claims.js +57 -5
  46. package/dist/resource-claims.js.map +1 -1
  47. package/dist/session-registry.d.ts +68 -7
  48. package/dist/session-registry.js +422 -149
  49. package/dist/session-registry.js.map +1 -1
  50. package/package.json +5 -3
@@ -1,11 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, readCurrentHead, resolveRepositoryContext, verifyPhysicalExecutionContext, } from "./git.js";
3
+ import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readGitPathStats, readCurrentHead, resolveRepositoryContext, 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
- import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput, canonicalizeClaimResource, claimError, claimsConflict, claimsOverlap, cloneResourceClaim, compareCodePointStrings, createResourceClaim, isResourceClaimMode, resourceClaimConflictsWithAccess, resourceMatchesClaim, RESOURCE_CLAIM_SCHEMA_VERSION, sortResourceClaims, } from "./resource-claims.js";
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";
10
+ import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, evidenceHash, } from "./repository-evidence.js";
11
+ export { DIFF_EVIDENCE_SCHEMA_VERSION, EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, REPOSITORY_EVIDENCE_SCHEMA_VERSION, } from "./repository-evidence.js";
9
12
  export const REGISTRY_SCHEMA_VERSION = 1;
10
13
  export { RESOURCE_CLAIM_SCHEMA_VERSION };
11
14
  export const REGISTRY_DIRECTORY_NAME = "nawabari";
@@ -15,6 +18,8 @@ export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
15
18
  export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
16
19
  export const RECONCILIATION_SCHEMA_VERSION = 1;
17
20
  const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
21
+ /** Bounds a caller-declared commit-message pattern before it is compiled. */
22
+ export const MAX_MESSAGE_PATTERN_LENGTH = 512;
18
23
  export { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, OPERATION_REQUIRED_ACCESS, OPERATION_VOCABULARY, } from "./operation-authorization.js";
19
24
  export const GOVERNED_GIT_OPERATION_SCHEMA_VERSION = 1;
20
25
  const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
@@ -75,6 +80,10 @@ export class SessionRegistry {
75
80
  metadataGraceMs: this.lockMetadataGraceMs,
76
81
  });
77
82
  }
83
+ /** Resolved repository-local root used for default managed session worktrees. */
84
+ get managedWorktreeRoot() {
85
+ return this.worktreeRoot;
86
+ }
78
87
  read() {
79
88
  return this.readUnsafe().map(cloneSessionRecord);
80
89
  }
@@ -105,16 +114,18 @@ export class SessionRegistry {
105
114
  return claim === undefined ? undefined : cloneResourceClaim(claim);
106
115
  }
107
116
  /**
108
- * Explicitly materialize the v0.1.0 registry's empty claim section. This is
109
- * also performed by the first ownership mutation after a legacy read.
117
+ * Explicitly materialize a claim section or upgrade its semantics. A v1
118
+ * claim registry is intentionally unreadable through ordinary operations;
119
+ * only this locked, explicit migration rewrites it as v2.
110
120
  */
111
121
  migrate() {
112
122
  return this.withLock(() => {
113
- const state = this.readStateUnsafe();
114
- if (state.legacyClaimsAbsent)
123
+ const state = this.readStateUnsafe(true);
124
+ const migrated = state.legacyClaimsAbsent || state.legacyClaimsSchemaVersion !== undefined;
125
+ if (migrated)
115
126
  this.writeUnsafe(state.sessions, state.claims);
116
127
  return {
117
- migrated: state.legacyClaimsAbsent,
128
+ migrated,
118
129
  registrySchemaVersion: REGISTRY_SCHEMA_VERSION,
119
130
  claimSchemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
120
131
  };
@@ -248,6 +259,7 @@ export class SessionRegistry {
248
259
  state: "active",
249
260
  createdAt: timestamp,
250
261
  updatedAt: timestamp,
262
+ baseRevision: resources.baseRevision,
251
263
  ...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
252
264
  });
253
265
  assertNoOwnershipConflict(records, record);
@@ -282,13 +294,14 @@ export class SessionRegistry {
282
294
  state: "active",
283
295
  createdAt: timestamp,
284
296
  updatedAt: timestamp,
297
+ baseRevision: resources.baseRevision,
285
298
  ...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
286
299
  });
287
300
  assertNoOwnershipConflict(state.sessions, record);
288
301
  assertGitResourcesAvailable(this.git, this.repository.worktreePath, resources);
289
302
  let gitProvisioned = false;
290
303
  try {
291
- this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRef], this.repository.worktreePath);
304
+ this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRevision], this.repository.worktreePath);
292
305
  gitProvisioned = true;
293
306
  verifyPhysicalExecutionContext({
294
307
  repository: this.repository,
@@ -300,13 +313,40 @@ export class SessionRegistry {
300
313
  return cloneSessionRecord(record);
301
314
  }
302
315
  catch (error) {
303
- if (gitProvisioned) {
316
+ if (gitProvisioned && this.absenceProvenAfterProvisioningFailure(error, sessionId)) {
304
317
  rollbackProvisionedResources(this.git, this.repository.worktreePath, resources);
305
318
  }
306
319
  throw classifyProvisioningFailure(error, this.git, this.repository.worktreePath, resources);
307
320
  }
308
321
  });
309
322
  }
323
+ /**
324
+ * Decide whether it is safe to destroy the just-created worktree/branch
325
+ * after a provisioning failure. Only an ordinary (non-durability-uncertain)
326
+ * failure, or a durability-uncertain failure whose registry re-read
327
+ * positively proves the session record absent, licenses a rollback.
328
+ *
329
+ * A durability-uncertain write means the rename may have already
330
+ * committed this session as the registry's visible ownership record:
331
+ * rolling back the worktree/branch would then strand a registry entry
332
+ * pointing at resources we just deleted. Absence must be *proven*, not
333
+ * merely unconfirmed — if the reconciliation read itself cannot complete
334
+ * (I/O failure, corrupt registry state, or any other exception), that is
335
+ * not proof of absence, so no rollback happens and the original
336
+ * durability-uncertain outcome propagates unchanged.
337
+ */
338
+ absenceProvenAfterProvisioningFailure(error, sessionId) {
339
+ const durabilityUncertain = error instanceof SessionRegistryError && error.code === "REGISTRY_DURABILITY_UNCERTAIN";
340
+ if (!durabilityUncertain) {
341
+ return true;
342
+ }
343
+ try {
344
+ return !this.readUnsafe().some((candidate) => candidate.sessionId === sessionId);
345
+ }
346
+ catch {
347
+ return false;
348
+ }
349
+ }
310
350
  provisionSession(options = {}) {
311
351
  return this.provision(options);
312
352
  }
@@ -359,6 +399,36 @@ export class SessionRegistry {
359
399
  const session = this.resolveSessionOwnership(physical, options.sessionId ?? null);
360
400
  return Object.freeze({ ...physical, session: cloneSessionRecord(session) });
361
401
  }
402
+ /** Resolve and physically verify an explicitly addressed active session. */
403
+ verifyEvidenceSession(sessionId) {
404
+ assertSessionId(sessionId);
405
+ const record = this.readUnsafe().find((candidate) => candidate.sessionId === sessionId);
406
+ if (record === undefined || record.state === "closed") {
407
+ throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
408
+ }
409
+ if (record.state !== "active") {
410
+ throw new SessionRegistryError("STALE_REGISTRY", `Session is not active: ${sessionId}`, {
411
+ sessionId,
412
+ state: record.state,
413
+ });
414
+ }
415
+ const physical = verifyPhysicalExecutionContext({
416
+ repository: this.repository,
417
+ worktreePath: record.worktreePath,
418
+ branchName: record.branchName,
419
+ git: this.git,
420
+ });
421
+ if (physical.worktreeId !== record.worktreeId || physical.branchId !== record.branchId) {
422
+ throw new SessionRegistryError("OWNERSHIP_MISMATCH", "The selected session does not own the observed worktree", {
423
+ sessionId,
424
+ expectedWorktree: record.worktreePath,
425
+ actualWorktree: physical.worktreePath,
426
+ expectedBranch: record.branchName,
427
+ actualBranch: physical.branchName,
428
+ });
429
+ }
430
+ return Object.freeze({ session: cloneSessionRecord(record), physical });
431
+ }
362
432
  /** Shared physical/protected/ownership path for every governed decision. */
363
433
  verifyGovernedExecutionContext(requestedSessionId, observedPhysical) {
364
434
  const physical = observedPhysical ??
@@ -594,6 +664,14 @@ export class SessionRegistry {
594
664
  ownerMode: conflictingClaim.mode,
595
665
  });
596
666
  }
667
+ if (ownClaims.length > 0) {
668
+ return deniedOperation("INSUFFICIENT_CLAIM_MODE", verified, {
669
+ resource,
670
+ requiredAccess,
671
+ grantedModes: sortStrings(new Set(ownClaims.map((claim) => claim.mode))),
672
+ sessionId: currentRecord.sessionId,
673
+ });
674
+ }
597
675
  return deniedOperation("MISSING_RESOURCE_CLAIM", verified, {
598
676
  resource,
599
677
  requiredAccess,
@@ -634,13 +712,17 @@ export class SessionRegistry {
634
712
  * ownership or mutation authority.
635
713
  */
636
714
  commit(options) {
715
+ // Validated before the repository lock is acquired: a pathological
716
+ // caller-declared pattern must not hold the cross-session repository
717
+ // lock hostage while it backtracks.
718
+ const messagePattern = options.messagePattern ?? options.message_pattern ?? null;
719
+ assertFinalCommitMessage(options.message, messagePattern);
637
720
  return this.withLock(() => {
638
721
  const sessionId = operationSessionId(options.sessionId, options.session_id);
639
722
  const requestedResources = operationResources(options.resources ?? options.paths);
640
723
  const authorization = this.requireMutationAuthorization("commit", requestedResources, sessionId);
641
724
  const initial = this.verifyGovernedExecutionContext(sessionId);
642
- assertFinalCommitMessage(options.message);
643
- const before = observeMutationPaths(this.git, initial.worktreePath);
725
+ const before = observeGitMutationPaths(this.git, initial.worktreePath);
644
726
  assertNoUnexpectedMutationPaths(before.changed, authorization.resources.map((resource) => resource.resource));
645
727
  const stageResources = authorization.resources
646
728
  .map((resource) => resource.resource)
@@ -652,7 +734,7 @@ export class SessionRegistry {
652
734
  }
653
735
  reverifyMutationContext(this, initial, sessionId, "stage");
654
736
  runMutationGit(this.git, ["add", "--", ...stageResources], initial.worktreePath, "stage", "COMMIT_STAGING_FAILED");
655
- const afterStage = observeMutationPaths(this.git, initial.worktreePath);
737
+ const afterStage = observeGitMutationPaths(this.git, initial.worktreePath);
656
738
  assertNoUnexpectedMutationPaths(afterStage.changed, stageResources);
657
739
  if (!afterStage.staged.some((resource) => stageResources.includes(resource))) {
658
740
  throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Git staging produced no authorized staged paths", {
@@ -660,7 +742,7 @@ export class SessionRegistry {
660
742
  });
661
743
  }
662
744
  reverifyMutationContext(this, initial, sessionId, "commit");
663
- const beforeCommit = observeMutationPaths(this.git, initial.worktreePath);
745
+ const beforeCommit = observeGitMutationPaths(this.git, initial.worktreePath);
664
746
  assertNoUnexpectedMutationPaths(beforeCommit.changed, stageResources);
665
747
  if (!beforeCommit.staged.some((resource) => stageResources.includes(resource))) {
666
748
  throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Authorized staged paths disappeared before commit", {
@@ -677,11 +759,34 @@ export class SessionRegistry {
677
759
  throw error;
678
760
  throw new SessionRegistryError("COMMIT_RESULT_UNAVAILABLE", "Git committed the operation but the resulting commit SHA could not be resolved", { phase: "resolve-commit-result" }, error);
679
761
  }
762
+ // The Git commit succeeded and its SHA is known; every failure from here
763
+ // must retain commitSha so the caller can recover/reconcile the result
764
+ // instead of losing track of a commit that actually happened.
765
+ let committedPaths;
766
+ try {
767
+ committedPaths = readCanonicalCommitChangedPaths(this.git, initial.worktreePath, commitSha);
768
+ }
769
+ catch (error) {
770
+ if (error instanceof SessionRegistryError && isBoundedGitFailure(error.code)) {
771
+ throw new SessionRegistryError(error.code, error.message, { ...error.details, commitSha }, error);
772
+ }
773
+ throw new SessionRegistryError("COMMIT_RESULT_UNAVAILABLE", "Git committed the operation but its actual changed paths could not be resolved", { commitSha, phase: "resolve-commit-paths" }, error);
774
+ }
775
+ const authorizedSet = new Set(stageResources);
776
+ const divergentResources = committedPaths.filter((resource) => !authorizedSet.has(resource));
777
+ if (divergentResources.length > 0) {
778
+ throw new SessionRegistryError("COMMIT_RESULT_DIVERGED", "The resulting commit contains paths outside the authorized/staged resource set", {
779
+ commitSha,
780
+ authorizedResources: [...stageResources],
781
+ committedResources: [...committedPaths],
782
+ divergentResources,
783
+ });
784
+ }
680
785
  return Object.freeze({
681
786
  schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
682
787
  commitSha,
683
788
  message: options.message,
684
- resources: Object.freeze([...stageResources]),
789
+ resources: committedPaths,
685
790
  });
686
791
  });
687
792
  }
@@ -705,7 +810,7 @@ export class SessionRegistry {
705
810
  pushBranch: branch,
706
811
  });
707
812
  }
708
- const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch);
813
+ const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch, initial.headId);
709
814
  if (inspection.upstream !== undefined && inspection.upstream !== `${remote}/${branch}`) {
710
815
  throw new SessionRegistryError("PUSH_TARGET_MISMATCH", "The explicit push target does not match the currently configured upstream", { upstream: inspection.upstream, remote, branch });
711
816
  }
@@ -719,32 +824,44 @@ export class SessionRegistry {
719
824
  const code = inspection.relation === "behind" ? "PUSH_BEHIND" : "PUSH_DIVERGED";
720
825
  throw new SessionRegistryError(code, `The local and remote branches are ${inspection.relation}; explicit force intent is required`, { remote, branch, relation: inspection.relation, forceRequired: true });
721
826
  }
722
- const dirtyBefore = observeMutationPaths(this.git, initial.worktreePath);
827
+ const dirtyBefore = observeGitMutationPaths(this.git, initial.worktreePath);
723
828
  if (dirtyBefore.changed.length > 0) {
724
829
  throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "Push requires a clean Git worktree", {
725
830
  paths: [...dirtyBefore.changed],
726
831
  });
727
832
  }
728
- reverifyMutationContext(this, initial, sessionId, "push");
729
- const dirtyBeforePush = observeMutationPaths(this.git, initial.worktreePath);
833
+ const finalContext = reverifyMutationContext(this, initial, sessionId, "push");
834
+ const dirtyBeforePush = observeGitMutationPaths(this.git, initial.worktreePath);
730
835
  if (dirtyBeforePush.changed.length > 0) {
731
836
  throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "The worktree changed before push mutation", {
732
837
  paths: [...dirtyBeforePush.changed],
733
838
  });
734
839
  }
840
+ const targetRef = `refs/heads/${branch}`;
841
+ const leaseValue = inspection.observedRemoteSha ?? "";
735
842
  const pushArguments = [
736
843
  "push",
737
- ...(force ? ["--force-with-lease"] : []),
844
+ ...(force ? [`--force-with-lease=${targetRef}:${leaseValue}`] : ["--no-force"]),
738
845
  ...(createUpstream ? ["--set-upstream"] : []),
739
846
  remote,
740
- `HEAD:refs/heads/${branch}`,
847
+ `${finalContext.headId}:${targetRef}`,
741
848
  ];
742
849
  runMutationGit(this.git, pushArguments, initial.worktreePath, "push", "PUSH_FAILED");
850
+ if (createUpstream) {
851
+ // A SHA:ref refspec is intentional: it cannot be redirected by a
852
+ // mutable symbolic HEAD. Git does not consistently infer the current
853
+ // branch's tracking configuration from a raw SHA source, so complete
854
+ // the explicitly requested local upstream setup after the push.
855
+ runMutationGit(this.git, ["branch", "--set-upstream-to", `${remote}/${branch}`, branch], initial.worktreePath, "set-upstream", "PUSH_FAILED");
856
+ }
743
857
  return Object.freeze({
744
858
  schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
859
+ sourceSha: finalContext.headId,
745
860
  remote,
746
861
  branch,
747
862
  target: `${remote}/${branch}`,
863
+ targetRef,
864
+ observedRemoteSha: inspection.observedRemoteSha,
748
865
  relation: inspection.relation,
749
866
  force,
750
867
  upstreamCreated: createUpstream,
@@ -776,13 +893,7 @@ export class SessionRegistry {
776
893
  const execution = this.verifyGovernedExecutionContext(requestedSessionId);
777
894
  const physical = execution;
778
895
  const session = execution.session;
779
- const observed = captureGitCheckpoint(this.git, physical.worktreePath);
780
- const paths = {
781
- changed: canonicalObservedPaths(observed.changed, physical.worktreePath),
782
- staged: canonicalObservedPaths(observed.staged, physical.worktreePath),
783
- unstaged: canonicalObservedPaths(observed.unstaged, physical.worktreePath),
784
- untracked: canonicalObservedPaths(observed.untracked, physical.worktreePath),
785
- };
896
+ const paths = observeGitCheckpoint(this.git, physical.worktreePath);
786
897
  const state = this.readStateUnsafe();
787
898
  const activeClaims = state.claims.filter((claim) => claim.sessionId === session.sessionId &&
788
899
  state.sessions.some((record) => record.sessionId === claim.sessionId && record.state === "active"));
@@ -810,6 +921,122 @@ export class SessionRegistry {
810
921
  captureCheckpoint(options = {}) {
811
922
  return this.checkpoint(options);
812
923
  }
924
+ /**
925
+ * Capture one deterministic, read-only physical repository generation for
926
+ * an explicitly addressed owned session.
927
+ */
928
+ repositoryEvidence(options) {
929
+ return this.withLock(() => {
930
+ const initial = this.verifyEvidenceSession(options.sessionId);
931
+ const paths = observeGitCheckpoint(this.git, initial.physical.worktreePath);
932
+ const stats = paths.changed.length === 0
933
+ ? []
934
+ : readGitPathStats(this.git, initial.physical.worktreePath, paths.changed, initial.physical.headId);
935
+ const final = this.verifyEvidenceSession(options.sessionId);
936
+ if (final.physical.branchName !== initial.physical.branchName ||
937
+ final.physical.headId !== initial.physical.headId ||
938
+ !sameCheckpointPaths(paths, observeGitCheckpoint(this.git, final.physical.worktreePath))) {
939
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository evidence changed during physical observation", {
940
+ sessionId: options.sessionId,
941
+ worktree: initial.physical.worktreePath,
942
+ });
943
+ }
944
+ const baseRevision = initial.session.baseRevision ?? null;
945
+ const incompleteReasons = stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : [];
946
+ const pathsEvidence = Object.freeze({
947
+ changed: paths.changed,
948
+ staged: paths.staged,
949
+ unstaged: paths.unstaged,
950
+ untracked: paths.untracked,
951
+ stats: Object.freeze([...stats]),
952
+ });
953
+ const snapshotWithoutHash = {
954
+ schemaVersion: 1,
955
+ source: "git",
956
+ guarantee: "git-observable-only",
957
+ repositoryId: initial.physical.repositoryId,
958
+ worktreePath: initial.physical.worktreePath,
959
+ branchId: initial.physical.branchId,
960
+ branchName: initial.physical.branchName,
961
+ sessionId: initial.session.sessionId,
962
+ sessionState: initial.session.state,
963
+ sessionCreatedAt: initial.session.createdAt,
964
+ sessionUpdatedAt: initial.session.updatedAt,
965
+ baseRevision,
966
+ baseRevisionProven: baseRevision !== null,
967
+ headId: initial.physical.headId,
968
+ clean: paths.changed.length === 0,
969
+ complete: incompleteReasons.length === 0,
970
+ incompleteReasons: Object.freeze(incompleteReasons),
971
+ paths: pathsEvidence,
972
+ bounds: Object.freeze({
973
+ maxPaths: CHECKPOINT_MAX_PATHS,
974
+ maxDiffPaths: EVIDENCE_MAX_DIFF_PATHS,
975
+ maxDiffBytes: EVIDENCE_MAX_DIFF_BYTES,
976
+ maxDiffHunks: EVIDENCE_MAX_DIFF_HUNKS,
977
+ }),
978
+ };
979
+ return Object.freeze({
980
+ ...snapshotWithoutHash,
981
+ evidenceHash: evidenceHash(snapshotWithoutHash),
982
+ });
983
+ });
984
+ }
985
+ evidenceSnapshot(options) {
986
+ return this.repositoryEvidence(options);
987
+ }
988
+ /** Inspect only explicitly selected paths with bounded stat/patch output. */
989
+ repositoryDiff(options) {
990
+ return this.withLock(() => {
991
+ const initial = this.verifyEvidenceSession(options.sessionId);
992
+ const diff = readBoundedGitDiff(this.git, initial.physical.worktreePath, {
993
+ paths: options.paths,
994
+ from: options.from,
995
+ to: options.to,
996
+ includePatch: options.includePatch,
997
+ maxBytes: options.maxBytes,
998
+ maxHunks: options.maxHunks,
999
+ });
1000
+ const final = this.verifyEvidenceSession(options.sessionId);
1001
+ if (final.physical.branchName !== initial.physical.branchName ||
1002
+ final.physical.headId !== initial.physical.headId) {
1003
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository diff changed during physical observation", {
1004
+ sessionId: options.sessionId,
1005
+ worktree: initial.physical.worktreePath,
1006
+ });
1007
+ }
1008
+ const diffWithoutHash = {
1009
+ schemaVersion: diff.schemaVersion,
1010
+ source: "git",
1011
+ guarantee: "git-observable-only",
1012
+ repositoryId: initial.physical.repositoryId,
1013
+ worktreePath: initial.physical.worktreePath,
1014
+ branchId: initial.physical.branchId,
1015
+ branchName: initial.physical.branchName,
1016
+ sessionId: initial.session.sessionId,
1017
+ sessionState: initial.session.state,
1018
+ headId: initial.physical.headId,
1019
+ fromRevision: diff.fromRevision,
1020
+ toRevision: diff.toRevision,
1021
+ paths: diff.paths,
1022
+ stats: diff.stats,
1023
+ complete: diff.stats.every((stat) => stat.available),
1024
+ incompleteReasons: Object.freeze(diff.stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : []),
1025
+ patch: diff.patch,
1026
+ patchBytes: diff.patchBytes,
1027
+ hunkCount: diff.hunkCount,
1028
+ maxBytes: options.maxBytes ?? EVIDENCE_MAX_DIFF_BYTES,
1029
+ maxHunks: options.maxHunks ?? EVIDENCE_MAX_DIFF_HUNKS,
1030
+ };
1031
+ return Object.freeze({
1032
+ ...diffWithoutHash,
1033
+ evidenceHash: evidenceHash(diffWithoutHash),
1034
+ });
1035
+ });
1036
+ }
1037
+ diff(options) {
1038
+ return this.repositoryDiff(options);
1039
+ }
813
1040
  /**
814
1041
  * Produce the authoritative, non-mutating cleanup decision for one session.
815
1042
  * The decision observes registry claims and current Git/filesystem state in
@@ -1393,12 +1620,13 @@ export class SessionRegistry {
1393
1620
  branch: shortBranchName,
1394
1621
  });
1395
1622
  }
1396
- const baseRef = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
1623
+ const base = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
1397
1624
  return {
1398
1625
  worktreePath: requestedWorktreePath,
1399
1626
  branchId,
1400
1627
  branchName: shortBranchName,
1401
- baseRef,
1628
+ baseRef: base.ref,
1629
+ baseRevision: base.revision,
1402
1630
  };
1403
1631
  }
1404
1632
  resolveCreationResources(options) {
@@ -1413,9 +1641,10 @@ export class SessionRegistry {
1413
1641
  worktreePath: physical.worktreePath,
1414
1642
  branchId: physical.branchId,
1415
1643
  branchName: physical.branchName,
1644
+ baseRevision: physical.headId,
1416
1645
  };
1417
1646
  }
1418
- readStateUnsafe() {
1647
+ readStateUnsafe(allowLegacyClaimSchema = false) {
1419
1648
  let contents;
1420
1649
  try {
1421
1650
  contents = fs.readFileSync(this.paths.registry, "utf8");
@@ -1437,11 +1666,20 @@ export class SessionRegistry {
1437
1666
  path: this.paths.registry,
1438
1667
  }, error);
1439
1668
  }
1440
- return parseRegistry(parsed, this.repository.repositoryId);
1669
+ return parseRegistry(parsed, this.repository.repositoryId, allowLegacyClaimSchema);
1441
1670
  }
1442
1671
  readUnsafe() {
1443
1672
  return this.readStateUnsafe().sessions;
1444
1673
  }
1674
+ /**
1675
+ * The single authoritative persistence path for session/claim registry
1676
+ * state, sharing its durability policy with `registry/atomic.ts`: the
1677
+ * temporary file and target directory are fsynced around an atomic
1678
+ * rename, and only known unsupported-directory-fsync conditions are
1679
+ * tolerated. A failure observed after rename is reported as a distinct
1680
+ * durability-uncertain outcome rather than an ordinary write failure,
1681
+ * since the renamed document may already be the one readers observe.
1682
+ */
1445
1683
  writeUnsafe(records, claims) {
1446
1684
  const registry = {
1447
1685
  schema_version: REGISTRY_SCHEMA_VERSION,
@@ -1450,24 +1688,19 @@ export class SessionRegistry {
1450
1688
  claims_schema_version: RESOURCE_CLAIM_SCHEMA_VERSION,
1451
1689
  claims: sortResourceClaims(claims).map((claim) => toPersistedResourceClaim(claim, this.repository.repositoryId)),
1452
1690
  };
1453
- const contents = `${JSON.stringify(registry, null, 2)}\n`;
1454
- const temporaryPath = `${this.paths.registry}.tmp-${process.pid}-${generateSessionId()}`;
1455
- let descriptor;
1456
1691
  try {
1457
- fs.mkdirSync(this.paths.directory, { recursive: true, mode: 0o700 });
1458
- descriptor = fs.openSync(temporaryPath, "wx", 0o600);
1459
- fs.writeFileSync(descriptor, contents, "utf8");
1460
- fs.fsyncSync(descriptor);
1461
- fs.closeSync(descriptor);
1462
- descriptor = undefined;
1463
- fs.renameSync(temporaryPath, this.paths.registry);
1464
- syncDirectory(this.paths.directory);
1692
+ writeJsonAtomicallySync(this.paths.registry, registry);
1465
1693
  }
1466
1694
  catch (error) {
1467
- if (descriptor !== undefined) {
1468
- closeQuietly(descriptor);
1695
+ if (isPostRenameFailure(error)) {
1696
+ throw new SessionRegistryError("REGISTRY_DURABILITY_UNCERTAIN", `Registry rename to ${this.paths.registry} may have already committed, but durable persistence could not be proven`, {
1697
+ path: this.paths.registry,
1698
+ recoveryHints: [
1699
+ "Re-read the registry to check whether the mutation is already visible before retrying.",
1700
+ "Do not assume this operation did not happen.",
1701
+ ],
1702
+ }, error);
1469
1703
  }
1470
- unlinkQuietly(temporaryPath);
1471
1704
  throw new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not atomically write ${this.paths.registry}`, {
1472
1705
  path: this.paths.registry,
1473
1706
  }, error);
@@ -1676,7 +1909,7 @@ function canonicalOperationResources(resources, worktreePath) {
1676
1909
  });
1677
1910
  }
1678
1911
  try {
1679
- canonical.add(canonicalizeClaimResource(resource, worktreePath));
1912
+ canonical.add(canonicalizeConcretePath(resource, worktreePath));
1680
1913
  }
1681
1914
  catch (error) {
1682
1915
  if (error instanceof SessionRegistryError &&
@@ -1702,13 +1935,28 @@ function operationSessionId(first, second) {
1702
1935
  function operationResources(resources) {
1703
1936
  return resources === undefined ? [] : [...resources];
1704
1937
  }
1705
- function assertFinalCommitMessage(message) {
1938
+ function assertFinalCommitMessage(message, messagePattern) {
1706
1939
  if (typeof message !== "string" ||
1707
1940
  message.length === 0 ||
1708
1941
  message.trim().length === 0 ||
1709
1942
  message.includes("\u0000")) {
1710
1943
  throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message must be a non-empty final message without NUL bytes");
1711
1944
  }
1945
+ if (messagePattern === null)
1946
+ return;
1947
+ if (messagePattern.length > MAX_MESSAGE_PATTERN_LENGTH) {
1948
+ throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "The caller-declared commit-message pattern exceeds the maximum bounded length", { reason: "message-pattern-too-long", maxMessagePatternLength: MAX_MESSAGE_PATTERN_LENGTH });
1949
+ }
1950
+ let pattern;
1951
+ try {
1952
+ pattern = new RegExp(messagePattern, "u");
1953
+ }
1954
+ catch (error) {
1955
+ throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "The caller-declared commit-message pattern is not a valid regular expression", { reason: "invalid-message-pattern", messagePattern }, error);
1956
+ }
1957
+ if (!pattern.test(message)) {
1958
+ throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message does not match the caller-declared commit-message pattern", { reason: "message-pattern-mismatch", messagePattern, message });
1959
+ }
1712
1960
  }
1713
1961
  function explicitRemote(remote) {
1714
1962
  if (typeof remote !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(remote)) {
@@ -1740,25 +1988,6 @@ function explicitRemoteBranch(branch, alias) {
1740
1988
  }, error);
1741
1989
  }
1742
1990
  }
1743
- function observeMutationPaths(git, worktreePath) {
1744
- const observed = captureGitCheckpoint(git, worktreePath);
1745
- return Object.freeze({
1746
- changed: Object.freeze(canonicalMutationPaths(observed.changed, worktreePath)),
1747
- staged: Object.freeze(canonicalMutationPaths(observed.staged, worktreePath)),
1748
- });
1749
- }
1750
- function canonicalMutationPaths(paths, worktreePath) {
1751
- const canonical = new Set();
1752
- for (const resource of paths) {
1753
- try {
1754
- canonical.add(canonicalizeClaimResource(resource, worktreePath));
1755
- }
1756
- catch (error) {
1757
- throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported a path that cannot be represented as a canonical repository resource", { path: resource }, error);
1758
- }
1759
- }
1760
- return sortStrings(canonical);
1761
- }
1762
1991
  function assertNoUnexpectedMutationPaths(paths, authorized) {
1763
1992
  const authorizedSet = new Set(authorized);
1764
1993
  const unexpected = paths.filter((resource) => !authorizedSet.has(resource));
@@ -1812,7 +2041,8 @@ function runMutationGit(git, args, cwd, phase, failureCode) {
1812
2041
  throw new SessionRegistryError(failureCode, `${phase} Git operation failed`, details, error);
1813
2042
  }
1814
2043
  }
1815
- function inspectPushTarget(git, cwd, remote, branch) {
2044
+ function inspectPushTarget(git, cwd, remote, branch, sourceSha) {
2045
+ const targetRef = `refs/heads/${branch}`;
1816
2046
  try {
1817
2047
  git.run(["remote", "get-url", remote], cwd);
1818
2048
  }
@@ -1821,7 +2051,7 @@ function inspectPushTarget(git, cwd, remote, branch) {
1821
2051
  }
1822
2052
  let remoteTargetOutput;
1823
2053
  try {
1824
- remoteTargetOutput = git.run(["ls-remote", "--heads", remote, `refs/heads/${branch}`], cwd);
2054
+ remoteTargetOutput = git.run(["ls-remote", "--heads", remote, targetRef], cwd);
1825
2055
  }
1826
2056
  catch (error) {
1827
2057
  throwRemoteInspectionFailure(error, "ls-remote");
@@ -1847,7 +2077,13 @@ function inspectPushTarget(git, cwd, remote, branch) {
1847
2077
  if (upstream === undefined) {
1848
2078
  // The absence of an upstream is itself a first-class relation. A remote
1849
2079
  // branch may exist, but it must not silently become tracking state.
1850
- return { relation: "no-upstream", upstream: undefined };
2080
+ return {
2081
+ sourceSha,
2082
+ relation: "no-upstream",
2083
+ upstream: undefined,
2084
+ targetRef,
2085
+ observedRemoteSha: remoteTargetExists ? extractRemoteSha(remoteTargetOutput, remote, branch) : null,
2086
+ };
1851
2087
  }
1852
2088
  const separator = upstream.indexOf("/");
1853
2089
  if (separator <= 0 || separator === upstream.length - 1) {
@@ -1870,19 +2106,57 @@ function inspectPushTarget(git, cwd, remote, branch) {
1870
2106
  });
1871
2107
  }
1872
2108
  const remoteSha = remoteShaMatch[1];
1873
- let headSha;
2109
+ const temporaryRef = `refs/nawabari/push-inspection/${remoteSha}`;
2110
+ let relationOutput;
2111
+ let inspectionError;
1874
2112
  try {
1875
- headSha = git.run(["rev-parse", "HEAD"], cwd).trim();
2113
+ try {
2114
+ // Fetch only the explicitly inspected remote branch into a disposable
2115
+ // ref. This obtains missing ancestry without mutating tracking refs or
2116
+ // fetching unrelated branches/tags.
2117
+ // Requires Git 2.29 or later for --no-write-fetch-head support.
2118
+ // Unsupported Git versions will fail here and surface as
2119
+ // PUSH_REMOTE_INSPECTION_FAILED via throwRemoteInspectionFailure.
2120
+ git.run(["fetch", "--no-tags", "--no-write-fetch-head", "--refmap=", remote, `+${targetRef}:${temporaryRef}`], cwd);
2121
+ const fetchedSha = git.run(["rev-parse", "--verify", temporaryRef], cwd).trim();
2122
+ if (fetchedSha !== remoteSha) {
2123
+ throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "The remote branch changed while its observed generation was being fetched", {
2124
+ remote,
2125
+ branch,
2126
+ observedRemoteSha: remoteSha,
2127
+ fetchedRemoteSha: fetchedSha,
2128
+ });
2129
+ }
2130
+ }
2131
+ catch (error) {
2132
+ if (error instanceof SessionRegistryError && error.code === "PUSH_REMOTE_INSPECTION_FAILED")
2133
+ throw error;
2134
+ throwRemoteInspectionFailure(error, "remote-generation");
2135
+ }
2136
+ try {
2137
+ relationOutput = git.run(["rev-list", "--left-right", "--count", `${sourceSha}...${temporaryRef}`], cwd);
2138
+ }
2139
+ catch (error) {
2140
+ throwRemoteInspectionFailure(error, "relation");
2141
+ }
1876
2142
  }
1877
2143
  catch (error) {
1878
- throwRemoteInspectionFailure(error, "HEAD");
2144
+ inspectionError = error;
1879
2145
  }
1880
- let relationOutput;
1881
2146
  try {
1882
- relationOutput = git.run(["rev-list", "--left-right", "--count", `HEAD...${remoteSha}`], cwd);
2147
+ git.run(["update-ref", "-d", temporaryRef], cwd);
1883
2148
  }
1884
2149
  catch (error) {
1885
- throwRemoteInspectionFailure(error, "relation");
2150
+ if (inspectionError === undefined)
2151
+ throwRemoteInspectionFailure(error, "remote-generation-cleanup");
2152
+ }
2153
+ if (inspectionError !== undefined)
2154
+ throw inspectionError;
2155
+ if (relationOutput === undefined) {
2156
+ throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Remote ancestry relation was unavailable", {
2157
+ remote,
2158
+ branch,
2159
+ });
1886
2160
  }
1887
2161
  const counts = relationOutput
1888
2162
  .trim()
@@ -1896,7 +2170,14 @@ function inspectPushTarget(git, cwd, remote, branch) {
1896
2170
  }
1897
2171
  const [ahead, behind] = counts;
1898
2172
  const relation = ahead > 0 && behind > 0 ? "diverged" : behind > 0 ? "behind" : ahead > 0 ? "ahead" : "up-to-date";
1899
- return { relation, upstream };
2173
+ return { sourceSha, relation, upstream, targetRef, observedRemoteSha: remoteSha };
2174
+ }
2175
+ function extractRemoteSha(remoteTargetOutput, remote, branch) {
2176
+ const remoteShaMatch = remoteTargetOutput.trim().match(/^([0-9a-f]{40,64})\s+/u);
2177
+ if (remoteShaMatch === null) {
2178
+ throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Could not extract remote SHA from ls-remote output", { remote, branch });
2179
+ }
2180
+ return remoteShaMatch[1];
1900
2181
  }
1901
2182
  function isExpectedMissingRef(error) {
1902
2183
  return (error instanceof SessionRegistryError &&
@@ -1913,35 +2194,18 @@ function throwRemoteInspectionFailure(error, phase) {
1913
2194
  ...(error instanceof SessionRegistryError ? { gitCode: error.code } : {}),
1914
2195
  }, error);
1915
2196
  }
1916
- function canonicalObservedPaths(paths, worktreePath) {
1917
- const canonical = new Set();
1918
- for (const resource of paths) {
1919
- if (typeof resource !== "string" || resource.length === 0) {
1920
- continue;
1921
- }
1922
- try {
1923
- // Git-observed paths are concrete repository-relative paths reported by Git.
1924
- // They may contain literal * or ? characters in filenames (not wildcards).
1925
- // Canonicalize and bounds-check them without rejecting metacharacters.
1926
- // If canonicalization encounters traversal, symlink escape, or invalid
1927
- // syntax, skip the path so it is reported as outOfClaim rather than throwing.
1928
- canonical.add(canonicalizeClaimResource(resource, worktreePath));
1929
- }
1930
- catch (error) {
1931
- // Let observed paths that fail canonicalization appear in the outOfClaim
1932
- // set rather than causing checkpoint to throw. This ensures Git-reported
1933
- // paths with unusual names or structure are observable in evidence.
1934
- if (error instanceof SessionRegistryError) {
1935
- continue;
1936
- }
1937
- throw error;
1938
- }
1939
- }
1940
- return Object.freeze(sortStrings(canonical));
1941
- }
1942
2197
  function sortStrings(values) {
1943
2198
  return [...new Set(values)].sort(compareCodePointStrings);
1944
2199
  }
2200
+ function sameCheckpointPaths(left, right) {
2201
+ return (sameStringArray(left.changed, right.changed) &&
2202
+ sameStringArray(left.staged, right.staged) &&
2203
+ sameStringArray(left.unstaged, right.unstaged) &&
2204
+ sameStringArray(left.untracked, right.untracked));
2205
+ }
2206
+ function sameStringArray(left, right) {
2207
+ return left.length === right.length && left.every((value, index) => value === right[index]);
2208
+ }
1945
2209
  function assertWorktreeClean(git, worktreePath) {
1946
2210
  // Git is authoritative for this decision. --ignored=no means ignored or
1947
2211
  // generated artifacts alone are intentionally absent from the blocker set.
@@ -2263,21 +2527,35 @@ function resolveBaseRef(git, cwd, candidate) {
2263
2527
  candidate !== candidate.trim() ||
2264
2528
  candidate.startsWith("-") ||
2265
2529
  candidate.includes("\u0000")) {
2266
- throw new SessionRegistryError("INVALID_BASE_REF", `Invalid base ref: ${candidate}`, { baseRef: candidate });
2530
+ throw invalidBaseRef(candidate, "invalid-format");
2267
2531
  }
2268
2532
  try {
2269
- git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
2270
- return candidate;
2533
+ const revision = git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
2534
+ if (!isRevision(revision)) {
2535
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid base revision", {
2536
+ baseRef: candidate,
2537
+ revision,
2538
+ });
2539
+ }
2540
+ return { ref: candidate, revision };
2271
2541
  }
2272
2542
  catch (error) {
2273
2543
  if (isExpectedGitLookupFailure(error)) {
2274
- throw new SessionRegistryError("INVALID_BASE_REF", `Base ref does not resolve to a commit: ${candidate}`, {
2275
- baseRef: candidate,
2276
- });
2544
+ throw invalidBaseRef(candidate, "does-not-resolve-to-commit");
2277
2545
  }
2278
2546
  throw error;
2279
2547
  }
2280
2548
  }
2549
+ function invalidBaseRef(candidate, reason) {
2550
+ return new SessionRegistryError("INVALID_BASE_REF", reason === "invalid-format"
2551
+ ? `Invalid base ref: ${candidate}`
2552
+ : `Base ref does not resolve to a commit: ${candidate}`, {
2553
+ baseRef: candidate,
2554
+ reason,
2555
+ defaultBaseRef: "HEAD",
2556
+ recoveryHints: ["Omit --base to use HEAD, then retry session create."],
2557
+ });
2558
+ }
2281
2559
  function isExpectedGitLookupFailure(error) {
2282
2560
  return (error instanceof SessionRegistryError &&
2283
2561
  error.code === "GIT_COMMAND_FAILED" &&
@@ -2320,6 +2598,7 @@ export function toPersistedSessionRecord(record, expectedRepositoryId = record.r
2320
2598
  state: validated.state,
2321
2599
  created_at: validated.createdAt,
2322
2600
  updated_at: validated.updatedAt,
2601
+ ...(validated.baseRevision === undefined ? {} : { base_revision: validated.baseRevision }),
2323
2602
  ...(validated.label === undefined ? {} : { label: validated.label }),
2324
2603
  };
2325
2604
  }
@@ -2337,7 +2616,7 @@ export function toPersistedResourceClaim(claim, expectedRepositoryId = claim.rep
2337
2616
  updated_at: validated.updatedAt,
2338
2617
  };
2339
2618
  }
2340
- function parseRegistry(value, expectedRepositoryId) {
2619
+ function parseRegistry(value, expectedRepositoryId, allowLegacyClaimSchema = false) {
2341
2620
  if (!isRecord(value)) {
2342
2621
  throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry root must be an object");
2343
2622
  }
@@ -2368,17 +2647,27 @@ function parseRegistry(value, expectedRepositoryId) {
2368
2647
  // materialized on the next locked mutation or via migrate().
2369
2648
  return { sessions: records, claims: [], legacyClaimsAbsent: true };
2370
2649
  }
2371
- if (value.claims_schema_version !== RESOURCE_CLAIM_SCHEMA_VERSION) {
2372
- throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version: ${stringifyDetail(value.claims_schema_version)}`, { schemaVersion: value.claims_schema_version });
2650
+ const claimSchemaVersion = value.claims_schema_version;
2651
+ const isLegacyClaimSchema = claimSchemaVersion === LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION;
2652
+ if (claimSchemaVersion !== RESOURCE_CLAIM_SCHEMA_VERSION && !isLegacyClaimSchema) {
2653
+ throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version: ${stringifyDetail(claimSchemaVersion)}`, { schemaVersion: claimSchemaVersion });
2654
+ }
2655
+ if (isLegacyClaimSchema && !allowLegacyClaimSchema) {
2656
+ throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", "Resource claim schema v1 requires explicit migration before use", { schemaVersion: LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION, migrationRequired: true });
2373
2657
  }
2374
2658
  if (!Array.isArray(value.claims)) {
2375
2659
  throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry claims must be an array");
2376
2660
  }
2377
- const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId));
2661
+ const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId, isLegacyClaimSchema ? LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION : undefined));
2378
2662
  validateRegistryClaims(records, claims, expectedRepositoryId);
2379
- return { sessions: records, claims: sortResourceClaims(claims), legacyClaimsAbsent: false };
2663
+ return {
2664
+ sessions: records,
2665
+ claims: sortResourceClaims(claims),
2666
+ legacyClaimsAbsent: false,
2667
+ ...(isLegacyClaimSchema ? { legacyClaimsSchemaVersion: LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION } : {}),
2668
+ };
2380
2669
  }
2381
- function parseResourceClaim(value, index, expectedRepositoryId) {
2670
+ function parseResourceClaim(value, index, expectedRepositoryId, expectedSchemaVersion = RESOURCE_CLAIM_SCHEMA_VERSION) {
2382
2671
  if (!isRecord(value))
2383
2672
  throw invalidRecord(index, "claim must be an object");
2384
2673
  assertExactKeys(value, [
@@ -2392,8 +2681,8 @@ function parseResourceClaim(value, index, expectedRepositoryId) {
2392
2681
  "created_at",
2393
2682
  "updated_at",
2394
2683
  ], [], index);
2395
- if (value.schema_version !== RESOURCE_CLAIM_SCHEMA_VERSION) {
2396
- throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version at index ${index}: ${stringifyDetail(value.schema_version)}`, { schemaVersion: value.schema_version, index });
2684
+ if (value.schema_version !== expectedSchemaVersion) {
2685
+ throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version at index ${index}: ${stringifyDetail(value.schema_version)}`, { schemaVersion: value.schema_version, index, expectedSchemaVersion });
2397
2686
  }
2398
2687
  const claim = {
2399
2688
  schemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
@@ -2505,7 +2794,7 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
2505
2794
  "state",
2506
2795
  "created_at",
2507
2796
  "updated_at",
2508
- ], ["label"], index);
2797
+ ], ["base_revision", "label"], index);
2509
2798
  if (value.schema_version !== REGISTRY_SCHEMA_VERSION) {
2510
2799
  if (typeof value.schema_version === "number") {
2511
2800
  throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported session schema version: ${value.schema_version}`, {
@@ -2526,6 +2815,9 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
2526
2815
  state: requireState(value.state, index),
2527
2816
  createdAt: requireString(value.created_at, index, "created_at"),
2528
2817
  updatedAt: requireString(value.updated_at, index, "updated_at"),
2818
+ ...(value.base_revision === undefined
2819
+ ? {}
2820
+ : { baseRevision: requireRevision(value.base_revision, index, "base_revision") }),
2529
2821
  ...(value.label === undefined ? {} : { label: requireString(value.label, index, "label") }),
2530
2822
  };
2531
2823
  return validateSessionRecord(record, expectedRepositoryId, index);
@@ -2571,6 +2863,9 @@ function validateSessionRecord(record, expectedRepositoryId, index) {
2571
2863
  if (Date.parse(record.updatedAt) < Date.parse(record.createdAt)) {
2572
2864
  throw invalidRecord(index, "updated_at cannot precede created_at");
2573
2865
  }
2866
+ if (record.baseRevision !== undefined && !isRevision(record.baseRevision)) {
2867
+ throw invalidRecord(index, "base_revision must be a full hexadecimal Git revision");
2868
+ }
2574
2869
  if (record.label !== undefined && (typeof record.label !== "string" || record.label.length === 0)) {
2575
2870
  throw invalidRecord(index, "label must be a non-empty string");
2576
2871
  }
@@ -2660,6 +2955,16 @@ function requireString(value, index, field) {
2660
2955
  }
2661
2956
  return value;
2662
2957
  }
2958
+ function requireRevision(value, index, field) {
2959
+ const revision = requireString(value, index, field);
2960
+ if (!isRevision(revision)) {
2961
+ throw invalidRecord(index, `${field} must be a full hexadecimal Git revision`);
2962
+ }
2963
+ return revision;
2964
+ }
2965
+ function isRevision(value) {
2966
+ return /^[0-9a-f]{40,64}$/u.test(value);
2967
+ }
2663
2968
  function requireState(value, index) {
2664
2969
  if (typeof value !== "string" || !SESSION_STATES.has(value)) {
2665
2970
  throw invalidRecord(index, `state is invalid: ${stringifyDetail(value)}`);
@@ -2719,36 +3024,4 @@ function toSessionRegistryLockError(error, lockPath) {
2719
3024
  }
2720
3025
  return new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not operate on ${lockPath}`, { path: lockPath }, error);
2721
3026
  }
2722
- function closeQuietly(descriptor) {
2723
- try {
2724
- fs.closeSync(descriptor);
2725
- }
2726
- catch {
2727
- // The original operation's error is more useful than a best-effort close error.
2728
- }
2729
- }
2730
- function unlinkQuietly(filePath) {
2731
- try {
2732
- fs.unlinkSync(filePath);
2733
- }
2734
- catch {
2735
- // A missing temporary file is already the desired state.
2736
- }
2737
- }
2738
- function syncDirectory(directory) {
2739
- let descriptor;
2740
- try {
2741
- descriptor = fs.openSync(directory, "r");
2742
- fs.fsyncSync(descriptor);
2743
- }
2744
- catch {
2745
- // Directory fsync is not available on every supported filesystem. The
2746
- // file itself was fsynced before rename, so continue on that limitation.
2747
- }
2748
- finally {
2749
- if (descriptor !== undefined) {
2750
- closeQuietly(descriptor);
2751
- }
2752
- }
2753
- }
2754
3027
  //# sourceMappingURL=session-registry.js.map