nawabari 0.2.0 → 0.3.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,11 +1,13 @@
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
6
  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";
7
+ 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
8
  import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
9
+ import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, evidenceHash, } from "./repository-evidence.js";
10
+ 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
11
  export const REGISTRY_SCHEMA_VERSION = 1;
10
12
  export { RESOURCE_CLAIM_SCHEMA_VERSION };
11
13
  export const REGISTRY_DIRECTORY_NAME = "nawabari";
@@ -15,6 +17,8 @@ export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
15
17
  export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
16
18
  export const RECONCILIATION_SCHEMA_VERSION = 1;
17
19
  const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
20
+ /** Bounds a caller-declared commit-message pattern before it is compiled. */
21
+ export const MAX_MESSAGE_PATTERN_LENGTH = 512;
18
22
  export { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, OPERATION_REQUIRED_ACCESS, OPERATION_VOCABULARY, } from "./operation-authorization.js";
19
23
  export const GOVERNED_GIT_OPERATION_SCHEMA_VERSION = 1;
20
24
  const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
@@ -75,6 +79,10 @@ export class SessionRegistry {
75
79
  metadataGraceMs: this.lockMetadataGraceMs,
76
80
  });
77
81
  }
82
+ /** Resolved repository-local root used for default managed session worktrees. */
83
+ get managedWorktreeRoot() {
84
+ return this.worktreeRoot;
85
+ }
78
86
  read() {
79
87
  return this.readUnsafe().map(cloneSessionRecord);
80
88
  }
@@ -105,16 +113,18 @@ export class SessionRegistry {
105
113
  return claim === undefined ? undefined : cloneResourceClaim(claim);
106
114
  }
107
115
  /**
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.
116
+ * Explicitly materialize a claim section or upgrade its semantics. A v1
117
+ * claim registry is intentionally unreadable through ordinary operations;
118
+ * only this locked, explicit migration rewrites it as v2.
110
119
  */
111
120
  migrate() {
112
121
  return this.withLock(() => {
113
- const state = this.readStateUnsafe();
114
- if (state.legacyClaimsAbsent)
122
+ const state = this.readStateUnsafe(true);
123
+ const migrated = state.legacyClaimsAbsent || state.legacyClaimsSchemaVersion !== undefined;
124
+ if (migrated)
115
125
  this.writeUnsafe(state.sessions, state.claims);
116
126
  return {
117
- migrated: state.legacyClaimsAbsent,
127
+ migrated,
118
128
  registrySchemaVersion: REGISTRY_SCHEMA_VERSION,
119
129
  claimSchemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
120
130
  };
@@ -248,6 +258,7 @@ export class SessionRegistry {
248
258
  state: "active",
249
259
  createdAt: timestamp,
250
260
  updatedAt: timestamp,
261
+ baseRevision: resources.baseRevision,
251
262
  ...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
252
263
  });
253
264
  assertNoOwnershipConflict(records, record);
@@ -282,13 +293,14 @@ export class SessionRegistry {
282
293
  state: "active",
283
294
  createdAt: timestamp,
284
295
  updatedAt: timestamp,
296
+ baseRevision: resources.baseRevision,
285
297
  ...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
286
298
  });
287
299
  assertNoOwnershipConflict(state.sessions, record);
288
300
  assertGitResourcesAvailable(this.git, this.repository.worktreePath, resources);
289
301
  let gitProvisioned = false;
290
302
  try {
291
- this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRef], this.repository.worktreePath);
303
+ this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRevision], this.repository.worktreePath);
292
304
  gitProvisioned = true;
293
305
  verifyPhysicalExecutionContext({
294
306
  repository: this.repository,
@@ -359,6 +371,36 @@ export class SessionRegistry {
359
371
  const session = this.resolveSessionOwnership(physical, options.sessionId ?? null);
360
372
  return Object.freeze({ ...physical, session: cloneSessionRecord(session) });
361
373
  }
374
+ /** Resolve and physically verify an explicitly addressed active session. */
375
+ verifyEvidenceSession(sessionId) {
376
+ assertSessionId(sessionId);
377
+ const record = this.readUnsafe().find((candidate) => candidate.sessionId === sessionId);
378
+ if (record === undefined || record.state === "closed") {
379
+ throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
380
+ }
381
+ if (record.state !== "active") {
382
+ throw new SessionRegistryError("STALE_REGISTRY", `Session is not active: ${sessionId}`, {
383
+ sessionId,
384
+ state: record.state,
385
+ });
386
+ }
387
+ const physical = verifyPhysicalExecutionContext({
388
+ repository: this.repository,
389
+ worktreePath: record.worktreePath,
390
+ branchName: record.branchName,
391
+ git: this.git,
392
+ });
393
+ if (physical.worktreeId !== record.worktreeId || physical.branchId !== record.branchId) {
394
+ throw new SessionRegistryError("OWNERSHIP_MISMATCH", "The selected session does not own the observed worktree", {
395
+ sessionId,
396
+ expectedWorktree: record.worktreePath,
397
+ actualWorktree: physical.worktreePath,
398
+ expectedBranch: record.branchName,
399
+ actualBranch: physical.branchName,
400
+ });
401
+ }
402
+ return Object.freeze({ session: cloneSessionRecord(record), physical });
403
+ }
362
404
  /** Shared physical/protected/ownership path for every governed decision. */
363
405
  verifyGovernedExecutionContext(requestedSessionId, observedPhysical) {
364
406
  const physical = observedPhysical ??
@@ -594,6 +636,14 @@ export class SessionRegistry {
594
636
  ownerMode: conflictingClaim.mode,
595
637
  });
596
638
  }
639
+ if (ownClaims.length > 0) {
640
+ return deniedOperation("INSUFFICIENT_CLAIM_MODE", verified, {
641
+ resource,
642
+ requiredAccess,
643
+ grantedModes: sortStrings(new Set(ownClaims.map((claim) => claim.mode))),
644
+ sessionId: currentRecord.sessionId,
645
+ });
646
+ }
597
647
  return deniedOperation("MISSING_RESOURCE_CLAIM", verified, {
598
648
  resource,
599
649
  requiredAccess,
@@ -634,13 +684,17 @@ export class SessionRegistry {
634
684
  * ownership or mutation authority.
635
685
  */
636
686
  commit(options) {
687
+ // Validated before the repository lock is acquired: a pathological
688
+ // caller-declared pattern must not hold the cross-session repository
689
+ // lock hostage while it backtracks.
690
+ const messagePattern = options.messagePattern ?? options.message_pattern ?? null;
691
+ assertFinalCommitMessage(options.message, messagePattern);
637
692
  return this.withLock(() => {
638
693
  const sessionId = operationSessionId(options.sessionId, options.session_id);
639
694
  const requestedResources = operationResources(options.resources ?? options.paths);
640
695
  const authorization = this.requireMutationAuthorization("commit", requestedResources, sessionId);
641
696
  const initial = this.verifyGovernedExecutionContext(sessionId);
642
- assertFinalCommitMessage(options.message);
643
- const before = observeMutationPaths(this.git, initial.worktreePath);
697
+ const before = observeGitMutationPaths(this.git, initial.worktreePath);
644
698
  assertNoUnexpectedMutationPaths(before.changed, authorization.resources.map((resource) => resource.resource));
645
699
  const stageResources = authorization.resources
646
700
  .map((resource) => resource.resource)
@@ -652,7 +706,7 @@ export class SessionRegistry {
652
706
  }
653
707
  reverifyMutationContext(this, initial, sessionId, "stage");
654
708
  runMutationGit(this.git, ["add", "--", ...stageResources], initial.worktreePath, "stage", "COMMIT_STAGING_FAILED");
655
- const afterStage = observeMutationPaths(this.git, initial.worktreePath);
709
+ const afterStage = observeGitMutationPaths(this.git, initial.worktreePath);
656
710
  assertNoUnexpectedMutationPaths(afterStage.changed, stageResources);
657
711
  if (!afterStage.staged.some((resource) => stageResources.includes(resource))) {
658
712
  throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Git staging produced no authorized staged paths", {
@@ -660,7 +714,7 @@ export class SessionRegistry {
660
714
  });
661
715
  }
662
716
  reverifyMutationContext(this, initial, sessionId, "commit");
663
- const beforeCommit = observeMutationPaths(this.git, initial.worktreePath);
717
+ const beforeCommit = observeGitMutationPaths(this.git, initial.worktreePath);
664
718
  assertNoUnexpectedMutationPaths(beforeCommit.changed, stageResources);
665
719
  if (!beforeCommit.staged.some((resource) => stageResources.includes(resource))) {
666
720
  throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Authorized staged paths disappeared before commit", {
@@ -677,11 +731,34 @@ export class SessionRegistry {
677
731
  throw error;
678
732
  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
733
  }
734
+ // The Git commit succeeded and its SHA is known; every failure from here
735
+ // must retain commitSha so the caller can recover/reconcile the result
736
+ // instead of losing track of a commit that actually happened.
737
+ let committedPaths;
738
+ try {
739
+ committedPaths = readCanonicalCommitChangedPaths(this.git, initial.worktreePath, commitSha);
740
+ }
741
+ catch (error) {
742
+ if (error instanceof SessionRegistryError && isBoundedGitFailure(error.code)) {
743
+ throw new SessionRegistryError(error.code, error.message, { ...error.details, commitSha }, error);
744
+ }
745
+ 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);
746
+ }
747
+ const authorizedSet = new Set(stageResources);
748
+ const divergentResources = committedPaths.filter((resource) => !authorizedSet.has(resource));
749
+ if (divergentResources.length > 0) {
750
+ throw new SessionRegistryError("COMMIT_RESULT_DIVERGED", "The resulting commit contains paths outside the authorized/staged resource set", {
751
+ commitSha,
752
+ authorizedResources: [...stageResources],
753
+ committedResources: [...committedPaths],
754
+ divergentResources,
755
+ });
756
+ }
680
757
  return Object.freeze({
681
758
  schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
682
759
  commitSha,
683
760
  message: options.message,
684
- resources: Object.freeze([...stageResources]),
761
+ resources: committedPaths,
685
762
  });
686
763
  });
687
764
  }
@@ -705,7 +782,7 @@ export class SessionRegistry {
705
782
  pushBranch: branch,
706
783
  });
707
784
  }
708
- const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch);
785
+ const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch, initial.headId);
709
786
  if (inspection.upstream !== undefined && inspection.upstream !== `${remote}/${branch}`) {
710
787
  throw new SessionRegistryError("PUSH_TARGET_MISMATCH", "The explicit push target does not match the currently configured upstream", { upstream: inspection.upstream, remote, branch });
711
788
  }
@@ -719,32 +796,44 @@ export class SessionRegistry {
719
796
  const code = inspection.relation === "behind" ? "PUSH_BEHIND" : "PUSH_DIVERGED";
720
797
  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
798
  }
722
- const dirtyBefore = observeMutationPaths(this.git, initial.worktreePath);
799
+ const dirtyBefore = observeGitMutationPaths(this.git, initial.worktreePath);
723
800
  if (dirtyBefore.changed.length > 0) {
724
801
  throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "Push requires a clean Git worktree", {
725
802
  paths: [...dirtyBefore.changed],
726
803
  });
727
804
  }
728
- reverifyMutationContext(this, initial, sessionId, "push");
729
- const dirtyBeforePush = observeMutationPaths(this.git, initial.worktreePath);
805
+ const finalContext = reverifyMutationContext(this, initial, sessionId, "push");
806
+ const dirtyBeforePush = observeGitMutationPaths(this.git, initial.worktreePath);
730
807
  if (dirtyBeforePush.changed.length > 0) {
731
808
  throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "The worktree changed before push mutation", {
732
809
  paths: [...dirtyBeforePush.changed],
733
810
  });
734
811
  }
812
+ const targetRef = `refs/heads/${branch}`;
813
+ const leaseValue = inspection.observedRemoteSha ?? "";
735
814
  const pushArguments = [
736
815
  "push",
737
- ...(force ? ["--force-with-lease"] : []),
816
+ ...(force ? [`--force-with-lease=${targetRef}:${leaseValue}`] : ["--no-force"]),
738
817
  ...(createUpstream ? ["--set-upstream"] : []),
739
818
  remote,
740
- `HEAD:refs/heads/${branch}`,
819
+ `${finalContext.headId}:${targetRef}`,
741
820
  ];
742
821
  runMutationGit(this.git, pushArguments, initial.worktreePath, "push", "PUSH_FAILED");
822
+ if (createUpstream) {
823
+ // A SHA:ref refspec is intentional: it cannot be redirected by a
824
+ // mutable symbolic HEAD. Git does not consistently infer the current
825
+ // branch's tracking configuration from a raw SHA source, so complete
826
+ // the explicitly requested local upstream setup after the push.
827
+ runMutationGit(this.git, ["branch", "--set-upstream-to", `${remote}/${branch}`, branch], initial.worktreePath, "set-upstream", "PUSH_FAILED");
828
+ }
743
829
  return Object.freeze({
744
830
  schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
831
+ sourceSha: finalContext.headId,
745
832
  remote,
746
833
  branch,
747
834
  target: `${remote}/${branch}`,
835
+ targetRef,
836
+ observedRemoteSha: inspection.observedRemoteSha,
748
837
  relation: inspection.relation,
749
838
  force,
750
839
  upstreamCreated: createUpstream,
@@ -776,13 +865,7 @@ export class SessionRegistry {
776
865
  const execution = this.verifyGovernedExecutionContext(requestedSessionId);
777
866
  const physical = execution;
778
867
  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
- };
868
+ const paths = observeGitCheckpoint(this.git, physical.worktreePath);
786
869
  const state = this.readStateUnsafe();
787
870
  const activeClaims = state.claims.filter((claim) => claim.sessionId === session.sessionId &&
788
871
  state.sessions.some((record) => record.sessionId === claim.sessionId && record.state === "active"));
@@ -810,6 +893,122 @@ export class SessionRegistry {
810
893
  captureCheckpoint(options = {}) {
811
894
  return this.checkpoint(options);
812
895
  }
896
+ /**
897
+ * Capture one deterministic, read-only physical repository generation for
898
+ * an explicitly addressed owned session.
899
+ */
900
+ repositoryEvidence(options) {
901
+ return this.withLock(() => {
902
+ const initial = this.verifyEvidenceSession(options.sessionId);
903
+ const paths = observeGitCheckpoint(this.git, initial.physical.worktreePath);
904
+ const stats = paths.changed.length === 0
905
+ ? []
906
+ : readGitPathStats(this.git, initial.physical.worktreePath, paths.changed, initial.physical.headId);
907
+ const final = this.verifyEvidenceSession(options.sessionId);
908
+ if (final.physical.branchName !== initial.physical.branchName ||
909
+ final.physical.headId !== initial.physical.headId ||
910
+ !sameCheckpointPaths(paths, observeGitCheckpoint(this.git, final.physical.worktreePath))) {
911
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository evidence changed during physical observation", {
912
+ sessionId: options.sessionId,
913
+ worktree: initial.physical.worktreePath,
914
+ });
915
+ }
916
+ const baseRevision = initial.session.baseRevision ?? null;
917
+ const incompleteReasons = stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : [];
918
+ const pathsEvidence = Object.freeze({
919
+ changed: paths.changed,
920
+ staged: paths.staged,
921
+ unstaged: paths.unstaged,
922
+ untracked: paths.untracked,
923
+ stats: Object.freeze([...stats]),
924
+ });
925
+ const snapshotWithoutHash = {
926
+ schemaVersion: 1,
927
+ source: "git",
928
+ guarantee: "git-observable-only",
929
+ repositoryId: initial.physical.repositoryId,
930
+ worktreePath: initial.physical.worktreePath,
931
+ branchId: initial.physical.branchId,
932
+ branchName: initial.physical.branchName,
933
+ sessionId: initial.session.sessionId,
934
+ sessionState: initial.session.state,
935
+ sessionCreatedAt: initial.session.createdAt,
936
+ sessionUpdatedAt: initial.session.updatedAt,
937
+ baseRevision,
938
+ baseRevisionProven: baseRevision !== null,
939
+ headId: initial.physical.headId,
940
+ clean: paths.changed.length === 0,
941
+ complete: incompleteReasons.length === 0,
942
+ incompleteReasons: Object.freeze(incompleteReasons),
943
+ paths: pathsEvidence,
944
+ bounds: Object.freeze({
945
+ maxPaths: CHECKPOINT_MAX_PATHS,
946
+ maxDiffPaths: EVIDENCE_MAX_DIFF_PATHS,
947
+ maxDiffBytes: EVIDENCE_MAX_DIFF_BYTES,
948
+ maxDiffHunks: EVIDENCE_MAX_DIFF_HUNKS,
949
+ }),
950
+ };
951
+ return Object.freeze({
952
+ ...snapshotWithoutHash,
953
+ evidenceHash: evidenceHash(snapshotWithoutHash),
954
+ });
955
+ });
956
+ }
957
+ evidenceSnapshot(options) {
958
+ return this.repositoryEvidence(options);
959
+ }
960
+ /** Inspect only explicitly selected paths with bounded stat/patch output. */
961
+ repositoryDiff(options) {
962
+ return this.withLock(() => {
963
+ const initial = this.verifyEvidenceSession(options.sessionId);
964
+ const diff = readBoundedGitDiff(this.git, initial.physical.worktreePath, {
965
+ paths: options.paths,
966
+ from: options.from,
967
+ to: options.to,
968
+ includePatch: options.includePatch,
969
+ maxBytes: options.maxBytes,
970
+ maxHunks: options.maxHunks,
971
+ });
972
+ const final = this.verifyEvidenceSession(options.sessionId);
973
+ if (final.physical.branchName !== initial.physical.branchName ||
974
+ final.physical.headId !== initial.physical.headId) {
975
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository diff changed during physical observation", {
976
+ sessionId: options.sessionId,
977
+ worktree: initial.physical.worktreePath,
978
+ });
979
+ }
980
+ const diffWithoutHash = {
981
+ schemaVersion: diff.schemaVersion,
982
+ source: "git",
983
+ guarantee: "git-observable-only",
984
+ repositoryId: initial.physical.repositoryId,
985
+ worktreePath: initial.physical.worktreePath,
986
+ branchId: initial.physical.branchId,
987
+ branchName: initial.physical.branchName,
988
+ sessionId: initial.session.sessionId,
989
+ sessionState: initial.session.state,
990
+ headId: initial.physical.headId,
991
+ fromRevision: diff.fromRevision,
992
+ toRevision: diff.toRevision,
993
+ paths: diff.paths,
994
+ stats: diff.stats,
995
+ complete: diff.stats.every((stat) => stat.available),
996
+ incompleteReasons: Object.freeze(diff.stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : []),
997
+ patch: diff.patch,
998
+ patchBytes: diff.patchBytes,
999
+ hunkCount: diff.hunkCount,
1000
+ maxBytes: options.maxBytes ?? EVIDENCE_MAX_DIFF_BYTES,
1001
+ maxHunks: options.maxHunks ?? EVIDENCE_MAX_DIFF_HUNKS,
1002
+ };
1003
+ return Object.freeze({
1004
+ ...diffWithoutHash,
1005
+ evidenceHash: evidenceHash(diffWithoutHash),
1006
+ });
1007
+ });
1008
+ }
1009
+ diff(options) {
1010
+ return this.repositoryDiff(options);
1011
+ }
813
1012
  /**
814
1013
  * Produce the authoritative, non-mutating cleanup decision for one session.
815
1014
  * The decision observes registry claims and current Git/filesystem state in
@@ -1393,12 +1592,13 @@ export class SessionRegistry {
1393
1592
  branch: shortBranchName,
1394
1593
  });
1395
1594
  }
1396
- const baseRef = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
1595
+ const base = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
1397
1596
  return {
1398
1597
  worktreePath: requestedWorktreePath,
1399
1598
  branchId,
1400
1599
  branchName: shortBranchName,
1401
- baseRef,
1600
+ baseRef: base.ref,
1601
+ baseRevision: base.revision,
1402
1602
  };
1403
1603
  }
1404
1604
  resolveCreationResources(options) {
@@ -1413,9 +1613,10 @@ export class SessionRegistry {
1413
1613
  worktreePath: physical.worktreePath,
1414
1614
  branchId: physical.branchId,
1415
1615
  branchName: physical.branchName,
1616
+ baseRevision: physical.headId,
1416
1617
  };
1417
1618
  }
1418
- readStateUnsafe() {
1619
+ readStateUnsafe(allowLegacyClaimSchema = false) {
1419
1620
  let contents;
1420
1621
  try {
1421
1622
  contents = fs.readFileSync(this.paths.registry, "utf8");
@@ -1437,7 +1638,7 @@ export class SessionRegistry {
1437
1638
  path: this.paths.registry,
1438
1639
  }, error);
1439
1640
  }
1440
- return parseRegistry(parsed, this.repository.repositoryId);
1641
+ return parseRegistry(parsed, this.repository.repositoryId, allowLegacyClaimSchema);
1441
1642
  }
1442
1643
  readUnsafe() {
1443
1644
  return this.readStateUnsafe().sessions;
@@ -1676,7 +1877,7 @@ function canonicalOperationResources(resources, worktreePath) {
1676
1877
  });
1677
1878
  }
1678
1879
  try {
1679
- canonical.add(canonicalizeClaimResource(resource, worktreePath));
1880
+ canonical.add(canonicalizeConcretePath(resource, worktreePath));
1680
1881
  }
1681
1882
  catch (error) {
1682
1883
  if (error instanceof SessionRegistryError &&
@@ -1702,13 +1903,28 @@ function operationSessionId(first, second) {
1702
1903
  function operationResources(resources) {
1703
1904
  return resources === undefined ? [] : [...resources];
1704
1905
  }
1705
- function assertFinalCommitMessage(message) {
1906
+ function assertFinalCommitMessage(message, messagePattern) {
1706
1907
  if (typeof message !== "string" ||
1707
1908
  message.length === 0 ||
1708
1909
  message.trim().length === 0 ||
1709
1910
  message.includes("\u0000")) {
1710
1911
  throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message must be a non-empty final message without NUL bytes");
1711
1912
  }
1913
+ if (messagePattern === null)
1914
+ return;
1915
+ if (messagePattern.length > MAX_MESSAGE_PATTERN_LENGTH) {
1916
+ 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 });
1917
+ }
1918
+ let pattern;
1919
+ try {
1920
+ pattern = new RegExp(messagePattern, "u");
1921
+ }
1922
+ catch (error) {
1923
+ throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "The caller-declared commit-message pattern is not a valid regular expression", { reason: "invalid-message-pattern", messagePattern }, error);
1924
+ }
1925
+ if (!pattern.test(message)) {
1926
+ throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message does not match the caller-declared commit-message pattern", { reason: "message-pattern-mismatch", messagePattern, message });
1927
+ }
1712
1928
  }
1713
1929
  function explicitRemote(remote) {
1714
1930
  if (typeof remote !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(remote)) {
@@ -1740,25 +1956,6 @@ function explicitRemoteBranch(branch, alias) {
1740
1956
  }, error);
1741
1957
  }
1742
1958
  }
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
1959
  function assertNoUnexpectedMutationPaths(paths, authorized) {
1763
1960
  const authorizedSet = new Set(authorized);
1764
1961
  const unexpected = paths.filter((resource) => !authorizedSet.has(resource));
@@ -1812,7 +2009,8 @@ function runMutationGit(git, args, cwd, phase, failureCode) {
1812
2009
  throw new SessionRegistryError(failureCode, `${phase} Git operation failed`, details, error);
1813
2010
  }
1814
2011
  }
1815
- function inspectPushTarget(git, cwd, remote, branch) {
2012
+ function inspectPushTarget(git, cwd, remote, branch, sourceSha) {
2013
+ const targetRef = `refs/heads/${branch}`;
1816
2014
  try {
1817
2015
  git.run(["remote", "get-url", remote], cwd);
1818
2016
  }
@@ -1821,7 +2019,7 @@ function inspectPushTarget(git, cwd, remote, branch) {
1821
2019
  }
1822
2020
  let remoteTargetOutput;
1823
2021
  try {
1824
- remoteTargetOutput = git.run(["ls-remote", "--heads", remote, `refs/heads/${branch}`], cwd);
2022
+ remoteTargetOutput = git.run(["ls-remote", "--heads", remote, targetRef], cwd);
1825
2023
  }
1826
2024
  catch (error) {
1827
2025
  throwRemoteInspectionFailure(error, "ls-remote");
@@ -1847,7 +2045,13 @@ function inspectPushTarget(git, cwd, remote, branch) {
1847
2045
  if (upstream === undefined) {
1848
2046
  // The absence of an upstream is itself a first-class relation. A remote
1849
2047
  // branch may exist, but it must not silently become tracking state.
1850
- return { relation: "no-upstream", upstream: undefined };
2048
+ return {
2049
+ sourceSha,
2050
+ relation: "no-upstream",
2051
+ upstream: undefined,
2052
+ targetRef,
2053
+ observedRemoteSha: remoteTargetExists ? extractRemoteSha(remoteTargetOutput, remote, branch) : null,
2054
+ };
1851
2055
  }
1852
2056
  const separator = upstream.indexOf("/");
1853
2057
  if (separator <= 0 || separator === upstream.length - 1) {
@@ -1870,19 +2074,57 @@ function inspectPushTarget(git, cwd, remote, branch) {
1870
2074
  });
1871
2075
  }
1872
2076
  const remoteSha = remoteShaMatch[1];
1873
- let headSha;
2077
+ const temporaryRef = `refs/nawabari/push-inspection/${remoteSha}`;
2078
+ let relationOutput;
2079
+ let inspectionError;
1874
2080
  try {
1875
- headSha = git.run(["rev-parse", "HEAD"], cwd).trim();
2081
+ try {
2082
+ // Fetch only the explicitly inspected remote branch into a disposable
2083
+ // ref. This obtains missing ancestry without mutating tracking refs or
2084
+ // fetching unrelated branches/tags.
2085
+ // Requires Git 2.29 or later for --no-write-fetch-head support.
2086
+ // Unsupported Git versions will fail here and surface as
2087
+ // PUSH_REMOTE_INSPECTION_FAILED via throwRemoteInspectionFailure.
2088
+ git.run(["fetch", "--no-tags", "--no-write-fetch-head", "--refmap=", remote, `+${targetRef}:${temporaryRef}`], cwd);
2089
+ const fetchedSha = git.run(["rev-parse", "--verify", temporaryRef], cwd).trim();
2090
+ if (fetchedSha !== remoteSha) {
2091
+ throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "The remote branch changed while its observed generation was being fetched", {
2092
+ remote,
2093
+ branch,
2094
+ observedRemoteSha: remoteSha,
2095
+ fetchedRemoteSha: fetchedSha,
2096
+ });
2097
+ }
2098
+ }
2099
+ catch (error) {
2100
+ if (error instanceof SessionRegistryError && error.code === "PUSH_REMOTE_INSPECTION_FAILED")
2101
+ throw error;
2102
+ throwRemoteInspectionFailure(error, "remote-generation");
2103
+ }
2104
+ try {
2105
+ relationOutput = git.run(["rev-list", "--left-right", "--count", `${sourceSha}...${temporaryRef}`], cwd);
2106
+ }
2107
+ catch (error) {
2108
+ throwRemoteInspectionFailure(error, "relation");
2109
+ }
1876
2110
  }
1877
2111
  catch (error) {
1878
- throwRemoteInspectionFailure(error, "HEAD");
2112
+ inspectionError = error;
1879
2113
  }
1880
- let relationOutput;
1881
2114
  try {
1882
- relationOutput = git.run(["rev-list", "--left-right", "--count", `HEAD...${remoteSha}`], cwd);
2115
+ git.run(["update-ref", "-d", temporaryRef], cwd);
1883
2116
  }
1884
2117
  catch (error) {
1885
- throwRemoteInspectionFailure(error, "relation");
2118
+ if (inspectionError === undefined)
2119
+ throwRemoteInspectionFailure(error, "remote-generation-cleanup");
2120
+ }
2121
+ if (inspectionError !== undefined)
2122
+ throw inspectionError;
2123
+ if (relationOutput === undefined) {
2124
+ throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Remote ancestry relation was unavailable", {
2125
+ remote,
2126
+ branch,
2127
+ });
1886
2128
  }
1887
2129
  const counts = relationOutput
1888
2130
  .trim()
@@ -1896,7 +2138,14 @@ function inspectPushTarget(git, cwd, remote, branch) {
1896
2138
  }
1897
2139
  const [ahead, behind] = counts;
1898
2140
  const relation = ahead > 0 && behind > 0 ? "diverged" : behind > 0 ? "behind" : ahead > 0 ? "ahead" : "up-to-date";
1899
- return { relation, upstream };
2141
+ return { sourceSha, relation, upstream, targetRef, observedRemoteSha: remoteSha };
2142
+ }
2143
+ function extractRemoteSha(remoteTargetOutput, remote, branch) {
2144
+ const remoteShaMatch = remoteTargetOutput.trim().match(/^([0-9a-f]{40,64})\s+/u);
2145
+ if (remoteShaMatch === null) {
2146
+ throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Could not extract remote SHA from ls-remote output", { remote, branch });
2147
+ }
2148
+ return remoteShaMatch[1];
1900
2149
  }
1901
2150
  function isExpectedMissingRef(error) {
1902
2151
  return (error instanceof SessionRegistryError &&
@@ -1913,35 +2162,18 @@ function throwRemoteInspectionFailure(error, phase) {
1913
2162
  ...(error instanceof SessionRegistryError ? { gitCode: error.code } : {}),
1914
2163
  }, error);
1915
2164
  }
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
2165
  function sortStrings(values) {
1943
2166
  return [...new Set(values)].sort(compareCodePointStrings);
1944
2167
  }
2168
+ function sameCheckpointPaths(left, right) {
2169
+ return (sameStringArray(left.changed, right.changed) &&
2170
+ sameStringArray(left.staged, right.staged) &&
2171
+ sameStringArray(left.unstaged, right.unstaged) &&
2172
+ sameStringArray(left.untracked, right.untracked));
2173
+ }
2174
+ function sameStringArray(left, right) {
2175
+ return left.length === right.length && left.every((value, index) => value === right[index]);
2176
+ }
1945
2177
  function assertWorktreeClean(git, worktreePath) {
1946
2178
  // Git is authoritative for this decision. --ignored=no means ignored or
1947
2179
  // generated artifacts alone are intentionally absent from the blocker set.
@@ -2263,21 +2495,35 @@ function resolveBaseRef(git, cwd, candidate) {
2263
2495
  candidate !== candidate.trim() ||
2264
2496
  candidate.startsWith("-") ||
2265
2497
  candidate.includes("\u0000")) {
2266
- throw new SessionRegistryError("INVALID_BASE_REF", `Invalid base ref: ${candidate}`, { baseRef: candidate });
2498
+ throw invalidBaseRef(candidate, "invalid-format");
2267
2499
  }
2268
2500
  try {
2269
- git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
2270
- return candidate;
2501
+ const revision = git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
2502
+ if (!isRevision(revision)) {
2503
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid base revision", {
2504
+ baseRef: candidate,
2505
+ revision,
2506
+ });
2507
+ }
2508
+ return { ref: candidate, revision };
2271
2509
  }
2272
2510
  catch (error) {
2273
2511
  if (isExpectedGitLookupFailure(error)) {
2274
- throw new SessionRegistryError("INVALID_BASE_REF", `Base ref does not resolve to a commit: ${candidate}`, {
2275
- baseRef: candidate,
2276
- });
2512
+ throw invalidBaseRef(candidate, "does-not-resolve-to-commit");
2277
2513
  }
2278
2514
  throw error;
2279
2515
  }
2280
2516
  }
2517
+ function invalidBaseRef(candidate, reason) {
2518
+ return new SessionRegistryError("INVALID_BASE_REF", reason === "invalid-format"
2519
+ ? `Invalid base ref: ${candidate}`
2520
+ : `Base ref does not resolve to a commit: ${candidate}`, {
2521
+ baseRef: candidate,
2522
+ reason,
2523
+ defaultBaseRef: "HEAD",
2524
+ recoveryHints: ["Omit --base to use HEAD, then retry session create."],
2525
+ });
2526
+ }
2281
2527
  function isExpectedGitLookupFailure(error) {
2282
2528
  return (error instanceof SessionRegistryError &&
2283
2529
  error.code === "GIT_COMMAND_FAILED" &&
@@ -2320,6 +2566,7 @@ export function toPersistedSessionRecord(record, expectedRepositoryId = record.r
2320
2566
  state: validated.state,
2321
2567
  created_at: validated.createdAt,
2322
2568
  updated_at: validated.updatedAt,
2569
+ ...(validated.baseRevision === undefined ? {} : { base_revision: validated.baseRevision }),
2323
2570
  ...(validated.label === undefined ? {} : { label: validated.label }),
2324
2571
  };
2325
2572
  }
@@ -2337,7 +2584,7 @@ export function toPersistedResourceClaim(claim, expectedRepositoryId = claim.rep
2337
2584
  updated_at: validated.updatedAt,
2338
2585
  };
2339
2586
  }
2340
- function parseRegistry(value, expectedRepositoryId) {
2587
+ function parseRegistry(value, expectedRepositoryId, allowLegacyClaimSchema = false) {
2341
2588
  if (!isRecord(value)) {
2342
2589
  throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry root must be an object");
2343
2590
  }
@@ -2368,17 +2615,27 @@ function parseRegistry(value, expectedRepositoryId) {
2368
2615
  // materialized on the next locked mutation or via migrate().
2369
2616
  return { sessions: records, claims: [], legacyClaimsAbsent: true };
2370
2617
  }
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 });
2618
+ const claimSchemaVersion = value.claims_schema_version;
2619
+ const isLegacyClaimSchema = claimSchemaVersion === LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION;
2620
+ if (claimSchemaVersion !== RESOURCE_CLAIM_SCHEMA_VERSION && !isLegacyClaimSchema) {
2621
+ throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version: ${stringifyDetail(claimSchemaVersion)}`, { schemaVersion: claimSchemaVersion });
2622
+ }
2623
+ if (isLegacyClaimSchema && !allowLegacyClaimSchema) {
2624
+ 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
2625
  }
2374
2626
  if (!Array.isArray(value.claims)) {
2375
2627
  throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry claims must be an array");
2376
2628
  }
2377
- const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId));
2629
+ const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId, isLegacyClaimSchema ? LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION : undefined));
2378
2630
  validateRegistryClaims(records, claims, expectedRepositoryId);
2379
- return { sessions: records, claims: sortResourceClaims(claims), legacyClaimsAbsent: false };
2631
+ return {
2632
+ sessions: records,
2633
+ claims: sortResourceClaims(claims),
2634
+ legacyClaimsAbsent: false,
2635
+ ...(isLegacyClaimSchema ? { legacyClaimsSchemaVersion: LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION } : {}),
2636
+ };
2380
2637
  }
2381
- function parseResourceClaim(value, index, expectedRepositoryId) {
2638
+ function parseResourceClaim(value, index, expectedRepositoryId, expectedSchemaVersion = RESOURCE_CLAIM_SCHEMA_VERSION) {
2382
2639
  if (!isRecord(value))
2383
2640
  throw invalidRecord(index, "claim must be an object");
2384
2641
  assertExactKeys(value, [
@@ -2392,8 +2649,8 @@ function parseResourceClaim(value, index, expectedRepositoryId) {
2392
2649
  "created_at",
2393
2650
  "updated_at",
2394
2651
  ], [], 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 });
2652
+ if (value.schema_version !== expectedSchemaVersion) {
2653
+ 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
2654
  }
2398
2655
  const claim = {
2399
2656
  schemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
@@ -2505,7 +2762,7 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
2505
2762
  "state",
2506
2763
  "created_at",
2507
2764
  "updated_at",
2508
- ], ["label"], index);
2765
+ ], ["base_revision", "label"], index);
2509
2766
  if (value.schema_version !== REGISTRY_SCHEMA_VERSION) {
2510
2767
  if (typeof value.schema_version === "number") {
2511
2768
  throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported session schema version: ${value.schema_version}`, {
@@ -2526,6 +2783,9 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
2526
2783
  state: requireState(value.state, index),
2527
2784
  createdAt: requireString(value.created_at, index, "created_at"),
2528
2785
  updatedAt: requireString(value.updated_at, index, "updated_at"),
2786
+ ...(value.base_revision === undefined
2787
+ ? {}
2788
+ : { baseRevision: requireRevision(value.base_revision, index, "base_revision") }),
2529
2789
  ...(value.label === undefined ? {} : { label: requireString(value.label, index, "label") }),
2530
2790
  };
2531
2791
  return validateSessionRecord(record, expectedRepositoryId, index);
@@ -2571,6 +2831,9 @@ function validateSessionRecord(record, expectedRepositoryId, index) {
2571
2831
  if (Date.parse(record.updatedAt) < Date.parse(record.createdAt)) {
2572
2832
  throw invalidRecord(index, "updated_at cannot precede created_at");
2573
2833
  }
2834
+ if (record.baseRevision !== undefined && !isRevision(record.baseRevision)) {
2835
+ throw invalidRecord(index, "base_revision must be a full hexadecimal Git revision");
2836
+ }
2574
2837
  if (record.label !== undefined && (typeof record.label !== "string" || record.label.length === 0)) {
2575
2838
  throw invalidRecord(index, "label must be a non-empty string");
2576
2839
  }
@@ -2660,6 +2923,16 @@ function requireString(value, index, field) {
2660
2923
  }
2661
2924
  return value;
2662
2925
  }
2926
+ function requireRevision(value, index, field) {
2927
+ const revision = requireString(value, index, field);
2928
+ if (!isRevision(revision)) {
2929
+ throw invalidRecord(index, `${field} must be a full hexadecimal Git revision`);
2930
+ }
2931
+ return revision;
2932
+ }
2933
+ function isRevision(value) {
2934
+ return /^[0-9a-f]{40,64}$/u.test(value);
2935
+ }
2663
2936
  function requireState(value, index) {
2664
2937
  if (typeof value !== "string" || !SESSION_STATES.has(value)) {
2665
2938
  throw invalidRecord(index, `state is invalid: ${stringifyDetail(value)}`);