nawabari 0.8.0 → 0.9.2

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 (52) hide show
  1. package/README.md +35 -0
  2. package/dist/cli-command-registry.d.ts +6 -0
  3. package/dist/cli-command-registry.js +26 -5
  4. package/dist/cli-command-registry.js.map +1 -1
  5. package/dist/cli.js +70 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/contract.js +35 -0
  8. package/dist/contract.js.map +1 -1
  9. package/dist/domain/doctor.js +44 -0
  10. package/dist/domain/doctor.js.map +1 -1
  11. package/dist/domain/session-backend.d.ts +2 -1
  12. package/dist/domain/session-backend.js +119 -18
  13. package/dist/domain/session-backend.js.map +1 -1
  14. package/dist/domain/session.d.ts +100 -19
  15. package/dist/domain/session.js.map +1 -1
  16. package/dist/product-state-manifest.d.ts +152 -0
  17. package/dist/product-state-manifest.js +240 -0
  18. package/dist/product-state-manifest.js.map +1 -0
  19. package/dist/public-contract.d.ts +25 -0
  20. package/dist/public-contract.js +29 -0
  21. package/dist/public-contract.js.map +1 -0
  22. package/dist/public-state.d.ts +75 -0
  23. package/dist/public-state.js +60 -0
  24. package/dist/public-state.js.map +1 -0
  25. package/dist/session-lifecycle-actions.d.ts +4 -3
  26. package/dist/session-lifecycle-actions.js +14 -5
  27. package/dist/session-lifecycle-actions.js.map +1 -1
  28. package/dist/session-lifecycle-classification.d.ts +55 -2
  29. package/dist/session-lifecycle-classification.js +39 -306
  30. package/dist/session-lifecycle-classification.js.map +1 -1
  31. package/dist/session-registry.d.ts +83 -1
  32. package/dist/session-registry.js +440 -21
  33. package/dist/session-registry.js.map +1 -1
  34. package/dist/state/index.d.ts +5 -0
  35. package/dist/state/index.js +4 -0
  36. package/dist/state/index.js.map +1 -0
  37. package/dist/state/session/actors.d.ts +71 -0
  38. package/dist/state/session/actors.js +259 -0
  39. package/dist/state/session/actors.js.map +1 -0
  40. package/dist/state/session/guards.d.ts +25 -0
  41. package/dist/state/session/guards.js +108 -0
  42. package/dist/state/session/guards.js.map +1 -0
  43. package/dist/state/session/index.d.ts +6 -0
  44. package/dist/state/session/index.js +5 -0
  45. package/dist/state/session/index.js.map +1 -0
  46. package/dist/state/session/machine.d.ts +185 -0
  47. package/dist/state/session/machine.js +408 -0
  48. package/dist/state/session/machine.js.map +1 -0
  49. package/dist/state/session/types.d.ts +94 -0
  50. package/dist/state/session/types.js +14 -0
  51. package/dist/state/session/types.js.map +1 -0
  52. package/package.json +26 -6
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readChangedPathNames, readGitPathStats, readCurrentHead, readTreePathStates, resolveRepositoryContext, treeEntriesEqual, verifyPhysicalExecutionContext, } from "./git.js";
3
+ import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readChangedPathNames, canonicalizeGitObservedPaths, readGitPathStats, readCurrentHead, readTreePathStates, resolveRepositoryContext, treeEntriesEqual, verifyPhysicalExecutionContext, } from "./git.js";
4
4
  import { SessionRegistryError, } from "./errors.js";
5
5
  import { generateSessionId, isSessionId } from "./session-id.js";
6
6
  import { isPostRenameFailure, writeJsonAtomicallySync } from "./registry/atomic.js";
@@ -9,9 +9,10 @@ import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput,
9
9
  import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
10
10
  import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, evidenceHash, } from "./repository-evidence.js";
11
11
  import { classifySessionLifecycle, } from "./session-lifecycle-classification.js";
12
+ import { coordinateSessionLifecycle, } from "./state/session/actors.js";
12
13
  import { projectSessionLifecycleActions, } from "./session-lifecycle-actions.js";
13
14
  export { primarySessionLifecycleAction, primarySessionLifecycleNextAction, projectSessionLifecycleActions, projectSessionLifecycleNextActions, SESSION_LIFECYCLE_ACTION_SCHEMA_VERSION, } from "./session-lifecycle-actions.js";
14
- export { classifySessionLifecycle, lifecycleTransition, SESSION_LIFECYCLE_CLASSIFICATION_SCHEMA_VERSION, SESSION_LIFECYCLE_STATES, SESSION_LIFECYCLE_TRANSITION_TABLE, } from "./session-lifecycle-classification.js";
15
+ export { availableLifecycleOperations, classifySessionLifecycle, lifecycleTransition, SESSION_LIFECYCLE_CLASSIFICATION_SCHEMA_VERSION, SESSION_LIFECYCLE_STATES, SESSION_LIFECYCLE_TRANSITION_TABLE, } from "./session-lifecycle-classification.js";
15
16
  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";
16
17
  export const REGISTRY_SCHEMA_VERSION = 1;
17
18
  export { RESOURCE_CLAIM_SCHEMA_VERSION };
@@ -23,6 +24,7 @@ export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
23
24
  export const RECONCILIATION_SCHEMA_VERSION = 1;
24
25
  export const SESSION_DIAGNOSTIC_SCHEMA_VERSION = 1;
25
26
  export const DISCARD_RESULT_SCHEMA_VERSION = 1;
27
+ export const DISCARD_PREVIEW_SCHEMA_VERSION = 1;
26
28
  const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
27
29
  /** Bounds a caller-declared commit-message pattern before it is compiled. */
28
30
  export const MAX_MESSAGE_PATTERN_LENGTH = 512;
@@ -944,7 +946,17 @@ export class SessionRegistry {
944
946
  assertFinalCommitMessage(options.message, messagePattern);
945
947
  return this.withLock(() => {
946
948
  const sessionId = operationSessionId(options.sessionId, options.session_id);
947
- const requestedResources = operationResources(options.resources ?? options.paths);
949
+ const allClaimed = options.allClaimed === true || options.all_claimed === true;
950
+ const explicitResources = options.resources ?? options.paths;
951
+ if (allClaimed && explicitResources !== undefined && explicitResources.length > 0) {
952
+ throw new SessionRegistryError("INVALID_RESOURCE", "--all-claimed cannot be combined with explicit resources", {
953
+ operation: "commit",
954
+ selectors: ["--resource", "--all-claimed"],
955
+ });
956
+ }
957
+ const requestedResources = allClaimed
958
+ ? this.resolveAllClaimedResourcesUnsafe("commit", sessionId)
959
+ : operationResources(explicitResources);
948
960
  const authorization = this.requireMutationAuthorization("commit", requestedResources, sessionId);
949
961
  const initial = this.verifyGovernedExecutionContext(sessionId);
950
962
  const before = observeGitMutationPaths(this.git, initial.worktreePath);
@@ -1065,7 +1077,16 @@ export class SessionRegistry {
1065
1077
  push(options) {
1066
1078
  return this.withLock(() => {
1067
1079
  const sessionId = operationSessionId(options.sessionId, options.session_id);
1068
- const requestedResources = operationResources(options.resources);
1080
+ const allClaimed = options.allClaimed === true || options.all_claimed === true;
1081
+ if (allClaimed && options.resources !== undefined && options.resources.length > 0) {
1082
+ throw new SessionRegistryError("INVALID_RESOURCE", "--all-claimed cannot be combined with explicit resources", {
1083
+ operation: "push",
1084
+ selectors: ["--resource", "--all-claimed"],
1085
+ });
1086
+ }
1087
+ const requestedResources = allClaimed
1088
+ ? this.resolveAllClaimedResourcesUnsafe("push", sessionId)
1089
+ : operationResources(options.resources);
1069
1090
  this.requireMutationAuthorization("push", requestedResources, sessionId);
1070
1091
  const initial = this.verifyGovernedExecutionContext(sessionId);
1071
1092
  const remote = explicitRemote(options.remote);
@@ -1155,6 +1176,69 @@ export class SessionRegistry {
1155
1176
  pushSession(options) {
1156
1177
  return this.push(options);
1157
1178
  }
1179
+ /**
1180
+ * Resolve the opt-in all-claimed selector while the mutation lock is held.
1181
+ * Claims remain the authority, but only concrete Git/repository evidence is
1182
+ * ever handed to the existing concrete authorization path.
1183
+ */
1184
+ resolveAllClaimedResourcesUnsafe(operation, sessionId) {
1185
+ const execution = this.verifyGovernedExecutionContext(sessionId);
1186
+ const requiredAccess = requiredAccessForOperation(operation);
1187
+ const state = this.readStateUnsafe();
1188
+ const claims = sortResourceClaims(state.claims.filter((claim) => claim.sessionId === execution.session.sessionId &&
1189
+ claim.repositoryId === execution.repositoryId &&
1190
+ claim.worktreePath === execution.worktreePath));
1191
+ const globClaims = claims.filter((claim) => claim.resource.includes("*") || claim.resource.includes("?"));
1192
+ let candidates;
1193
+ if (operation === "commit") {
1194
+ candidates = observeGitMutationPaths(this.git, execution.worktreePath).changed;
1195
+ }
1196
+ else {
1197
+ const exactClaims = claims.filter((claim) => !claim.resource.includes("*") && !claim.resource.includes("?"));
1198
+ const selected = new Set(exactClaims.map((claim) => claim.resource));
1199
+ if (globClaims.length === 0)
1200
+ return Object.freeze(sortStrings(selected));
1201
+ const baseRevision = execution.session.baseRevision;
1202
+ if (baseRevision === undefined) {
1203
+ throw new SessionRegistryError("INVALID_RESOURCE", "A glob claim cannot be projected to concrete push resources without a persisted base revision", { operation, reason: "non-enumerable-claim", claims: globClaims.map((claim) => claim.resource) });
1204
+ }
1205
+ const changed = readChangedPathNames(this.git, execution.worktreePath, baseRevision, execution.headId);
1206
+ candidates = canonicalizeGitObservedPaths(changed, execution.worktreePath);
1207
+ const ambiguousResource = candidates.find((resource) => resource.includes("*") || resource.includes("?"));
1208
+ if (ambiguousResource !== undefined) {
1209
+ throw new SessionRegistryError("INVALID_RESOURCE", "Git reported a concrete resource whose wildcard spelling is ambiguous", { operation, reason: "ambiguous-concrete-resource", resource: ambiguousResource });
1210
+ }
1211
+ for (const claim of globClaims) {
1212
+ const matches = candidates.filter((resource) => resourceMatchesClaim(claim, resource));
1213
+ if (matches.length === 0) {
1214
+ throw new SessionRegistryError("INVALID_RESOURCE", "A glob claim could not be deterministically projected to a concrete push resource", { operation, reason: "non-enumerable-claim", claim: claim.resource });
1215
+ }
1216
+ for (const match of matches)
1217
+ selected.add(match);
1218
+ }
1219
+ return Object.freeze(sortStrings(selected));
1220
+ }
1221
+ const selected = new Set();
1222
+ for (const resource of candidates) {
1223
+ if (resource.includes("*") || resource.includes("?")) {
1224
+ throw new SessionRegistryError("INVALID_RESOURCE", "Git reported a concrete resource whose wildcard spelling is ambiguous", { operation, reason: "ambiguous-concrete-resource", resource });
1225
+ }
1226
+ const matchingClaims = claims.filter((claim) => resourceMatchesClaim(claim, resource));
1227
+ if (matchingClaims.length === 0)
1228
+ continue;
1229
+ const qualifyingClaims = matchingClaims.filter((claim) => claimModeGrantsAccess(claim.mode, requiredAccess));
1230
+ // Include mode-insufficient matches as concrete candidates so the
1231
+ // existing authorization authority emits INSUFFICIENT_CLAIM_MODE.
1232
+ // Unclaimed paths remain outside the selection and are still rejected by
1233
+ // commit's existing UNEXPECTED_CHANGED_PATHS protection.
1234
+ if (qualifyingClaims.length > 0 || matchingClaims.length > 0)
1235
+ selected.add(resource);
1236
+ }
1237
+ // Let the existing authorization authority produce its normal missing-claim
1238
+ // denial when every observed path is unclaimed; do not invent a selector
1239
+ // denial with different semantics.
1240
+ return Object.freeze(sortStrings(selected.size === 0 ? candidates : selected));
1241
+ }
1158
1242
  requireMutationAuthorization(operation, resources, sessionId) {
1159
1243
  const decision = this.authorizeOperation({ operation, resources, sessionId });
1160
1244
  if (decision.allowed)
@@ -1492,7 +1576,22 @@ export class SessionRegistry {
1492
1576
  if (record === undefined) {
1493
1577
  throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
1494
1578
  }
1495
- return this.observeCleanupReconciliation(record, operation ?? (record.terminalOperation === "discard" ? "discard" : "close"));
1579
+ const cleanupOperation = operation ?? (record.terminalOperation === "discard" ? "discard" : "close");
1580
+ let observed;
1581
+ const result = coordinateSessionLifecycle({
1582
+ operation: "reconcile",
1583
+ adapter: {
1584
+ observe: (stage) => this.lifecycleActorInputUnsafe(record, this.readStateUnsafe(), undefined, false, stage === "after" ? "current" : "termination"),
1585
+ effect: () => {
1586
+ observed = this.observeCleanupReconciliation(record, cleanupOperation);
1587
+ return observed;
1588
+ },
1589
+ },
1590
+ });
1591
+ if (result.phase !== "completed" || observed === undefined) {
1592
+ throw this.lifecycleActorRejection(record, result.decision);
1593
+ }
1594
+ return observed;
1496
1595
  });
1497
1596
  }
1498
1597
  reconcileLifecycleCleanup(sessionIdOrOptions, operation) {
@@ -1507,7 +1606,7 @@ export class SessionRegistry {
1507
1606
  const selectedSessionId = sessionId ?? this.resolveCurrentSession().sessionId;
1508
1607
  assertSessionId(selectedSessionId);
1509
1608
  const evidence = closeIntegrationEvidence(sessionIdOrOptions ?? null);
1510
- return this.closeUnsafe(selectedSessionId, evidence);
1609
+ return this.coordinateCleanupUnsafe("close", selectedSessionId, evidence);
1511
1610
  });
1512
1611
  }
1513
1612
  closeSession(sessionIdOrOptions) {
@@ -1523,12 +1622,95 @@ export class SessionRegistry {
1523
1622
  throw new SessionRegistryError("SESSION_NOT_FOUND", "Explicit session identity is required for discard; the current session is never inferred");
1524
1623
  }
1525
1624
  assertSessionId(requestedSessionId);
1526
- return this.discardUnsafe(requestedSessionId);
1625
+ return this.coordinateCleanupUnsafe("discard", requestedSessionId);
1527
1626
  });
1528
1627
  }
1529
1628
  discardSession(sessionIdOrOptions) {
1530
1629
  return this.discard(sessionIdOrOptions);
1531
1630
  }
1631
+ /**
1632
+ * Produce a bounded, side-effect-free summary of the same session/Git/claim
1633
+ * authorities that an explicit discard observes. The preview never invokes
1634
+ * the cleanup actor or writes registry, worktree, branch, or claim state.
1635
+ */
1636
+ previewDiscard(sessionIdOrOptions) {
1637
+ return this.withLock(() => {
1638
+ const requestedSessionId = typeof sessionIdOrOptions === "string"
1639
+ ? sessionIdOrOptions
1640
+ : (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id ?? null);
1641
+ if (requestedSessionId === null) {
1642
+ throw new SessionRegistryError("SESSION_NOT_FOUND", "Explicit session identity is required for discard preview; the current session is never inferred");
1643
+ }
1644
+ assertSessionId(requestedSessionId);
1645
+ const state = this.readStateUnsafe();
1646
+ const record = state.sessions.find((candidate) => candidate.sessionId === requestedSessionId);
1647
+ if (record === undefined) {
1648
+ throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${requestedSessionId}`, {
1649
+ sessionId: requestedSessionId,
1650
+ });
1651
+ }
1652
+ const diagnostic = this.diagnoseUnsafe(record, state);
1653
+ const reconciliation = this.observeCleanupReconciliation(record, "discard");
1654
+ const blockers = diagnostic.blockers.map((blocker) => ({
1655
+ code: blocker.code,
1656
+ message: blocker.message,
1657
+ details: { ...blocker.details },
1658
+ }));
1659
+ const recoverableEvidence = blockers.filter((blocker) => blocker.code === "RECOVERABLE_COMMITS");
1660
+ const dirtyEvidence = blockers.filter((blocker) => blocker.code === "DIRTY_WORKTREE");
1661
+ const branchEvidenceObservable = recoverableEvidence.length > 0 || blockers.length === 0;
1662
+ const worktreeEvidenceObservable = dirtyEvidence.length > 0 || recoverableEvidence.length > 0 || blockers.length === 0;
1663
+ const recoverableCommits = {
1664
+ observable: branchEvidenceObservable,
1665
+ present: recoverableEvidence.length > 0 ? true : branchEvidenceObservable ? false : null,
1666
+ evidence: Object.freeze(recoverableEvidence.slice(0, 8)),
1667
+ };
1668
+ const uncommittedWork = {
1669
+ observable: worktreeEvidenceObservable,
1670
+ present: dirtyEvidence.length > 0 ? true : worktreeEvidenceObservable ? false : null,
1671
+ evidence: Object.freeze(dirtyEvidence.slice(0, 8)),
1672
+ };
1673
+ const ownedClaims = sortResourceClaims(state.claims.filter((claim) => claim.sessionId === record.sessionId));
1674
+ const boundedClaims = ownedClaims.slice(0, MAX_DISCARD_CLAIMS).map(cloneResourceClaim);
1675
+ const worktreeHead = reconciliation.observedWorktreeHead;
1676
+ const branchHead = reconciliation.observedBranchHead;
1677
+ return Object.freeze({
1678
+ schemaVersion: DISCARD_PREVIEW_SCHEMA_VERSION,
1679
+ operation: "discard-preview",
1680
+ destructive: true,
1681
+ warning: "Actual session discard is destructive: it may remove the worktree and branch and discard recoverable work.",
1682
+ session: cloneSessionRecord(record),
1683
+ currentState: record.state,
1684
+ persistedState: record.state,
1685
+ physicalState: diagnostic.physicalState,
1686
+ worktreePresent: reconciliation.worktreePresent,
1687
+ branchPresent: reconciliation.branchPresent,
1688
+ worktreeHead,
1689
+ branchHead,
1690
+ head: worktreeHead ?? branchHead ?? record.discardedHead ?? null,
1691
+ expectedHead: reconciliation.expectedHead,
1692
+ recoverableCommits,
1693
+ uncommittedWork,
1694
+ claims: Object.freeze(boundedClaims),
1695
+ claimCount: ownedClaims.length,
1696
+ claimsTruncated: ownedClaims.length > boundedClaims.length,
1697
+ destructiveScope: Object.freeze({
1698
+ worktree: reconciliation.worktreePresent,
1699
+ branch: reconciliation.branchPresent,
1700
+ unintegratedCommits: recoverableCommits.present,
1701
+ uncommittedWork: uncommittedWork.present,
1702
+ claims: ownedClaims.length,
1703
+ }),
1704
+ diagnostic: Object.freeze({
1705
+ closeReadiness: diagnostic.closeReadiness,
1706
+ cleanupReadiness: diagnostic.cleanupReadiness,
1707
+ resultState: diagnostic.resultState,
1708
+ blockers: Object.freeze(blockers.slice(0, 8)),
1709
+ ...(diagnostic.lifecycle === undefined ? {} : { lifecycleState: diagnostic.lifecycle.state }),
1710
+ }),
1711
+ });
1712
+ });
1713
+ }
1532
1714
  /** Detect stale sessions, optionally applying only cleanup that passes close preflight. */
1533
1715
  garbageCollect(options = {}) {
1534
1716
  const apply = options.apply ?? false;
@@ -1543,20 +1725,39 @@ export class SessionRegistry {
1543
1725
  const worktrees = listGitWorktrees(this.git, this.repository.worktreePath);
1544
1726
  const assessments = records
1545
1727
  .filter((record) => record.state !== "closed")
1546
- .map((record) => assessGarbageCollection(record, now, staleAfterMs, worktrees));
1728
+ .map((record) => {
1729
+ const assessment = assessGarbageCollection(record, now, staleAfterMs, worktrees);
1730
+ const decision = this.cleanupDecisionUnsafe(record, state);
1731
+ const lifecycle = classifySessionLifecycle({
1732
+ sessionState: record.state,
1733
+ physicalState: decision.physicalState,
1734
+ closeReadiness: decision.lifecycle?.closeReadiness ?? (decision.allowed ? "ready" : "blocked"),
1735
+ blockers: decision.blockers.map((blocker) => ({ code: blocker.code })),
1736
+ terminalOperation: record.terminalOperation,
1737
+ ageSuspicious: assessment.suspicion === "age",
1738
+ // GC's existing candidate assessment is the independent positive
1739
+ // authority; the machine still evaluates the complete observed
1740
+ // cleanup evidence and may reject the event.
1741
+ gcAuthorized: assessment.destructiveEligibility === "eligible",
1742
+ phase: "termination",
1743
+ });
1744
+ const nextActions = projectSessionLifecycleActions({
1745
+ classification: lifecycle,
1746
+ sessionId: record.sessionId,
1747
+ blockers: decision.blockers.map((blocker) => ({ code: blocker.code, details: blocker.details })),
1748
+ });
1749
+ return Object.freeze({ ...assessment, physicalState: decision.physicalState, lifecycle, nextActions });
1750
+ });
1547
1751
  const candidates = assessments.filter((assessment) => assessment.suspicion !== "none");
1548
1752
  const eligible = assessments.filter((assessment) => {
1549
1753
  if (assessment.terminalOperation === "discard")
1550
1754
  return false;
1551
- const lifecycle = classifySessionLifecycle({
1552
- sessionState: assessment.state,
1553
- physicalState: assessment.physicalState,
1554
- closeReadiness: "ready",
1555
- gcAuthorized: true,
1556
- phase: "termination",
1557
- });
1755
+ // Eligibility is evaluated from the GC authority's own bounded
1756
+ // evidence. Full cleanup blockers remain on `assessment.lifecycle`
1757
+ // and are applied by the existing preflight below.
1758
+ const gcLifecycle = projectGarbageCollectAuthorization(assessment);
1558
1759
  if (assessment.destructiveEligibility === "eligible" &&
1559
- (lifecycle.destructiveCleanupEligible || assessment.physicalState === "prunable-missing")) {
1760
+ (gcLifecycle.destructiveCleanupEligible || assessment.physicalState === "prunable-missing")) {
1560
1761
  return true;
1561
1762
  }
1562
1763
  // A close that already removed one physical resource is represented
@@ -1601,7 +1802,7 @@ export class SessionRegistry {
1601
1802
  this.writeUnsafe(records, claims, claimSetGeneration);
1602
1803
  }
1603
1804
  try {
1604
- const result = this.closeUnsafe(candidate.sessionId, undefined, "gc");
1805
+ const result = this.coordinateCleanupUnsafe("gc", candidate.sessionId);
1605
1806
  cleaned.push(result.session);
1606
1807
  }
1607
1808
  catch (error) {
@@ -1624,6 +1825,173 @@ export class SessionRegistry {
1624
1825
  gc(options = {}) {
1625
1826
  return this.garbageCollect(options);
1626
1827
  }
1828
+ coordinateCleanupUnsafe(operation, sessionId, evidence, retryOverride) {
1829
+ const state = this.readStateUnsafe();
1830
+ const record = state.sessions.find((candidate) => candidate.sessionId === sessionId);
1831
+ if (record === undefined) {
1832
+ throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
1833
+ }
1834
+ const retry = record.state === "closing" &&
1835
+ (operation === "gc" ||
1836
+ (operation === "close" && record.terminalOperation === undefined) ||
1837
+ (operation === "discard" && record.terminalOperation === "discard"));
1838
+ const actorRetry = retryOverride ?? retry;
1839
+ const reconciliationOperation = operation === "close" ? "close" : operation === "discard" ? "discard" : "gc";
1840
+ let observedDiagnostic;
1841
+ let observedResources;
1842
+ const result = coordinateSessionLifecycle({
1843
+ operation,
1844
+ retry: actorRetry,
1845
+ adapter: {
1846
+ retryAuthorized: actorRetry
1847
+ ? () => {
1848
+ const reconciliation = this.observeCleanupReconciliation(record, reconciliationOperation);
1849
+ if (reconciliation.retrySafe && reconciliation.outcome !== "unresolved")
1850
+ return true;
1851
+ // A cleanup that was not yet durably marked `closing` has no
1852
+ // captured head to reconcile. The existing cleanup preflight is
1853
+ // still the positive identity authority for prunable-missing
1854
+ // worktrees; do not infer permission from age or state alone.
1855
+ if (record.cleanupHead === undefined && record.discardedHead === undefined) {
1856
+ try {
1857
+ this.inspectCleanupResources(record, undefined, operation === "close" ? evidence : undefined, operation === "discard" ? "discard" : "close");
1858
+ return true;
1859
+ }
1860
+ catch {
1861
+ return false;
1862
+ }
1863
+ }
1864
+ return false;
1865
+ }
1866
+ : undefined,
1867
+ observe: (stage) => {
1868
+ const observedState = this.readStateUnsafe();
1869
+ const observedRecord = observedState.sessions.find((candidate) => candidate.sessionId === sessionId);
1870
+ if (observedRecord === undefined) {
1871
+ throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
1872
+ }
1873
+ return this.lifecycleActorInputUnsafe(observedRecord, observedState, evidence, operation === "gc", stage === "after" ? "current" : "termination", (diagnostic) => {
1874
+ observedDiagnostic = diagnostic;
1875
+ }, operation === "discard"
1876
+ ? undefined
1877
+ : (resources) => {
1878
+ if (stage === "before")
1879
+ observedResources = resources;
1880
+ });
1881
+ },
1882
+ effect: () => {
1883
+ if (operation === "discard")
1884
+ return this.discardUnsafe(sessionId);
1885
+ return this.closeUnsafe(sessionId, operation === "close" ? evidence : undefined, operation, observedResources);
1886
+ },
1887
+ reconcile: (_error, _after, _decision) => {
1888
+ const reconciliation = this.observeCleanupReconciliation(record, reconciliationOperation);
1889
+ return {
1890
+ outcome: reconciliation.outcome,
1891
+ retrySafe: reconciliation.retrySafe,
1892
+ ...(reconciliation.reason === undefined ? {} : { reason: reconciliation.reason }),
1893
+ };
1894
+ },
1895
+ },
1896
+ });
1897
+ if (result.phase === "rejected" &&
1898
+ !actorRetry &&
1899
+ record.state !== "closed" &&
1900
+ (operation === "close" || operation === "gc") &&
1901
+ observedDiagnostic?.blockers.length === 0 &&
1902
+ (observedDiagnostic.physicalState === "prunable-missing" ||
1903
+ (operation === "close" && observedDiagnostic.physicalState === "unregistered-missing"))) {
1904
+ if (operation === "close")
1905
+ return this.coordinateCleanupUnsafe("close", sessionId, evidence, true);
1906
+ return this.coordinateCleanupUnsafe("gc", sessionId, undefined, true);
1907
+ }
1908
+ if (result.phase === "rejected") {
1909
+ // Preserve the durable terminal-intent guards that the existing effect
1910
+ // adapters enforce before any physical observation can reinterpret the
1911
+ // request. These are persisted intent contracts, not a new actor
1912
+ // authority: normal close never acquires discard authority, and an
1913
+ // ordinary close already in progress cannot be converted to discard.
1914
+ if (operation === "close" && record.terminalOperation === "discard") {
1915
+ throw new SessionRegistryError("OPERATION_REJECTED", "An explicit discard is already in progress; retry session discard to complete it", { sessionId, state: record.state, terminalOperation: "discard", safeActions: ["retry-discard"] });
1916
+ }
1917
+ if (operation === "discard" && record.state === "closed" && record.terminalOperation !== "discard") {
1918
+ throw new SessionRegistryError("OPERATION_REJECTED", "The selected session was already closed normally; discard cannot be retroactively applied", { sessionId, state: record.state, terminalOperation: "close" });
1919
+ }
1920
+ if (operation === "discard" && record.state === "closing" && record.terminalOperation !== "discard") {
1921
+ throw new SessionRegistryError("OPERATION_REJECTED", "The selected session has an ordinary close in progress; retry close before choosing discard", { sessionId, state: record.state, safeActions: ["retry-close", "retain-session"] });
1922
+ }
1923
+ const blocker = observedDiagnostic?.blockers[0];
1924
+ if (blocker !== undefined) {
1925
+ throw enrichCloseBlockerError(new SessionRegistryError(blocker.code, blocker.message, {
1926
+ ...blocker.details,
1927
+ recoveryHints: [...blocker.recoveryHints],
1928
+ safeActions: [...blocker.safeActions],
1929
+ }));
1930
+ }
1931
+ throw this.lifecycleActorRejection(record, result.decision, evidence);
1932
+ }
1933
+ if (result.error !== undefined)
1934
+ throw result.error;
1935
+ if (result.phase !== "completed" || result.value === undefined) {
1936
+ throw new SessionRegistryError("OPERATION_REJECTED", "Lifecycle cleanup did not finalize", {
1937
+ sessionId,
1938
+ operation,
1939
+ phase: result.phase,
1940
+ reconciliation: result.reconciliation,
1941
+ });
1942
+ }
1943
+ return result.value;
1944
+ }
1945
+ /** Build an actor input from one authoritative registry/physical diagnostic. */
1946
+ lifecycleActorInputUnsafe(record, state, evidence, gcAuthorized, phase, diagnosticSink, resourcesSink) {
1947
+ const diagnostic = this.diagnoseUnsafe(record, state, evidence, resourcesSink);
1948
+ diagnosticSink?.(diagnostic);
1949
+ return {
1950
+ persisted: {
1951
+ sessionId: record.sessionId,
1952
+ state: record.state,
1953
+ ...(record.terminalOperation === undefined ? {} : { terminalOperation: record.terminalOperation }),
1954
+ ...(record.cleanupHead === undefined ? {} : { cleanupHead: record.cleanupHead }),
1955
+ ...(record.discardedHead === undefined ? {} : { discardedHead: record.discardedHead }),
1956
+ },
1957
+ observation: {
1958
+ sessionState: record.state,
1959
+ physicalState: diagnostic.physicalState,
1960
+ closeReadiness: diagnostic.closeReadiness === "not_due" ? "not-evaluated" : diagnostic.closeReadiness,
1961
+ blockers: diagnostic.blockers.map((blocker) => ({ code: blocker.code })),
1962
+ terminalOperation: record.terminalOperation,
1963
+ ageSuspicious: diagnostic.garbageCollection.suspicion === "age",
1964
+ gcAuthorized,
1965
+ phase,
1966
+ },
1967
+ };
1968
+ }
1969
+ /** Preserve existing blocker/error contracts when the actor rejects a request. */
1970
+ lifecycleActorRejection(record, decision, evidence) {
1971
+ if (decision.operation === "close" || decision.operation === "discard" || decision.operation === "gc") {
1972
+ try {
1973
+ const resources = this.inspectCleanupResources(record, undefined, decision.operation === "close" ? evidence : undefined, decision.operation === "discard" ? "discard" : "close");
1974
+ if (decision.operation === "discard" &&
1975
+ (resources.physicalState !== "healthy" || !resources.worktreePresent || !resources.branchPresent)) {
1976
+ throw ownershipMismatch(record, "Discard requires an unambiguous, currently owned worktree and branch", {
1977
+ physicalState: resources.physicalState,
1978
+ worktreePresent: resources.worktreePresent,
1979
+ branchPresent: resources.branchPresent,
1980
+ phase: "discard-preflight",
1981
+ });
1982
+ }
1983
+ }
1984
+ catch (error) {
1985
+ return enrichCloseBlockerError(error);
1986
+ }
1987
+ }
1988
+ return new SessionRegistryError("OPERATION_REJECTED", "Lifecycle transition was rejected by the Session actor", {
1989
+ sessionId: record.sessionId,
1990
+ operation: decision.operation,
1991
+ reason: decision.reason,
1992
+ target: decision.target,
1993
+ });
1994
+ }
1627
1995
  resolveOwnerSession(records) {
1628
1996
  const matches = records.filter((record) => CURRENT_SESSION_STATES.has(record.state) && record.worktreeId === this.repository.worktreePath);
1629
1997
  if (matches.length === 1)
@@ -1916,7 +2284,7 @@ export class SessionRegistry {
1916
2284
  }
1917
2285
  return candidates;
1918
2286
  }
1919
- closeUnsafe(sessionId, evidence, cleanupOperation = "close") {
2287
+ closeUnsafe(sessionId, evidence, cleanupOperation = "close", initialResources) {
1920
2288
  const state = this.readStateUnsafe();
1921
2289
  const records = state.sessions;
1922
2290
  const record = records.find((candidate) => candidate.sessionId === sessionId);
@@ -1939,7 +2307,7 @@ export class SessionRegistry {
1939
2307
  }
1940
2308
  let resources;
1941
2309
  try {
1942
- resources = this.inspectCleanupResources(record, undefined, evidence);
2310
+ resources = initialResources ?? this.inspectCleanupResources(record, undefined, evidence);
1943
2311
  }
1944
2312
  catch (error) {
1945
2313
  throw enrichCloseBlockerError(error);
@@ -2237,7 +2605,7 @@ export class SessionRegistry {
2237
2605
  lifecycle,
2238
2606
  });
2239
2607
  }
2240
- diagnoseUnsafe(record, state, evidence) {
2608
+ diagnoseUnsafe(record, state, evidence, resourcesSink) {
2241
2609
  const claims = state.claims.filter((claim) => claim.sessionId === record.sessionId).map(cloneResourceClaim);
2242
2610
  // Used only to evaluate staleness below; deliberately not part of the
2243
2611
  // returned diagnostic so repeated inspection of unchanged state is
@@ -2257,6 +2625,7 @@ export class SessionRegistry {
2257
2625
  });
2258
2626
  const nextActions = projectSessionLifecycleActions({ classification: lifecycle, sessionId: record.sessionId });
2259
2627
  const garbageCollection = closedGarbageCollectionAssessment(record, now);
2628
+ const projectedGarbageCollection = projectGarbageCollectCandidateLifecycle(garbageCollection, lifecycle, []);
2260
2629
  return Object.freeze({
2261
2630
  schemaVersion: SESSION_DIAGNOSTIC_SCHEMA_VERSION,
2262
2631
  operation: "diagnostic",
@@ -2275,7 +2644,7 @@ export class SessionRegistry {
2275
2644
  ...(nextActions.length === 0 ? {} : { nextAction: nextActions[0] }),
2276
2645
  nextActions,
2277
2646
  integrationEvidence: Object.freeze(integrationEvidence),
2278
- garbageCollection,
2647
+ garbageCollection: projectedGarbageCollection,
2279
2648
  lifecycle,
2280
2649
  });
2281
2650
  }
@@ -2290,6 +2659,7 @@ export class SessionRegistry {
2290
2659
  garbageCollection = assessGarbageCollection(record, now, this.staleAfterMs, worktrees);
2291
2660
  staleCandidate = garbageCollection.destructiveEligibility === "eligible";
2292
2661
  const resources = this.inspectCleanupResources(record, worktrees, evidence);
2662
+ resourcesSink?.(resources);
2293
2663
  integrationProof = resources.integrationProof;
2294
2664
  }
2295
2665
  catch (error) {
@@ -2307,6 +2677,7 @@ export class SessionRegistry {
2307
2677
  ageSuspicious: Date.parse(now) - Date.parse(record.updatedAt) >= this.staleAfterMs,
2308
2678
  phase: "termination",
2309
2679
  });
2680
+ garbageCollection = projectGarbageCollectCandidateLifecycle(garbageCollection, lifecycle, blockers);
2310
2681
  const safeActions = blockers.length > 0
2311
2682
  ? sortStrings(Array.from(new Set(blockers.flatMap((blocker) => blocker.safeActions))))
2312
2683
  : closeReadiness === "ready"
@@ -2988,6 +3359,17 @@ function assessGarbageCollection(record, now, staleAfterMs, worktrees) {
2988
3359
  destructiveEligibility = "ineligible";
2989
3360
  destructiveEligibilityReason = "age-only";
2990
3361
  }
3362
+ const lifecycle = projectGarbageCollectAuthorization({
3363
+ state: record.state,
3364
+ physicalState: worktreeState.kind,
3365
+ terminalOperation: record.terminalOperation,
3366
+ suspicion,
3367
+ destructiveEligibility,
3368
+ });
3369
+ const nextActions = projectSessionLifecycleActions({
3370
+ classification: lifecycle,
3371
+ sessionId: record.sessionId,
3372
+ });
2991
3373
  return Object.freeze({
2992
3374
  ...cloneSessionRecord(record),
2993
3375
  physicalState: worktreeState.kind,
@@ -2995,9 +3377,35 @@ function assessGarbageCollection(record, now, staleAfterMs, worktrees) {
2995
3377
  suspicionReason,
2996
3378
  destructiveEligibility,
2997
3379
  destructiveEligibilityReason,
3380
+ lifecycle,
3381
+ nextActions,
3382
+ });
3383
+ }
3384
+ /** Evaluate GC's independent authorization through the canonical machine. */
3385
+ function projectGarbageCollectAuthorization(candidate) {
3386
+ return classifySessionLifecycle({
3387
+ sessionState: candidate.state,
3388
+ physicalState: candidate.physicalState,
3389
+ closeReadiness: "ready",
3390
+ blockers: [],
3391
+ terminalOperation: candidate.terminalOperation,
3392
+ ageSuspicious: candidate.suspicion === "age",
3393
+ gcAuthorized: candidate.destructiveEligibility === "eligible",
3394
+ phase: "termination",
2998
3395
  });
2999
3396
  }
3000
3397
  function closedGarbageCollectionAssessment(record, now) {
3398
+ const lifecycle = classifySessionLifecycle({
3399
+ sessionState: record.state,
3400
+ physicalState: "closed",
3401
+ closeReadiness: "ready",
3402
+ terminalOperation: record.terminalOperation,
3403
+ phase: "termination",
3404
+ });
3405
+ const nextActions = projectSessionLifecycleActions({
3406
+ classification: lifecycle,
3407
+ sessionId: record.sessionId,
3408
+ });
3001
3409
  return Object.freeze({
3002
3410
  ...cloneSessionRecord(record),
3003
3411
  physicalState: "closed",
@@ -3005,6 +3413,8 @@ function closedGarbageCollectionAssessment(record, now) {
3005
3413
  suspicionReason: "none",
3006
3414
  destructiveEligibility: "ineligible",
3007
3415
  destructiveEligibilityReason: "not-suspected",
3416
+ lifecycle,
3417
+ nextActions,
3008
3418
  });
3009
3419
  }
3010
3420
  function isDestructiveGcEligible(record, worktrees) {
@@ -3166,6 +3576,15 @@ function toCleanupBlocker(error) {
3166
3576
  recoveryHints: Object.freeze(recoveryHints),
3167
3577
  });
3168
3578
  }
3579
+ /** Keep the nested GC assessment on the same observed lifecycle snapshot. */
3580
+ function projectGarbageCollectCandidateLifecycle(candidate, lifecycle, blockers) {
3581
+ const nextActions = projectSessionLifecycleActions({
3582
+ classification: lifecycle,
3583
+ sessionId: candidate.sessionId,
3584
+ blockers,
3585
+ });
3586
+ return Object.freeze({ ...candidate, lifecycle, nextActions });
3587
+ }
3169
3588
  function toDiagnosticBlocker(error) {
3170
3589
  const blocker = toCleanupBlocker(error);
3171
3590
  return Object.freeze({