@ornexus/neocortex 4.60.71 → 4.60.73

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 (243) hide show
  1. package/dist/release-source.json +1 -1
  2. package/dist/sbom.cdx.json +1211 -1217
  3. package/install.ps1 +1 -1
  4. package/install.sh +1 -1
  5. package/package.json +2 -2
  6. package/packages/client/dist/adapters/adapter-registry.js +108 -1
  7. package/packages/client/dist/adapters/antigravity-adapter.js +77 -2
  8. package/packages/client/dist/adapters/claude-code-adapter.js +79 -3
  9. package/packages/client/dist/adapters/codex-adapter.js +80 -2
  10. package/packages/client/dist/adapters/cursor-adapter.js +115 -4
  11. package/packages/client/dist/adapters/gemini-adapter.js +71 -2
  12. package/packages/client/dist/adapters/grok-adapter.js +42 -2
  13. package/packages/client/dist/adapters/index.js +22 -1
  14. package/packages/client/dist/adapters/platform-detector.js +212 -1
  15. package/packages/client/dist/adapters/target-adapter.js +12 -0
  16. package/packages/client/dist/adapters/vscode-adapter.js +72 -2
  17. package/packages/client/dist/agent/refresh-stubs.js +417 -2
  18. package/packages/client/dist/agent/update-agent-yaml.js +102 -1
  19. package/packages/client/dist/agent/update-description.js +251 -1
  20. package/packages/client/dist/cache/crypto-utils.js +78 -1
  21. package/packages/client/dist/cache/encrypted-cache.js +108 -1
  22. package/packages/client/dist/cache/in-memory-asset-cache.js +73 -1
  23. package/packages/client/dist/cache/index.js +13 -1
  24. package/packages/client/dist/cache/protected-pi-boundary.js +44 -1
  25. package/packages/client/dist/checkpoint/checkpoint-client-reader.js +127 -2
  26. package/packages/client/dist/checkpoint/index.js +11 -1
  27. package/packages/client/dist/checkpoint/shared-checkpoint-types.js +32 -1
  28. package/packages/client/dist/cli.js +358 -5
  29. package/packages/client/dist/commands/activate.js +394 -8
  30. package/packages/client/dist/commands/architecture-answers.js +274 -7
  31. package/packages/client/dist/commands/architecture-memory-terminal.js +253 -4
  32. package/packages/client/dist/commands/architecture-non-ui-continuation.js +100 -1
  33. package/packages/client/dist/commands/architecture-root-binding.js +51 -1
  34. package/packages/client/dist/commands/cache-status.js +112 -2
  35. package/packages/client/dist/commands/config.js +88 -1
  36. package/packages/client/dist/commands/doctor.js +635 -2
  37. package/packages/client/dist/commands/invoke.js +7200 -143
  38. package/packages/client/dist/commands/legacy-epic-terminal.js +71 -1
  39. package/packages/client/dist/commands/legacy-story-branch.js +96 -1
  40. package/packages/client/dist/commands/legacy-story-pr.js +276 -1
  41. package/packages/client/dist/commands/mutation-qa-checkpoint.js +285 -5
  42. package/packages/client/dist/commands/mutation-qa-gate.js +54 -1
  43. package/packages/client/dist/commands/mutation-qa-git.js +41 -1
  44. package/packages/client/dist/commands/mutation-qa-policy.js +102 -1
  45. package/packages/client/dist/commands/mutation-qa-run.js +219 -3
  46. package/packages/client/dist/commands/mutation-qa-verdict.js +149 -1
  47. package/packages/client/dist/commands/mutation-qa.js +220 -1
  48. package/packages/client/dist/commands/native-agent.js +136 -2
  49. package/packages/client/dist/commands/planning-diagnosis.js +43 -1
  50. package/packages/client/dist/commands/planning-generation.js +53 -1
  51. package/packages/client/dist/commands/planning-memory.js +40 -1
  52. package/packages/client/dist/commands/planning-research.js +42 -1
  53. package/packages/client/dist/commands/private-session.js +75 -1
  54. package/packages/client/dist/commands/property-qa.js +139 -1
  55. package/packages/client/dist/commands/qa-control.js +16 -1
  56. package/packages/client/dist/commands/qa-scan-browser-launch.js +55 -1
  57. package/packages/client/dist/commands/qa-scan-browser-route.js +28 -1
  58. package/packages/client/dist/commands/qa-scan-capture.js +663 -5
  59. package/packages/client/dist/commands/qa-scan-discovery.js +376 -3
  60. package/packages/client/dist/commands/qa-scan-review.js +826 -5
  61. package/packages/client/dist/commands/qa-synthetic-backend-capture.js +266 -1
  62. package/packages/client/dist/commands/qa-synthetic-backend-review.js +297 -1
  63. package/packages/client/dist/commands/qa-synthetic-backend.js +340 -2
  64. package/packages/client/dist/commands/qa-synthetic-broker-port.js +85 -1
  65. package/packages/client/dist/commands/qa-synthetic-browser.js +39 -1
  66. package/packages/client/dist/commands/refactor-characterization.js +671 -21
  67. package/packages/client/dist/commands/refactor-commit.js +399 -13
  68. package/packages/client/dist/commands/refactor-control.js +142 -3
  69. package/packages/client/dist/commands/refactor-cpp-assertions.js +93 -3
  70. package/packages/client/dist/commands/refactor-review.js +283 -5
  71. package/packages/client/dist/commands/refactor-root.js +588 -7
  72. package/packages/client/dist/commands/refresh-memory.js +156 -3
  73. package/packages/client/dist/commands/shared-architecture-non-ui.js +24 -1
  74. package/packages/client/dist/commands/story-checkpoint.js +171 -1
  75. package/packages/client/dist/config/profile-directory.js +13 -1
  76. package/packages/client/dist/config/resolver-selection.js +278 -1
  77. package/packages/client/dist/config/secure-config.js +281 -12
  78. package/packages/client/dist/config.js +223 -2
  79. package/packages/client/dist/constants.js +25 -1
  80. package/packages/client/dist/context/context-collector.js +222 -2
  81. package/packages/client/dist/context/context-sanitizer.js +145 -1
  82. package/packages/client/dist/continuity/autonomous-governance.js +285 -1
  83. package/packages/client/dist/continuity/continuity-client-state-store.js +1587 -1
  84. package/packages/client/dist/continuity/invoke-hooks.js +129 -1
  85. package/packages/client/dist/continuity/migrations/001-initial-schema.js +12 -2
  86. package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +12 -2
  87. package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +12 -2
  88. package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +12 -2
  89. package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +12 -2
  90. package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +10 -2
  91. package/packages/client/dist/continuity/native-agent-store.js +69 -1
  92. package/packages/client/dist/continuity/native-agent-types.js +90 -1
  93. package/packages/client/dist/continuity/outbox.js +117 -1
  94. package/packages/client/dist/continuity/runner-bootstrap-policy.js +172 -1
  95. package/packages/client/dist/continuity/sqlite-store.d.ts +22 -1
  96. package/packages/client/dist/continuity/sqlite-store.js +2612 -81
  97. package/packages/client/dist/effects/invoke-effect-gateways.js +62 -1
  98. package/packages/client/dist/errors/error-messages.js +228 -2
  99. package/packages/client/dist/errors/frontmatter-diagnostics.js +125 -1
  100. package/packages/client/dist/evidence/collector.js +347 -1
  101. package/packages/client/dist/evidence/index.js +1 -1
  102. package/packages/client/dist/evidence/types.js +2 -0
  103. package/packages/client/dist/fs/atomic-state-replace.js +152 -1
  104. package/packages/client/dist/generated/command-registry.generated.js +4076 -1
  105. package/packages/client/dist/generated/public-projections.generated.js +149 -1
  106. package/packages/client/dist/git/direct-yolo-c01-retry.js +138 -4
  107. package/packages/client/dist/git/direct-yolo-checkpoint.js +1234 -4
  108. package/packages/client/dist/git/epic-operational-state.d.ts +198 -0
  109. package/packages/client/dist/git/epic-operational-state.js +672 -0
  110. package/packages/client/dist/git/git-lifecycle-step-ids.js +14 -1
  111. package/packages/client/dist/git/git-lifecycle-store.d.ts +9 -1
  112. package/packages/client/dist/git/git-lifecycle-store.js +1981 -9
  113. package/packages/client/dist/git/legacy-story-branch-checkpoint.js +259 -9
  114. package/packages/client/dist/git/legacy-story-branch-setup.js +348 -4
  115. package/packages/client/dist/git/legacy-story-pr.js +3538 -70
  116. package/packages/client/dist/git/secure-filesystem-boundary.js +182 -5
  117. package/packages/client/dist/graph-retrieval/pre-command-hook.js +284 -1
  118. package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +237 -1
  119. package/packages/client/dist/i18n/first-run.js +60 -2
  120. package/packages/client/dist/index.js +47 -1
  121. package/packages/client/dist/invocation/invoke-input.js +24 -1
  122. package/packages/client/dist/license/index.js +5 -1
  123. package/packages/client/dist/license/license-client.js +304 -1
  124. package/packages/client/dist/machine/fingerprint.js +163 -2
  125. package/packages/client/dist/machine/index.js +5 -1
  126. package/packages/client/dist/memory/project-memory-path-scope.js +62 -1
  127. package/packages/client/dist/memory/project-memory-writer.js +1007 -40
  128. package/packages/client/dist/memory/shared-project-memory-types.js +253 -2
  129. package/packages/client/dist/migrations/epic-lifecycle-backfill.d.ts +97 -0
  130. package/packages/client/dist/migrations/epic-lifecycle-backfill.js +1060 -0
  131. package/packages/client/dist/migrations/project-identity-capability.js +50 -1
  132. package/packages/client/dist/migrations/project-identity.js +493 -7
  133. package/packages/client/dist/migrations/state-json.d.ts +22 -0
  134. package/packages/client/dist/migrations/state-json.js +393 -4
  135. package/packages/client/dist/policy/architecture-policy.js +184 -2
  136. package/packages/client/dist/policy/index.js +7 -1
  137. package/packages/client/dist/policy/shared-policy-types.js +38 -0
  138. package/packages/client/dist/policy/subagent-approval-policy.js +55 -1
  139. package/packages/client/dist/private-execution/clean-baseline.js +279 -2
  140. package/packages/client/dist/private-execution/codex-executor.js +574 -4
  141. package/packages/client/dist/private-execution/codex-host-runtime.js +137 -1
  142. package/packages/client/dist/private-execution/codex-lifecycle-guard.js +97 -1
  143. package/packages/client/dist/private-execution/codex-scoped-permissions.js +128 -1
  144. package/packages/client/dist/private-execution/delivery.js +410 -13
  145. package/packages/client/dist/private-execution/diagnosis-root-review.js +294 -2
  146. package/packages/client/dist/private-execution/frozen-handoff.js +19 -6
  147. package/packages/client/dist/private-execution/implementation-validation.js +106 -2
  148. package/packages/client/dist/private-execution/isolated-admission.js +1432 -23
  149. package/packages/client/dist/private-execution/isolated-session.js +725 -5
  150. package/packages/client/dist/private-execution/isolated-workspace.js +122 -1
  151. package/packages/client/dist/private-execution/pact-consumer-runtime.js +409 -3
  152. package/packages/client/dist/private-execution/pact-test-source.js +258 -1
  153. package/packages/client/dist/private-execution/pact-toolchain.js +290 -3
  154. package/packages/client/dist/private-execution/pact-write-set.js +172 -1
  155. package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +406 -5
  156. package/packages/client/dist/private-execution/planning-generation-checkpoint.js +462 -5
  157. package/packages/client/dist/private-execution/planning-memory-checkpoint.js +139 -3
  158. package/packages/client/dist/private-execution/planning-research-checkpoint.js +325 -4
  159. package/packages/client/dist/private-execution/private-report-store.js +42 -1
  160. package/packages/client/dist/private-execution/property-test-candidate.js +103 -1
  161. package/packages/client/dist/private-execution/property-test-choices.js +147 -3
  162. package/packages/client/dist/private-execution/property-test-plan.js +271 -7
  163. package/packages/client/dist/private-execution/property-test-review.js +258 -4
  164. package/packages/client/dist/private-execution/property-test-runtime.js +372 -9
  165. package/packages/client/dist/private-execution/property-test-source.js +139 -1
  166. package/packages/client/dist/private-execution/root-checkpoint.js +80 -1
  167. package/packages/client/dist/private-execution/root-memory-git-evidence.js +135 -3
  168. package/packages/client/dist/private-execution/root-memory-recovery.js +93 -1
  169. package/packages/client/dist/private-execution/root-memory-request.js +29 -1
  170. package/packages/client/dist/private-execution/root-memory-scope.js +178 -1
  171. package/packages/client/dist/private-execution/root-memory-writer.js +102 -1
  172. package/packages/client/dist/private-execution/session-lease.js +63 -1
  173. package/packages/client/dist/private-execution/shared-private-admission-types.js +50 -1
  174. package/packages/client/dist/private-execution/shared-project-validation.js +66 -1
  175. package/packages/client/dist/private-execution/shared-root-memory-types.js +61 -1
  176. package/packages/client/dist/private-execution/ux-source-validation.js +372 -1
  177. package/packages/client/dist/private-execution/ux-write-set.js +191 -2
  178. package/packages/client/dist/registry/registry-reconciliation.js +234 -1
  179. package/packages/client/dist/resilience/circuit-breaker.js +170 -1
  180. package/packages/client/dist/resilience/degradation-manager.js +164 -1
  181. package/packages/client/dist/resilience/freshness-indicator.js +100 -1
  182. package/packages/client/dist/resilience/index.js +8 -1
  183. package/packages/client/dist/resilience/recovery-detector.js +74 -1
  184. package/packages/client/dist/resolvers/asset-resolver.js +13 -0
  185. package/packages/client/dist/resolvers/local-resolver.js +255 -8
  186. package/packages/client/dist/resolvers/remote-resolver.js +233 -1
  187. package/packages/client/dist/runner/binary.js +404 -1
  188. package/packages/client/dist/runner/cli.js +1571 -17
  189. package/packages/client/dist/runner/headless/adapters.js +49 -1
  190. package/packages/client/dist/runner/headless/claude-code-adapter.js +79 -1
  191. package/packages/client/dist/runner/headless/continuous-story-workspace.js +1628 -11
  192. package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +69 -1
  193. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +110 -2
  194. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +1236 -8
  195. package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +318 -2
  196. package/packages/client/dist/runner/headless/fake-adapter.js +62 -1
  197. package/packages/client/dist/runner/headless/managed-profile.js +132 -1
  198. package/packages/client/dist/runner/headless/opencode-adapter.js +145 -2
  199. package/packages/client/dist/runner/headless/prepared-story-bundle.js +43 -1
  200. package/packages/client/dist/runner/headless/resolver.js +231 -1
  201. package/packages/client/dist/runner/headless/run-yolo.js +885 -1
  202. package/packages/client/dist/runner/headless/run-yoloop.js +2574 -2
  203. package/packages/client/dist/runner/headless/types.js +574 -2
  204. package/packages/client/dist/runner/local-console.js +396 -13
  205. package/packages/client/dist/runner/portal-sync-worker.js +1487 -1
  206. package/packages/client/dist/runner/project-identity.js +106 -1
  207. package/packages/client/dist/runner/scheduler.js +995 -7
  208. package/packages/client/dist/runner-cli.js +18 -2
  209. package/packages/client/dist/state/invocation-shape.js +13 -1
  210. package/packages/client/dist/state/project-state-snapshot.d.ts +8 -0
  211. package/packages/client/dist/state/project-state-snapshot.js +1731 -1
  212. package/packages/client/dist/state/state-json-repair.js +785 -3
  213. package/packages/client/dist/state/structural-dag-projection.js +118 -1
  214. package/packages/client/dist/telemetry/index.js +5 -1
  215. package/packages/client/dist/telemetry/offline-queue.js +131 -1
  216. package/packages/client/dist/tier/index.js +5 -1
  217. package/packages/client/dist/tier/tier-aware-client.js +405 -1
  218. package/packages/client/dist/types/index.js +38 -1
  219. package/packages/client/dist/version.js +27 -1
  220. package/packages/client/dist/yolo/change-evidence-collector.js +126 -1
  221. package/packages/client/dist/yolo/invoke-envelope-capability.js +62 -1
  222. package/packages/client/dist/yolo/spec-envelope-collector.js +184 -5
  223. package/packages/client/dist/yoloop/discovery-hook.js +300 -2
  224. package/packages/client/dist/yoloop/index.js +8 -1
  225. package/packages/client/dist/yoloop/invoke-hooks.js +669 -12
  226. package/packages/client/dist/yoloop/shared-discover-epics.js +1007 -1
  227. package/packages/client/dist/yoloop/shared-yoloop-types.d.ts +65 -1
  228. package/packages/client/dist/yoloop/shared-yoloop-types.js +33 -1
  229. package/packages/client/dist/yoloop/target-normalization.js +180 -1
  230. package/packages/client/dist/yoloop/yoloop-client-state-store.d.ts +8 -0
  231. package/packages/client/dist/yoloop/yoloop-client-state-store.js +1920 -1
  232. package/packages/shared/dist/yolo/spec-envelope.js +3 -6
  233. package/targets-stubs/antigravity/gemini.md +1 -1
  234. package/targets-stubs/antigravity/skill/SKILL.md +1 -1
  235. package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
  236. package/targets-stubs/claude-code/neocortex-root.md +2 -2
  237. package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
  238. package/targets-stubs/claude-code/neocortex.md +2 -2
  239. package/targets-stubs/codex/AGENTS.md +1 -1
  240. package/targets-stubs/cursor/agent.md +2 -2
  241. package/targets-stubs/gemini-cli/agent.md +2 -2
  242. package/targets-stubs/opencode/neocortex-root.md +1 -1
  243. package/targets-stubs/vscode/neocortex.agent.md +2 -2
@@ -1 +1,1920 @@
1
- import{promises as u}from"node:fs";import{createHash as V}from"node:crypto";import{join as S,dirname as $}from"node:path";import{hostname as w}from"node:os";import{setSecureFilePermissions as q,setSecureDirPermissions as B}from"../config/secure-config.js";import{RECOVERY_HISTORY_CAP as m}from"./shared-yoloop-types.js";const j=".neocortex",g="yoloop-state.json",J="yoloop.lock",z="yoloop-state.mutation.lock",A="yoloop-session-rollover.json",P="yoloop-successor-transition.json",W=360*60*1e3,G=3e4,X=200,x=Object.freeze(["rollover-session-v1","successor-transition-v1","continuation-replan-v1"]),Z={warn:()=>{},info:()=>{},error:()=>{}};function f(t){const e=t.slice(0,10);return{version:"1.1",lastResetAt:t,storiesRunToday:0,iterationsToday:0,iterationsTodayDate:e,tokensUsedToday:0,lastRunAt:null,coolDownUntil:null,totalRunsAllTime:0,totalStoriesAllTime:0,currentSession:null,continuationPolicy:"p205-finite-resume-v2",pendingContinuationAuthorities:[],continuationReceipts:[],continuationRecoveryReceipts:[]}}function Re(t){return{lock:null,state:f(t),capabilities:x}}class ve{stateDir;statePath;lockPath;mutationLockPath;rolloverManifestPath;successorTransitionManifestPath;logger;constructor(e,s=Z){this.stateDir=S(e,j),this.statePath=S(this.stateDir,g),this.lockPath=S(this.stateDir,J),this.mutationLockPath=S(this.stateDir,z),this.rolloverManifestPath=S(this.stateDir,A),this.successorTransitionManifestPath=S(this.stateDir,P),this.logger=s}async readAll(e){const s=e??new Date().toISOString();await this.cleanupOrphanTempFiles(),await this.recoverSessionRolloverIfPresent(s),await this.recoverSuccessorTransitionIfPresent(s);const[n,o]=await Promise.all([this.readLock(),this.readState(s)]);return{lock:await this.prepareLockForSnapshot(n,s),state:o,capabilities:x}}async applyUpdate(e,s){const n=s??new Date().toISOString();await this.ensureDir(),await this.recoverSessionRolloverIfPresent(n),await this.recoverSuccessorTransitionIfPresent(n);for(const o of e.operations)await this.applyOperation(o,n)}async requestRunTransition(e,s){const n=s??new Date().toISOString();return await this.ensureDir(),this.withMutationLock(()=>this.transitionRun(e,n))}async requestLifecycleAction(e,s={},n){const o=n??new Date().toISOString();return await this.ensureDir(),this.withMutationLock(async()=>{const c=(await this.readState(o)).runControl;if(!c)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");const a=Date.parse(o).toString(36),i=s.operationId??`op-${e}-${a}`,l=s.idempotencyKey??`idem-${e}-${a}`,h=c.receipts.find(y=>y.operationId===i||y.idempotencyKey===l);if(h){if(h.operationId===i&&h.idempotencyKey===l)return h;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}const O=de(c.status,e);if(!O)throw new Error(T.has(c.status)?"YOLOOP_RUN_TERMINAL":"YOLOOP_RUN_TRANSITION_INVALID");return this.transitionRun(le({current:c,action:e,target:O,operationId:i,idempotencyKey:l,occurredAt:o}),o)})}async applyOperation(e,s){switch(e.type){case"acquire_lock":await this.withMutationLock(()=>this.acquireLock(e.lock,s));return;case"release_lock":await this.withMutationLock(()=>this.releaseLock(e));return;case"write_state":await this.withMutationLock(async()=>{const n=await this.readState(s),{runControl:o,...r}=e.state;await this.writeState({...r,...n.runControl?{version:"1.2",runControl:n.runControl}:{}})});return;case"advance_epic":await this.advanceEpic(e.nextEpicIndex,e.nextEpicId,s);return;case"no_op":this.logger.info({event:"yoloop_noop"},"yoloop server sent no-op");return;case"reserve_continuation_authority":await this.reserveContinuationAuthority(e.authority,s);return;case"acknowledge_continuation_authority":await this.acknowledgeContinuationAuthority(e.idempotencyKey,e.receipt,s);return;case"repair_continuation_authority":await this.repairContinuationAuthority(e.beforeHash,e.authority,e.receipt,s);return;case"switch_continuation_policy":await this.switchContinuationPolicy(e.policy,e.session,s);return;case"transition_run":await this.requestRunTransition(e,s);return;case"start_successor_run":await this.withMutationLock(()=>this.startSuccessorRun(e,s));return;case"commit_successor_transition":await this.withMutationLock(()=>this.commitSuccessorTransition(e,s));return;case"rollover_session":await this.withMutationLock(()=>this.rolloverSession(e,s));return;case"replan_continuation_session":await this.withMutationLock(()=>this.replanContinuationSession(e,s));return;case"recovery_attempted":await this.appendRecoveryAttempt(e.attempt,s);return;case"recovery_resolved":await this.resolveRecoveryAttempt(e.attempt_id,e.outcome,s);return;default:{const n=e;throw this.logger.error({event:"yoloop_unknown_op",op:n},"unknown yoloop operation rejected"),new Error("YOLOOP_STATE_OPERATION_UNSUPPORTED")}}}async reserveContinuationAuthority(e,s){const n=await this.readState(s),o=n.pendingContinuationAuthorities??[],r=typeof e.idempotencyKey=="string"?e.idempotencyKey:"";if(!r)throw new Error("YOLOOP_CONTINUATION_AUTHORITY_INVALID");const c=o.find(a=>a.idempotencyKey===r);if(c){if(d(c)===d(e))return;throw new Error("YOLOOP_CONTINUATION_AUTHORITY_CONFLICT")}if(o.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED");await this.writeState({...n,pendingContinuationAuthorities:[...o,e]})}async acknowledgeContinuationAuthority(e,s,n){const o=await this.readState(n),r=o.pendingContinuationAuthorities??[],c=r.findIndex(h=>h.idempotencyKey===e);if(c<0)throw new Error("YOLOOP_CONTINUATION_RECEIPT_ORPHAN");const a=r[c];if(s.idempotencyKey!==e||s.sessionId!==a.sessionId||JSON.stringify(s.storyIds)!==JSON.stringify(a.storyIds))throw new Error("YOLOOP_CONTINUATION_RECEIPT_MISMATCH");const i=r.filter((h,O)=>O!==c),l=o.continuationReceipts??[];await this.writeState({...o,pendingContinuationAuthorities:i,continuationReceipts:[...l.slice(-63),s]})}async repairContinuationAuthority(e,s,n,o){const r=await this.readState(o),c=r.pendingContinuationAuthorities??[],a=r.continuationRecoveryReceipts??[],i=typeof n.idempotencyKey=="string"?n.idempotencyKey:"",l=a.find(y=>y.idempotencyKey===i);if(l){if(d(l)===d(n))return;throw new Error("YOLOOP_CONTINUATION_RECOVERY_RECEIPT_CONFLICT")}if(c.length!==1||p("p208-legacy-authority-before-v1",c[0])!==e)throw new Error("YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH");const h=p("p208-legacy-authority-after-v1",s);if(n.beforeHash!==e||n.afterHash!==h)throw new Error("YOLOOP_CONTINUATION_RECOVERY_HASH_MISMATCH");const O={...n};if(delete O.idempotencyKey,!i||p("p208-legacy-authority-recovery-receipt-v1",O)!==i)throw new Error("YOLOOP_CONTINUATION_RECOVERY_RECEIPT_INVALID");await this.writeState({...r,pendingContinuationAuthorities:[s],continuationRecoveryReceipts:[...a.slice(-63),n]})}async switchContinuationPolicy(e,s,n){const o=await this.readState(n);if(o.currentSession||(o.pendingContinuationAuthorities?.length??0)>0)throw new Error("YOLOOP_POLICY_SWITCH_ACTIVE");try{await u.copyFile(this.statePath,`${this.statePath}.policy-backup`)}catch(r){if(r.code!=="ENOENT")throw r}await this.writeState({...o,continuationPolicy:e,continuationSession:s})}async replanContinuationSession(e,s){const{type:n,payloadHash:o,receipt:r,...c}=e;if(Q(c)!==e.payloadHash||!k(e.nextSession))throw new Error("YOLOOP_CONTINUATION_REPLAN_PAYLOAD_INVALID");const a={schemaVersion:"YoloopContinuationReplanReceipt/1.0",operationId:e.operationId,idempotencyKey:e.idempotencyKey,runId:e.expectedRunId,previousSessionHash:e.expectedSessionHash,nextSessionHash:p("p222-yoloop-replan-session-v1",e.nextSession),retiredAuthorityHash:e.expectedPendingHash,retiredAuthorityCount:e.expectedPendingHash===p("p222-yoloop-replan-pending-v1",[])?0:1,payloadHash:e.payloadHash,outcome:"graph-replanned",publicSafe:!0};if(d(e.receipt)!==d(a))throw new Error("YOLOOP_CONTINUATION_REPLAN_RECEIPT_INVALID");const i=await this.readState(s);if(await this.readLock()||i.currentSession!==null)throw new Error("YOLOOP_CONTINUATION_REPLAN_ACTIVE");const l=i.runControl;if(!l||l.runId!==e.expectedRunId||!["success","exhausted"].includes(l.status)||l.revision!==e.expectedRevision||l.latestSnapshotHash!==e.expectedSnapshotHash||i.continuationPolicy!=="p205-finite-resume-v2")throw new Error("YOLOOP_CONTINUATION_REPLAN_RUN_CONFLICT");const h=i.continuationReplanReceipts??[],O=h.find(_=>_.idempotencyKey===e.idempotencyKey||_.operationId===e.operationId);if(O){if(d(O)===d(a)&&d(i.continuationSession)===d(e.nextSession)&&(i.pendingContinuationAuthorities?.length??0)===0)return;throw new Error("YOLOOP_CONTINUATION_REPLAN_REPLAY_CONFLICT")}const y=i.continuationSession,I=i.pendingContinuationAuthorities??[];if(!k(y)||p("p222-yoloop-replan-session-v1",y)!==e.expectedSessionHash||p("p222-yoloop-replan-pending-v1",I)!==e.expectedPendingHash||I.length>1||I.some(_=>_.delegated!==!1||_.sessionId!==y.sessionId||!_.certificate||_.certificate.sessionId!==y.sessionId))throw new Error("YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT");await this.writeState({...i,continuationSession:e.nextSession,pendingContinuationAuthorities:[],continuationReplanReceipts:[...h.slice(-63),a]})}async appendRecoveryAttempt(e,s){const n=await this.readState(s),o=n.recovery_history??[];if(o.some(i=>i.attempted_at===e.attempted_at&&i.story_id===e.story_id)){this.logger.warn({event:"recovery_attempted_duplicate",story_id:e.story_id},"duplicate recovery_attempted op; skipping");return}const c=M([...o,e]),a={...n,recovery_history:c};await this.writeState(a),this.logger.info({event:"recovery_attempted_persisted",story_id:e.story_id,history_size:c.length},"recovery attempt persisted")}async resolveRecoveryAttempt(e,s,n){const o=await this.readState(n),r=o.recovery_history??[],c=r.findIndex(h=>h.recovery_step_id_tokenized===e);if(c===-1){this.logger.warn({event:"recovery_resolve_orphan",attempt_id:e},"recovery_resolved op had no matching attempt; skipping");return}const a=r[c];if(a.outcome===s&&a.finished_at){this.logger.info({event:"recovery_resolved_duplicate",attempt_id:e,outcome:s},"duplicate recovery_resolved op; skipping");return}const i=r.slice();i[c]={...a,outcome:s,finished_at:n};const l={...o,recovery_history:i};await this.writeState(l),this.logger.info({event:"recovery_resolved_persisted",attempt_id:e,outcome:s},"recovery attempt resolved")}async acquireLock(e,s){const n={...e,pid:process.pid,hostname:w(),ownerKind:"client",startedAt:e.startedAt||s,leaseId:e.leaseId??he(e.sessionId,s),leaseState:"active",leaseAcquiredAt:e.leaseAcquiredAt??s,leaseDurationMs:e.leaseDurationMs??W,leaseExpiresAt:e.leaseExpiresAt??e.ttlExpiresAt},o=JSON.stringify(n,null,2);try{const r=await u.open(this.lockPath,"wx");try{await r.writeFile(o,"utf8")}finally{await r.close()}this.trySetFilePerms(this.lockPath),this.logger.info({event:"yoloop_lock_acquired",pid:n.pid,sessionId:n.sessionId},"yoloop lock acquired");return}catch(r){const c=r;if(c.code!=="EEXIST")throw this.logger.error({event:"yoloop_lock_acquire_error",err:c.message},"failed to acquire yoloop lock"),r;const a=await this.readLock();if(a&&this.isSameClientSessionLock(a,n)){this.logger.info({event:"yoloop_lock_acquire_duplicate",sessionId:n.sessionId},"duplicate acquire_lock for current yoloop session; treating as no-op");return}if(!await this.maybeRecoverOrphanLock(s))throw this.logger.warn({event:"yoloop_lock_held"},"yoloop lock held by another live process"),r;const l=await u.open(this.lockPath,"wx");try{await l.writeFile(o,"utf8")}finally{await l.close()}this.trySetFilePerms(this.lockPath),this.logger.info({event:"yoloop_lock_acquired_after_recovery",pid:n.pid},"yoloop lock acquired after orphan recovery")}}async releaseLock(e){const s=await this.readState(new Date().toISOString()),n=await this.readLock();if(s.runControl){if(!e.expectedRunId||!e.expectedSessionId||!e.expectedLeaseId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(e.expectedRunId!==s.runControl.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(!n||e.expectedSessionId!==n.sessionId||e.expectedLeaseId!==n.leaseId)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}try{await u.unlink(this.lockPath),this.logger.info({event:"yoloop_lock_released"},"yoloop lock released")}catch(o){const r=o;if(r.code==="ENOENT"){this.logger.info({event:"yoloop_lock_release_noop"},"yoloop lock release noop (file absent)");return}this.logger.warn({event:"yoloop_lock_release_error",err:r.message},"unexpected error releasing yoloop lock")}}async readLock(){try{const e=await u.readFile(this.lockPath,"utf8"),s=JSON.parse(e),n=s.currentEpicId??s.epicId??"";return{pid:s.pid??0,hostname:s.hostname??"",...s.ownerKind?{ownerKind:s.ownerKind}:{},...s.ownerPidState?{ownerPidState:s.ownerPidState}:{},...typeof s.leaseId=="string"?{leaseId:s.leaseId}:{},...s.leaseState?{leaseState:s.leaseState}:{},...typeof s.leaseAcquiredAt=="string"?{leaseAcquiredAt:s.leaseAcquiredAt}:{},...typeof s.leaseDurationMs=="number"?{leaseDurationMs:s.leaseDurationMs}:{},...typeof s.leaseExpiresAt=="string"?{leaseExpiresAt:s.leaseExpiresAt}:{},startedAt:s.startedAt??"",sessionId:s.sessionId??"",mode:s.mode??"epic-id",inputPath:s.inputPath??"",epicQueue:s.epicQueue??(n?[n]:[]),currentEpicIndex:s.currentEpicIndex??0,currentEpicId:n,ttlExpiresAt:s.ttlExpiresAt??"",epicId:s.epicId??n}}catch{return null}}async readState(e){try{const s=await u.readFile(this.statePath,"utf8"),n=JSON.parse(s);return Oe(n,e)}catch(s){const n=s;return n.code!=="ENOENT"&&this.logger.warn({event:"yoloop_state_read_error",err:n.message},"yoloop state file unreadable; using defaults"),f(e)}}async writeState(e){const s=`${this.statePath}.tmp.${process.pid}.${Date.now()}`,n=JSON.stringify(e,null,2);try{const o=await u.open(s,"w",384);try{await o.writeFile(n,"utf8"),await o.sync()}finally{await o.close()}this.trySetFilePerms(s),await u.rename(s,this.statePath),await this.syncStateDirectory()}catch(o){const r=o;this.logger.error({event:"yoloop_state_write_error",err:r.message},"failed to write yoloop state");try{await u.unlink(s)}catch{}throw o}}async rolloverSession(e,s){E(e);const n=await this.readRolloverManifest();if(n){if(ae(n.operation,e)){if(n.operation.payloadHash!==e.payloadHash)throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT");if(n.phase==="applied")return;await this.completePreparedSessionRollover(n.operation,s);return}else if(n.phase==="prepared")throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT")}const o=await this.readState(s),r=await this.readLock();U(e,o,r),await this.writeRolloverManifest({schemaVersion:"YoloopSessionRolloverManifest/1.0",phase:"prepared",operation:e}),await this.completePreparedSessionRollover(e,s)}async completePreparedSessionRollover(e,s){const n=await this.readState(s);let o=d(n)===d(e.next);if(!o){const c=await this.readLock();U(e,n,c),await this.writeState(e.next),o=!0}const r=await this.readLock();if(e.expectedSessionId===null||e.expectedLeaseId===null){if(e.expectedSessionId!==null||e.expectedLeaseId!==null||r)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}else if(r){if(r.sessionId!==e.expectedSessionId||r.leaseId!==e.expectedLeaseId)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");await u.unlink(this.lockPath),await this.syncStateDirectory()}else if(!o)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");await this.writeRolloverManifest({schemaVersion:"YoloopSessionRolloverManifest/1.0",phase:"applied",operation:e}),this.logger.info({event:"yoloop_session_rollover_applied",runId:e.runId,operationId:e.operationId,coolDownUntil:e.coolDownUntil},"yoloop per-run-cap session rollover applied")}async recoverSessionRolloverIfPresent(e){try{await u.access(this.rolloverManifestPath)}catch(s){if(s.code==="ENOENT")return;throw s}await this.ensureDir(),await this.withMutationLock(async()=>{const s=await this.readRolloverManifest();s?.phase==="prepared"&&await this.completePreparedSessionRollover(s.operation,e)})}async readRolloverManifest(){try{const e=JSON.parse(await u.readFile(this.rolloverManifestPath,"utf8"));if(e.schemaVersion!=="YoloopSessionRolloverManifest/1.0"||e.phase!=="prepared"&&e.phase!=="applied"||!e.operation||e.operation.type!=="rollover_session")throw new Error("YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT");return E(e.operation),e}catch(e){if(e.code==="ENOENT")return null;throw e.message==="YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT"?e:new Error("YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT")}}async writeRolloverManifest(e){const s=`${this.rolloverManifestPath}.tmp.${process.pid}.${Date.now()}`,n=await u.open(s,"w",384);try{await n.writeFile(JSON.stringify(e,null,2),"utf8"),await n.sync()}finally{await n.close()}this.trySetFilePerms(s),await u.rename(s,this.rolloverManifestPath),await this.syncStateDirectory()}async commitSuccessorTransition(e,s){R(e);const n=await this.readSuccessorTransitionManifest();if(n){if(oe(n.operation,e)){if(n.operation.payloadHash!==e.payloadHash)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT");if(n.phase==="applied")return;await this.completePreparedSuccessorTransition(n.operation,s);return}if(n.phase==="prepared")throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT")}const o=await this.readState(s);if(v(e,o)){await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"applied",operation:e});return}H(e,o),await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"prepared",operation:e}),await this.completePreparedSuccessorTransition(e,s)}async completePreparedSuccessorTransition(e,s){let n=await this.readState(s);if(!v(e,n)){d(n.runControl)===d(e.successor.next)||(await this.startSuccessorRun(e.successor,s),n=await this.readState(s));const r=ie(e,n);await this.writeState(r),n=await this.readState(s)}if(!v(e,n))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT");await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"applied",operation:e}),this.logger.info({event:"yoloop_successor_transition_applied",runId:e.successor.runId,operationId:e.operationId},"yoloop successor transition applied")}async recoverSuccessorTransitionIfPresent(e){try{await u.access(this.successorTransitionManifestPath)}catch(s){if(s.code==="ENOENT")return;throw s}await this.ensureDir(),await this.withMutationLock(async()=>{const s=await this.readSuccessorTransitionManifest();s?.phase==="prepared"&&await this.completePreparedSuccessorTransition(s.operation,e)})}async readSuccessorTransitionManifest(){try{const e=JSON.parse(await u.readFile(this.successorTransitionManifestPath,"utf8"));if(e.schemaVersion!=="YoloopSuccessorTransitionManifest/1.0"||e.phase!=="prepared"&&e.phase!=="applied"||!e.operation||e.operation.type!=="commit_successor_transition")throw new Error("YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT");return R(e.operation),e}catch(e){if(e.code==="ENOENT")return null;throw e.message==="YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT"?e:new Error("YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT")}}async writeSuccessorTransitionManifest(e){const s=`${this.successorTransitionManifestPath}.tmp.${process.pid}.${Date.now()}`,n=await u.open(s,"w",384);try{await n.writeFile(JSON.stringify(e,null,2),"utf8"),await n.sync()}finally{await n.close()}this.trySetFilePerms(s),await u.rename(s,this.successorTransitionManifestPath),await this.syncStateDirectory()}async advanceEpic(e,s,n){const o=await this.readState(n);if(!o.currentSession){this.logger.warn({event:"yoloop_advance_no_session",nextEpicId:s},"advance_epic with no active session; skipping");return}const r={...o,currentSession:{...o.currentSession,currentEpicIndex:e}};await this.writeState(r),this.logger.info({event:"yoloop_epic_advance",nextEpicId:s,nextIndex:e},"yoloop advanced to next epic")}async ensureDir(){try{await u.mkdir(this.stateDir,{recursive:!0}),this.trySetDirPerms(this.stateDir)}catch(e){const s=e;throw this.logger.error({event:"yoloop_mkdir_error",err:s.message,path:this.stateDir},"failed to ensure .neocortex/ directory"),e}}async syncStateDirectory(){try{const e=await u.open(this.stateDir,"r");try{await e.sync()}finally{await e.close()}}catch(e){const s=e.code;if(s!=="EINVAL"&&s!=="EISDIR"&&s!=="ENOTSUP"&&s!=="EPERM")throw e}}async withMutationLock(e){let s=null;for(let n=0;n<X;n+=1)try{s=await u.open(this.mutationLockPath,"wx",384),await s.writeFile(JSON.stringify({pid:process.pid,acquiredAt:new Date().toISOString()})),await s.sync();break}catch(o){if(o.code!=="EEXIST")throw o;try{const r=await u.stat(this.mutationLockPath);if(Date.now()-r.mtimeMs>G){await u.unlink(this.mutationLockPath);continue}}catch{continue}await new Promise(r=>setTimeout(r,5))}if(!s)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");try{return await e()}finally{await s.close().catch(()=>{}),await u.unlink(this.mutationLockPath).catch(()=>{})}}async transitionRun(e,s){N(e);const n=await this.readState(s),o=n.runControl,r=o?.receipts.find(l=>l.operationId===e.operationId||l.idempotencyKey===e.idempotencyKey);if(r){if(r.operationId===e.operationId&&r.idempotencyKey===e.idempotencyKey&&r.payloadHash===e.payloadHash)return r;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}if(o&&o.runId!==e.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if((o?.revision??0)!==e.expectedRevision)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if((o?.latestSnapshotHash??null)!==e.expectedSnapshotHash)throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT");L(o,e);const i=e.next.receipts.at(-1);if(!i)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");return await this.writeState({...n,version:"1.2",runControl:e.next}),this.logger.info({event:"yoloop_run_transition_committed",runId:e.runId,operationId:e.operationId,revision:i.revision,status:i.status},"yoloop run transition committed"),i}async startSuccessorRun(e,s){Y(e);const n=await this.readState(s),o=n.runControl,r={schemaVersion:"YoloopRunPredecessor/1.0",runId:e.previousRunId,revision:e.expectedRevision,snapshotHash:e.expectedSnapshotHash,successorOperationId:e.operationId,publicSafe:!0},c=o?.receipts.find(l=>l.operationId===e.operationId||l.idempotencyKey===e.idempotencyKey);if(c){if(c.operationId===e.operationId&&c.idempotencyKey===e.idempotencyKey&&e.next.receipts.at(-1)?.payloadHash===c.payloadHash&&d(o?.predecessor)===d(r))return c;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}if(!o||o.runId!==e.previousRunId||!["exhausted","success"].includes(o.status)||o.revision!==e.expectedRevision||o.latestSnapshotHash!==e.expectedSnapshotHash||n.currentSession!==null)throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT");const a=e.next.receipts.at(-1);if(!a||d(e.next.predecessor)!==d(r))throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT");const i={type:"transition_run",runId:e.runId,operationId:e.operationId,idempotencyKey:e.idempotencyKey,expectedRevision:0,expectedSnapshotHash:null,payloadHash:a.payloadHash,next:e.next};return N(i),L(void 0,i),await this.writeState({...n,version:"1.2",runControl:e.next}),a}trySetFilePerms(e){try{q(e)}catch{}}trySetDirPerms(e){try{B(e)}catch{}}async prepareLockForSnapshot(e,s){if(!e)return null;if(e.ownerKind!=="client")return e;const n=K(e,s),o={...e,leaseState:n};if(n==="expired")return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"lease_expired"},"expired client-owned yoloop lock lease recovered before snapshot echo"),null;if(!o.pid||o.pid<=0)return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"invalid_pid"},"invalid PID in client-owned yoloop lock; recovered"),null;if(o.hostname&&o.hostname!==w())return{...o,ownerPidState:"unknown"};try{return process.kill(o.pid,0),{...o,ownerPidState:"live"}}catch{return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"dead_pid"},"stale client-owned yoloop lock recovered before snapshot echo"),null}}async maybeRecoverOrphanLock(e){const s=await this.readLock();if(!s)return!0;if(K(s,e)==="expired")return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"lease_expired"},"expired yoloop lock lease recovered"),!0;if(!s.pid||s.pid<=0)return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"invalid_pid"},"invalid PID in lock file; recovered"),!0;if(s.hostname&&s.hostname!==w())return this.logger.warn({event:"yoloop_lock_remote_host",ownerKind:s.ownerKind??"server",ownerPidState:"unknown"},"lock held by a remote host; honoring"),!1;try{return process.kill(s.pid,0),!1}catch{return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"dead_pid"},"orphan yoloop lock recovered"),!0}}async deleteLockBestEffort(){try{await u.unlink(this.lockPath)}catch{}}isSameClientSessionLock(e,s){return e.ownerKind==="client"&&e.sessionId===s.sessionId&&e.pid===process.pid&&e.hostname===w()}async cleanupOrphanTempFiles(){try{const e=$(this.statePath),s=await u.readdir(e),n=[`${g}.tmp.`,`${A}.tmp.`,`${P}.tmp.`],o=s.filter(r=>n.some(c=>r.startsWith(c)));for(const r of o)try{await u.unlink(S(e,r))}catch{}}catch{}}}function d(t){return t===null||typeof t!="object"?JSON.stringify(t):Array.isArray(t)?`[${t.map(d).join(",")}]`:`{${Object.entries(t).filter(([,e])=>e!==void 0).sort(([e],[s])=>e.localeCompare(s)).map(([e,s])=>`${JSON.stringify(e)}:${d(s)}`).join(",")}}`}function p(t,e){return V("sha256").update(`${t}\0${d(e)}`).digest("hex")}function Q(t){return p("p222-yoloop-continuation-replan-v1",t)}function k(t){if(!t||typeof t!="object"||Array.isArray(t))return!1;const{sessionId:e,...s}=t;return typeof e=="string"&&/^[a-f0-9]{64}$/.test(e)&&e===p("p205-yoloop-session-v1",s)}function ee(t){return p("p9390812898200217364-yoloop-session-v1",t)}function te(t){return p("p9390812898200217364-yoloop-rollover-v1",t)}function se(t){return p("p9390812898200217364-yoloop-successor-run-v1",t)}function ne(t){return p("p9046281030721413419-yoloop-successor-transition-v1",t)}function oe(t,e){return t.operationId===e.operationId&&t.idempotencyKey===e.idempotencyKey}function R(t){const e=/^[A-Za-z0-9._:-]{1,128}$/;Y(t.successor);const s={operationId:t.operationId,idempotencyKey:t.idempotencyKey,successor:t.successor,state:t.state,...t.acknowledgement?{acknowledgement:t.acknowledgement}:{},...t.reservation?{reservation:t.reservation}:{}},n=t.acknowledgement?.idempotencyKey,o=t.acknowledgement?.receipt.idempotencyKey,r=t.reservation?.authority.idempotencyKey;if(t.successor.type!=="start_successor_run"||!e.test(t.operationId)||!e.test(t.idempotencyKey)||t.operationId!==t.successor.operationId||t.idempotencyKey!==t.successor.idempotencyKey||!/^[a-f0-9]{64}$/.test(t.payloadHash)||t.payloadHash!==ne(s)||Buffer.byteLength(d(s),"utf8")>1048576||t.acknowledgement!==void 0&&(t.acknowledgement.type!=="acknowledge_continuation_authority"||typeof n!="string"||!e.test(n)||o!==n)||t.reservation!==void 0&&(t.reservation.type!=="reserve_continuation_authority"||typeof r!="string"||!e.test(r))||n!==void 0&&r===n)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT")}function H(t,e){if(R(t),!(d(e.runControl)===d(t.successor.next))){const n=e.runControl;if(!n||n.runId!==t.successor.previousRunId||!["exhausted","success"].includes(n.status)||n.revision!==t.successor.expectedRevision||n.latestSnapshotHash!==t.successor.expectedSnapshotHash||e.currentSession!==null)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT")}re(t,e)}function re(t,e){const s=e.pendingContinuationAuthorities??[],n=e.continuationReceipts??[],o=t.acknowledgement;if(o){const l=s.find(y=>y.idempotencyKey===o.idempotencyKey);if(!l||o.receipt.sessionId!==l.sessionId||d(o.receipt.storyIds)!==d(l.storyIds))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");const h=s.filter(y=>y.idempotencyKey!==o.idempotencyKey),O=[...n.slice(-63),o.receipt];if(d(t.state.pendingContinuationAuthorities??[])!==d(h)||d(t.state.continuationReceipts??[])!==d(O))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT")}else if(d(t.state.pendingContinuationAuthorities??[])!==d(s)||d(t.state.continuationReceipts??[])!==d(n))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");const r=t.reservation?.authority;if(!r)return;const c=String(r.idempotencyKey??""),a=t.state.pendingContinuationAuthorities??[],i=a.find(l=>l.idempotencyKey===c);if(i&&d(i)!==d(r))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");if(!i&&a.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED")}function ie(t,e){H(t,e);const s=t.reservation?.authority,n=[...t.state.pendingContinuationAuthorities??[]];return s&&!n.some(o=>o.idempotencyKey===s.idempotencyKey)&&n.push(s),{...t.state,version:"1.2",runControl:t.successor.next,...s||t.state.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:n}:{}}}function v(t,e){const s=t.reservation?.authority,n=[...t.state.pendingContinuationAuthorities??[]];s&&!n.some(r=>r.idempotencyKey===s.idempotencyKey)&&n.push(s);const o={...t.state,version:"1.2",runControl:t.successor.next,...s||t.state.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:n}:{}};return d(e)===d(o)}function ae(t,e){return t.operationId===e.operationId&&t.idempotencyKey===e.idempotencyKey}function E(t){const e=/^[A-Za-z0-9._:-]{1,128}$/,s={runId:t.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,expectedSessionId:t.expectedSessionId,expectedSessionFingerprint:t.expectedSessionFingerprint,expectedLeaseId:t.expectedLeaseId,coolDownUntil:t.coolDownUntil,reason:t.reason,exhaustedRun:t.exhaustedRun,next:t.next};if(!e.test(t.runId)||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.expectedSessionFingerprint)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<0||!Number.isFinite(Date.parse(t.coolDownUntil))||!["YOLOOP_PER_RUN_CAP_REACHED","YOLOOP_SESSION_TIMEOUT"].includes(t.reason)||t.payloadHash!==te(s)||t.receipt.schemaVersion!=="YoloopSessionRolloverReceipt/1.0"||t.receipt.runId!==t.runId||t.receipt.operationId!==t.operationId||t.receipt.idempotencyKey!==t.idempotencyKey||t.receipt.payloadHash!==t.payloadHash||t.receipt.coolDownUntil!==t.coolDownUntil||t.receipt.revision!==(t.exhaustedRun?.revision??0)||t.receipt.snapshotHash!==(t.exhaustedRun?.latestSnapshotHash??null)||t.receipt.status!=="exhausted"||t.receipt.publicSafe!==!0)throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT");if(t.expectedSessionId===null!=(t.expectedLeaseId===null)||t.expectedSessionId!==null&&!e.test(t.expectedSessionId)||t.expectedLeaseId!==null&&!e.test(t.expectedLeaseId))throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}function Y(t){const e=/^[A-Za-z0-9._:-]{1,128}$/,s={runId:t.runId,previousRunId:t.previousRunId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,next:t.next};if(!e.test(t.runId)||!e.test(t.previousRunId)||t.runId===t.previousRunId||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<1||t.payloadHash!==se(s))throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT")}function U(t,e,s){E(t);const n=e.currentSession;if(!n||ee(n)!==t.expectedSessionFingerprint)throw new Error("YOLOOP_SESSION_ROLLOVER_SESSION_CONFLICT");const o=e.runControl;if((o?.revision??0)!==t.expectedRevision||(o?.latestSnapshotHash??null)!==t.expectedSnapshotHash||o&&o.runId!==t.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(t.expectedSessionId===null||t.expectedLeaseId===null){if(s)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}else if(!s||s.sessionId!==t.expectedSessionId||s.leaseId!==t.expectedLeaseId)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");const r=t.exhaustedRun;if(o)if(o.status==="exhausted"){if(d(r)!==d(o))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}else{if(!r||r.status!=="exhausted")throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT");const a=r.receipts.at(-1);if(!a)throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT");const i={type:"transition_run",runId:o.runId,operationId:a.operationId,idempotencyKey:a.idempotencyKey,expectedRevision:o.revision,expectedSnapshotHash:o.latestSnapshotHash,payloadHash:a.payloadHash,next:r};N(i),L(o,i)}else{const a=r?.receipts[0],i=r?.receipts[1];if(r&&(r.runId!==t.runId||r.status!=="exhausted"||r.revision!==2||r.events.length!==2||r.progressSnapshots.length!==4||r.receipts.length!==2||a?.runId!==t.runId||a.revision!==1||a.status!=="created"||i?.runId!==t.runId||i.revision!==2||i.status!=="exhausted"||i.operationId!==`${t.operationId}:run`||i.idempotencyKey!==`${t.idempotencyKey}:run`||r.latestSnapshotHash!==i.snapshotHash||r.predecessor!==void 0))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}const c={...e,version:"1.2",coolDownUntil:t.coolDownUntil,currentSession:null,...r?{runControl:r}:{}};if(d(c)!==d(t.next))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}function D(t){const{receipts:e,...s}=t.next;return p("p220-yoloop-run-transition-payload-v1",{runId:t.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,next:s})}const T=new Set(["success","partial","blocked","failure","exhausted","cancelled"]),ce={created:["running","cancelling","exhausted"],running:["pausing","cancelling","success","partial","blocked","failure","exhausted"],pausing:["paused","cancelling","failure","exhausted"],paused:["resuming","cancelling","exhausted"],resuming:["running","cancelling","failure","exhausted"],cancelling:["cancelled","failure","exhausted"]};function de(t,e){return e==="pause"?t==="running"?"pausing":null:e==="resume"?t==="paused"?"resuming":null:T.has(t)||t==="cancelling"?null:"cancelling"}function le(t){const e=t.current.revision+1,s=t.action==="cancel"?["YOLOOP_RUN_CANCEL_REQUESTED"]:[],n={schemaVersion:"YoloopOperationalBudgetSummary/1.0",limits:t.current.operationalBudget.limits,usage:t.current.usage,exhaustedDimension:null},o={schemaVersion:"ProgressSnapshot/1.0",runId:t.current.runId,sequence:t.current.progressSnapshots.length+1,revision:e,operationId:t.operationId,boundary:t.action,phase:"before",status:t.current.status,occurredAt:t.occurredAt,parentSnapshotHash:t.current.latestSnapshotHash,budget:n,reasonCodes:s,publicSafe:!0},r=p("p220-yoloop-progress-snapshot-id-v1",{runId:o.runId,operationId:o.operationId,sequence:o.sequence,phase:o.phase}),c={...o,snapshotId:r,snapshotHash:p("p220-yoloop-progress-snapshot-v1",{...o,snapshotId:r})},a={...o,sequence:c.sequence+1,phase:"after",status:t.target,parentSnapshotHash:c.snapshotHash},i=p("p220-yoloop-progress-snapshot-id-v1",{runId:a.runId,operationId:a.operationId,sequence:a.sequence,phase:a.phase}),l={...a,snapshotId:i,snapshotHash:p("p220-yoloop-progress-snapshot-v1",{...a,snapshotId:i})},h={schemaVersion:"YoloopRunEvent/1.0",runId:t.current.runId,operationId:t.operationId,kind:"intent",sequence:t.current.events.length+1,revision:e,occurredAt:t.occurredAt,fromStatus:t.current.status,toStatus:t.target,reasonCodes:s,budgetDimension:null,parentEventHash:t.current.events.at(-1)?.eventHash??null,publicSafe:!0},O=p("p220-yoloop-run-event-id-v1",{runId:h.runId,operationId:h.operationId,sequence:h.sequence}),y={...h,eventId:O,eventHash:p("p220-yoloop-run-event-v1",{...h,eventId:O})},I={...t.current,revision:e,status:t.target,events:[...t.current.events,y],progressSnapshots:[...t.current.progressSnapshots,c,l],latestSnapshotHash:l.snapshotHash,receipts:[...t.current.receipts]},_={runId:t.current.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.current.revision,expectedSnapshotHash:t.current.latestSnapshotHash,next:I},C=D(_),b={schemaVersion:"YoloopRunTransitionReceipt/1.0",runId:t.current.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,payloadHash:C,revision:e,snapshotHash:l.snapshotHash,status:t.target};return{type:"transition_run",..._,payloadHash:C,next:{...I,receipts:[...I.receipts,b]}}}function N(t){const e=/^[A-Za-z0-9._:-]{1,128}$/;if(!e.test(t.runId)||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<0)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");if(t.payloadHash!==D(t))throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}function L(t,e){const s=e.next,n=t?.events??[],o=t?.progressSnapshots??[],r=t?.receipts??[];if(s.schemaVersion!=="YoloopRunControl/1.0"||s.runId!==e.runId||s.revision!==e.expectedRevision+1||s.events.length!==n.length+1||s.progressSnapshots.length!==o.length+2||s.receipts.length!==r.length+1||d(s.events.slice(0,n.length))!==d(n)||d(s.progressSnapshots.slice(0,o.length))!==d(o)||d(s.receipts.slice(0,r.length))!==d(r))throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(t){if(T.has(t.status))throw new Error("YOLOOP_RUN_TERMINAL");const h=s.progressSnapshots.at(-2)?.boundary,O=t.status===s.status&&(h==="budget"||h==="retry");if(!(ce[t.status]??[]).includes(s.status)&&!O)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(t.startedAt!==s.startedAt||t.deadlineAt!==s.deadlineAt)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(d(t.predecessor)!==d(s.predecessor))throw new Error("YOLOOP_RUN_TRANSITION_INVALID")}else if(s.status!=="created")throw new Error("YOLOOP_RUN_TRANSITION_INVALID");const c=s.progressSnapshots.at(-2),a=s.progressSnapshots.at(-1),i=s.events.at(-1),l=s.receipts.at(-1);if(!c||!a||!i||!l||c.phase!=="before"||a.phase!=="after"||c.operationId!==e.operationId||a.operationId!==e.operationId||i.operationId!==e.operationId||c.runId!==e.runId||a.runId!==e.runId||i.runId!==e.runId||c.revision!==s.revision||a.revision!==s.revision||i.revision!==s.revision||c.parentSnapshotHash!==(t?.latestSnapshotHash??null)||a.parentSnapshotHash!==c.snapshotHash||s.latestSnapshotHash!==a.snapshotHash||l.schemaVersion!=="YoloopRunTransitionReceipt/1.0"||l.runId!==e.runId||l.operationId!==e.operationId||l.idempotencyKey!==e.idempotencyKey||l.payloadHash!==e.payloadHash||l.revision!==s.revision||l.snapshotHash!==s.latestSnapshotHash||l.status!==s.status||Date.parse(s.deadlineAt)<=Date.parse(s.startedAt))throw new Error("YOLOOP_RUN_TRANSITION_INVALID");F(c),F(a),ue(i,n.at(-1)?.eventHash??null)}function F(t){const{snapshotHash:e,...s}=t;if(e!==p("p220-yoloop-progress-snapshot-v1",s))throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT")}function ue(t,e){const{eventHash:s,...n}=t;if(t.parentEventHash!==e||s!==p("p220-yoloop-run-event-v1",n))throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT")}function he(t,e){return`lease-${pe(`${t}:${e}`)}`}function pe(t){let e=2166136261;for(let s=0;s<t.length;s+=1)e^=t.charCodeAt(s),e=Math.imul(e,16777619)>>>0;return e.toString(36).padStart(7,"0")}function K(t,e){const s=t.leaseExpiresAt??t.ttlExpiresAt,n=Date.parse(s),o=Date.parse(e);return!Number.isFinite(n)||!Number.isFinite(o)?"unknown":n<=o?"expired":"active"}function Oe(t,e){const s=f(e);return{...t,version:t.version==="1.2"?"1.2":"1.1",lastResetAt:t.lastResetAt??s.lastResetAt,storiesRunToday:t.storiesRunToday??0,iterationsToday:t.iterationsToday??0,iterationsTodayDate:t.iterationsTodayDate??s.iterationsTodayDate,tokensUsedToday:t.tokensUsedToday??0,lastRunAt:t.lastRunAt??null,coolDownUntil:t.coolDownUntil??null,totalRunsAllTime:t.totalRunsAllTime??0,totalStoriesAllTime:t.totalStoriesAllTime??0,currentSession:t.currentSession??null,...t.runControl!==void 0?{runControl:t.runControl}:{},...t.continuationPolicy!==void 0?{continuationPolicy:t.continuationPolicy}:{},...t.continuationSession!==void 0?{continuationSession:t.continuationSession}:{},...t.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:t.pendingContinuationAuthorities}:{},...t.continuationReceipts!==void 0?{continuationReceipts:t.continuationReceipts}:{},...t.continuationRecoveryReceipts!==void 0?{continuationRecoveryReceipts:t.continuationRecoveryReceipts}:{},...t.continuationReplanReceipts!==void 0?{continuationReplanReceipts:t.continuationReplanReceipts}:{},recovery_history:Array.isArray(t.recovery_history)?M(t.recovery_history):[]}}function M(t){return t.length<=m?t:t.slice(t.length-m)}export{x as YOLOOP_PERSISTENCE_CAPABILITIES,ve as YoloopClientStateStore,Q as buildYoloopContinuationReplanPayloadHash,D as buildYoloopRunTransitionPayloadHash,te as buildYoloopSessionRolloverPayloadHash,se as buildYoloopSuccessorRunPayloadHash,ne as buildYoloopSuccessorTransitionPayloadHash,M as capRecoveryHistory,f as createDefaultCounters,Re as createDefaultState,ee as fingerprintYoloopSession};
1
+ /**
2
+ * Epic P101.06 -- YoloopClientStateStore.
3
+ *
4
+ * Client-side counterpart of the (soon-to-be-deleted) server YoloopStateStore.
5
+ * Owns `.neocortex/yoloop.lock` and `.neocortex/yoloop-state.json` in the
6
+ * user's local project workspace.
7
+ *
8
+ * The client is the ONLY filesystem writer for yoloop state after P101
9
+ * lands. The server is pure: it reads state echoed by the client via
10
+ * `stateSnapshot.yoloop` and returns `metadata.yoloopStateUpdate` with an
11
+ * operation log the client applies here.
12
+ *
13
+ * Atomicity guarantees (P96.03):
14
+ * - acquireLock uses fs.open(... 'wx') -- POSIX exclusive create
15
+ * - writeState uses tmp + rename (POSIX atomic rename)
16
+ *
17
+ * Fail-open philosophy (P61.04 / P66.01):
18
+ * - chmod / ACL failure -> warn log, NEVER blocks operation
19
+ * - release on missing lock is idempotent (no-op)
20
+ * - readAll returns defaults on any I/O or parse error
21
+ *
22
+ * Cross-platform: Unix chmod + Windows icacls via existing
23
+ * setSecureFilePermissions helper from secure-config.ts.
24
+ */
25
+ import { promises as fs } from 'node:fs';
26
+ import { existsSync } from 'node:fs';
27
+ import { createHash } from 'node:crypto';
28
+ import { join, dirname } from 'node:path';
29
+ import { hostname } from 'node:os';
30
+ import { setSecureFilePermissions, setSecureDirPermissions } from '../config/secure-config.js';
31
+ import { RECOVERY_HISTORY_CAP } from './shared-yoloop-types.js';
32
+ import { applyCrossEpicContinuationReconciliation, epicOperationalCanonicalJson, epicOperationalHash, mutateEpicOperationalState, readEpicOperationalState, } from '../git/epic-operational-state.js';
33
+ const NEOCORTEX_DIR = '.neocortex';
34
+ const STATE_FILE = 'yoloop-state.json';
35
+ const LOCK_FILE = 'yoloop.lock';
36
+ const MUTATION_LOCK_FILE = 'yoloop-state.mutation.lock';
37
+ const ROLLOVER_MANIFEST_FILE = 'yoloop-session-rollover.json';
38
+ const SUCCESSOR_TRANSITION_MANIFEST_FILE = 'yoloop-successor-transition.json';
39
+ const DEFAULT_LOCK_LEASE_MS = 6 * 60 * 60 * 1000;
40
+ const MUTATION_LOCK_STALE_MS = 30_000;
41
+ const MUTATION_LOCK_RETRIES = 200;
42
+ export const YOLOOP_PERSISTENCE_CAPABILITIES = Object.freeze([
43
+ 'rollover-session-v1',
44
+ 'successor-transition-v1',
45
+ 'continuation-replan-v1',
46
+ ]);
47
+ const NOOP_LOGGER = {
48
+ warn: () => { },
49
+ info: () => { },
50
+ error: () => { },
51
+ };
52
+ /** Build default counters for a fresh project (no state file present). */
53
+ export function createDefaultCounters(nowIso) {
54
+ const dateOnly = nowIso.slice(0, 10);
55
+ return {
56
+ version: '1.1',
57
+ lastResetAt: nowIso,
58
+ storiesRunToday: 0,
59
+ iterationsToday: 0,
60
+ iterationsTodayDate: dateOnly,
61
+ tokensUsedToday: 0,
62
+ lastRunAt: null,
63
+ coolDownUntil: null,
64
+ totalRunsAllTime: 0,
65
+ totalStoriesAllTime: 0,
66
+ currentSession: null,
67
+ continuationPolicy: 'p205-finite-resume-v2',
68
+ pendingContinuationAuthorities: [],
69
+ continuationReceipts: [],
70
+ continuationRecoveryReceipts: [],
71
+ };
72
+ }
73
+ /** Build default state envelope (no lock, default counters). */
74
+ export function createDefaultState(nowIso) {
75
+ return {
76
+ lock: null,
77
+ state: createDefaultCounters(nowIso),
78
+ capabilities: YOLOOP_PERSISTENCE_CAPABILITIES,
79
+ };
80
+ }
81
+ export class YoloopClientStateStore {
82
+ projectRoot;
83
+ stateDir;
84
+ statePath;
85
+ lockPath;
86
+ mutationLockPath;
87
+ rolloverManifestPath;
88
+ successorTransitionManifestPath;
89
+ logger;
90
+ constructor(projectRoot, logger = NOOP_LOGGER) {
91
+ this.projectRoot = projectRoot;
92
+ this.stateDir = join(projectRoot, NEOCORTEX_DIR);
93
+ this.statePath = join(this.stateDir, STATE_FILE);
94
+ this.lockPath = join(this.stateDir, LOCK_FILE);
95
+ this.mutationLockPath = join(this.stateDir, MUTATION_LOCK_FILE);
96
+ this.rolloverManifestPath = join(this.stateDir, ROLLOVER_MANIFEST_FILE);
97
+ this.successorTransitionManifestPath = join(this.stateDir, SUCCESSOR_TRANSITION_MANIFEST_FILE);
98
+ this.logger = logger;
99
+ }
100
+ // ── Public read/apply API (P101.06 AC3 + AC4) ───────────────────────
101
+ /**
102
+ * Read lock + state in a single call. Used by invoke.ts pre-request hook
103
+ * to populate `stateSnapshot.yoloop` for the server. Handles ENOENT
104
+ * gracefully by returning default state.
105
+ */
106
+ async readAll(nowIso) {
107
+ const now = nowIso ?? new Date().toISOString();
108
+ const common = this.readCommonAuthorityIfPresent();
109
+ if (common) {
110
+ return {
111
+ lock: common.yoloop.lock,
112
+ state: common.yoloop.state,
113
+ capabilities: common.yoloop.capabilities,
114
+ };
115
+ }
116
+ await this.cleanupOrphanTempFiles();
117
+ await this.recoverSessionRolloverIfPresent(now);
118
+ await this.recoverSuccessorTransitionIfPresent(now);
119
+ const [rawLock, state] = await Promise.all([this.readLock(), this.readState(now)]);
120
+ const lock = await this.prepareLockForSnapshot(rawLock, now);
121
+ return { lock, state, capabilities: YOLOOP_PERSISTENCE_CAPABILITIES };
122
+ }
123
+ /**
124
+ * Apply server-provided operations in order. Called by invoke.ts
125
+ * post-response hook. Operations are applied atomically per-op; if one
126
+ * fails, subsequent ops are NOT applied and the caller (invoke.ts)
127
+ * decides whether to log-and-continue or abort.
128
+ *
129
+ * Contract: partial application is safe because each operation is a
130
+ * single atomic filesystem mutation. Next invoke's readAll() reconciles.
131
+ */
132
+ async applyUpdate(update, nowIso) {
133
+ const now = nowIso ?? new Date().toISOString();
134
+ if (this.readCommonAuthorityIfPresent()) {
135
+ for (const op of update.operations)
136
+ await this.applyCommonOperation(op, now);
137
+ return;
138
+ }
139
+ await this.ensureDir();
140
+ await this.recoverSessionRolloverIfPresent(now);
141
+ await this.recoverSuccessorTransitionIfPresent(now);
142
+ for (const op of update.operations) {
143
+ await this.applyOperation(op, now);
144
+ }
145
+ }
146
+ /**
147
+ * Local typed lifecycle-control API. Server transitions and local
148
+ * pause/resume/cancel requests share this serialized CAS boundary.
149
+ */
150
+ async requestRunTransition(operation, nowIso) {
151
+ const now = nowIso ?? new Date().toISOString();
152
+ await this.ensureDir();
153
+ return this.withMutationLock(() => this.transitionRun(operation, now));
154
+ }
155
+ /** Persist a cooperative control intent before the next orchestration effect. */
156
+ async requestLifecycleAction(action, options = {}, nowIso) {
157
+ const now = nowIso ?? new Date().toISOString();
158
+ await this.ensureDir();
159
+ return this.withMutationLock(async () => {
160
+ const state = await this.readState(now);
161
+ const current = state.runControl;
162
+ if (!current)
163
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
164
+ const suffix = Date.parse(now).toString(36);
165
+ const operationId = options.operationId ?? `op-${action}-${suffix}`;
166
+ const idempotencyKey = options.idempotencyKey ?? `idem-${action}-${suffix}`;
167
+ const replay = current.receipts.find((receipt) => receipt.operationId === operationId || receipt.idempotencyKey === idempotencyKey);
168
+ if (replay) {
169
+ if (replay.operationId === operationId && replay.idempotencyKey === idempotencyKey)
170
+ return replay;
171
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
172
+ }
173
+ const target = localLifecycleRequestTarget(current.status, action);
174
+ if (!target) {
175
+ throw new Error(TERMINAL_RUN_STATUSES.has(current.status)
176
+ ? 'YOLOOP_RUN_TERMINAL'
177
+ : 'YOLOOP_RUN_TRANSITION_INVALID');
178
+ }
179
+ return this.transitionRun(buildLocalLifecycleOperation({
180
+ current, action, target, operationId, idempotencyKey, occurredAt: now,
181
+ }), now);
182
+ });
183
+ }
184
+ // ── Per-operation dispatch ──────────────────────────────────────────
185
+ async applyOperation(op, nowIso) {
186
+ switch (op.type) {
187
+ case 'acquire_lock':
188
+ await this.withMutationLock(() => this.acquireLock(op.lock, nowIso));
189
+ return;
190
+ case 'release_lock':
191
+ await this.withMutationLock(() => this.releaseLock(op));
192
+ return;
193
+ case 'write_state':
194
+ await this.withMutationLock(async () => {
195
+ const current = await this.readState(nowIso);
196
+ const { runControl: _untrustedRunControl, ...legacyCounters } = op.state;
197
+ await this.writeState({
198
+ ...legacyCounters,
199
+ ...(current.runControl
200
+ ? { version: '1.2', runControl: current.runControl }
201
+ : {}),
202
+ });
203
+ });
204
+ return;
205
+ case 'advance_epic':
206
+ await this.advanceEpic(op.nextEpicIndex, op.nextEpicId, nowIso);
207
+ return;
208
+ case 'no_op':
209
+ this.logger.info({ event: 'yoloop_noop' }, 'yoloop server sent no-op');
210
+ return;
211
+ case 'reserve_continuation_authority':
212
+ await this.reserveContinuationAuthority(op.authority, nowIso);
213
+ return;
214
+ case 'acknowledge_continuation_authority':
215
+ await this.acknowledgeContinuationAuthority(op.idempotencyKey, op.receipt, nowIso);
216
+ return;
217
+ case 'repair_continuation_authority':
218
+ await this.repairContinuationAuthority(op.beforeHash, op.authority, op.receipt, nowIso);
219
+ return;
220
+ case 'switch_continuation_policy':
221
+ await this.switchContinuationPolicy(op.policy, op.session, nowIso);
222
+ return;
223
+ case 'transition_run':
224
+ await this.requestRunTransition(op, nowIso);
225
+ return;
226
+ case 'start_successor_run':
227
+ await this.withMutationLock(() => this.startSuccessorRun(op, nowIso));
228
+ return;
229
+ case 'commit_successor_transition':
230
+ await this.withMutationLock(() => this.commitSuccessorTransition(op, nowIso));
231
+ return;
232
+ case 'rollover_session':
233
+ await this.withMutationLock(() => this.rolloverSession(op, nowIso));
234
+ return;
235
+ case 'replan_continuation_session':
236
+ await this.withMutationLock(() => this.replanContinuationSession(op, nowIso));
237
+ return;
238
+ case 'reconcile_cross_epic_continuation':
239
+ await applyCrossEpicContinuationReconciliation(this.projectRoot, op);
240
+ return;
241
+ // ── P104.06: self-healing recovery audit ops ──────────────────────
242
+ case 'recovery_attempted':
243
+ await this.appendRecoveryAttempt(op.attempt, nowIso);
244
+ return;
245
+ case 'recovery_resolved':
246
+ await this.resolveRecoveryAttempt(op.attempt_id, op.outcome, nowIso);
247
+ return;
248
+ default: {
249
+ // Exhaustiveness check; never reached for typed inputs but
250
+ // defensive against forward-compat shape drift.
251
+ const _exhaustive = op;
252
+ this.logger.error({ event: 'yoloop_unknown_op', op: _exhaustive }, 'unknown yoloop operation rejected');
253
+ throw new Error('YOLOOP_STATE_OPERATION_UNSUPPORTED');
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * Runtime v2 is authoritative once present. Missing/non-Git/v1 projects keep
259
+ * the legacy checkout-local adapter; a corrupt v2 never falls back.
260
+ */
261
+ readCommonAuthorityIfPresent() {
262
+ try {
263
+ const operational = readEpicOperationalState(this.projectRoot).operational;
264
+ if (operational.activeJournal !== null)
265
+ throw new Error('epic-operational-recovery-required');
266
+ return operational;
267
+ }
268
+ catch (error) {
269
+ if (error instanceof Error && [
270
+ 'git-lifecycle-runtime-missing',
271
+ 'epic-operational-store-missing',
272
+ ].includes(error.message))
273
+ return null;
274
+ if (error instanceof Error && error.message === 'git-lifecycle-common-dir-invalid'
275
+ && !existsSync(join(this.projectRoot, '.git')))
276
+ return null;
277
+ throw error;
278
+ }
279
+ }
280
+ /** Apply legacy operation semantics to the common authority under its Git CAS. */
281
+ async applyCommonOperation(op, nowIso) {
282
+ if (op.type === 'no_op')
283
+ return;
284
+ if (op.type === 'reconcile_cross_epic_continuation') {
285
+ await applyCrossEpicContinuationReconciliation(this.projectRoot, op);
286
+ return;
287
+ }
288
+ const { document, operational } = readEpicOperationalState(this.projectRoot);
289
+ const identities = Object.keys(operational.identities);
290
+ if (identities.length !== 1)
291
+ throw new Error('epic-operational-identity-mismatch');
292
+ const identityHash = identities[0];
293
+ await mutateEpicOperationalState(this.projectRoot, {
294
+ identityHash,
295
+ expectedRevision: document.revision,
296
+ expectedWriterEpoch: operational.writerEpoch,
297
+ }, (state) => {
298
+ const current = state.yoloop.state;
299
+ let lock = state.yoloop.lock;
300
+ let next = current;
301
+ switch (op.type) {
302
+ case 'acquire_lock': {
303
+ if (lock && lock.sessionId !== op.lock.sessionId)
304
+ throw new Error('YOLOOP_LOCK_HELD');
305
+ lock = {
306
+ ...op.lock,
307
+ pid: process.pid,
308
+ hostname: hostname(),
309
+ ownerKind: 'client',
310
+ leaseId: op.lock.leaseId ?? buildPublicLeaseId(op.lock.sessionId, nowIso),
311
+ leaseState: 'active',
312
+ leaseAcquiredAt: op.lock.leaseAcquiredAt ?? nowIso,
313
+ leaseDurationMs: op.lock.leaseDurationMs ?? DEFAULT_LOCK_LEASE_MS,
314
+ leaseExpiresAt: op.lock.leaseExpiresAt ?? op.lock.ttlExpiresAt,
315
+ };
316
+ break;
317
+ }
318
+ case 'release_lock': {
319
+ if (op.expectedSessionId && lock?.sessionId !== op.expectedSessionId)
320
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
321
+ if (op.expectedLeaseId && lock?.leaseId !== op.expectedLeaseId)
322
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
323
+ if (op.expectedRunId && current.runControl?.runId !== op.expectedRunId)
324
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
325
+ lock = null;
326
+ break;
327
+ }
328
+ case 'write_state': {
329
+ const { runControl: _untrusted, ...legacy } = op.state;
330
+ next = { ...legacy, ...(current.runControl ? { version: '1.2', runControl: current.runControl } : {}) };
331
+ break;
332
+ }
333
+ case 'advance_epic': {
334
+ if (!current.currentSession)
335
+ return;
336
+ next = { ...current, currentSession: { ...current.currentSession, currentEpicIndex: op.nextEpicIndex } };
337
+ break;
338
+ }
339
+ case 'reserve_continuation_authority': {
340
+ const pending = current.pendingContinuationAuthorities ?? [];
341
+ const key = String(op.authority.idempotencyKey ?? '');
342
+ const existing = pending.find((item) => item.idempotencyKey === key);
343
+ if (existing) {
344
+ if (epicOperationalCanonicalJson(existing) !== epicOperationalCanonicalJson(op.authority)) {
345
+ throw new Error('YOLOOP_CONTINUATION_AUTHORITY_CONFLICT');
346
+ }
347
+ return;
348
+ }
349
+ if (!key || pending.length >= 64)
350
+ throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
351
+ next = { ...current, pendingContinuationAuthorities: [...pending, op.authority] };
352
+ break;
353
+ }
354
+ case 'acknowledge_continuation_authority': {
355
+ const pending = current.pendingContinuationAuthorities ?? [];
356
+ const authority = pending.find((item) => item.idempotencyKey === op.idempotencyKey);
357
+ if (!authority)
358
+ throw new Error('YOLOOP_CONTINUATION_RECEIPT_ORPHAN');
359
+ const receipts = current.continuationReceipts ?? [];
360
+ if (receipts.length >= 64)
361
+ throw new Error('YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED');
362
+ next = {
363
+ ...current,
364
+ pendingContinuationAuthorities: pending.filter((item) => item !== authority),
365
+ continuationReceipts: [...receipts, op.receipt],
366
+ };
367
+ break;
368
+ }
369
+ case 'repair_continuation_authority': {
370
+ const pending = current.pendingContinuationAuthorities ?? [];
371
+ if (pending.length !== 1 || epicOperationalHash('p208-legacy-authority-before-v1', pending[0]) !== op.beforeHash) {
372
+ throw new Error('YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH');
373
+ }
374
+ const receipts = current.continuationRecoveryReceipts ?? [];
375
+ if (receipts.length >= 64)
376
+ throw new Error('YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED');
377
+ next = { ...current, pendingContinuationAuthorities: [op.authority], continuationRecoveryReceipts: [...receipts, op.receipt] };
378
+ break;
379
+ }
380
+ case 'switch_continuation_policy': {
381
+ if (current.currentSession || (current.pendingContinuationAuthorities?.length ?? 0) > 0)
382
+ throw new Error('YOLOOP_POLICY_SWITCH_ACTIVE');
383
+ next = { ...current, continuationPolicy: op.policy, continuationSession: op.session };
384
+ break;
385
+ }
386
+ case 'transition_run':
387
+ case 'start_successor_run': {
388
+ const run = current.runControl;
389
+ if (!run || run.revision !== op.expectedRevision || run.latestSnapshotHash !== op.expectedSnapshotHash) {
390
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
391
+ }
392
+ next = { ...current, version: '1.2', runControl: op.next };
393
+ break;
394
+ }
395
+ case 'commit_successor_transition':
396
+ next = { ...op.state, version: '1.2', runControl: op.successor.next };
397
+ break;
398
+ case 'rollover_session':
399
+ next = op.next;
400
+ lock = null;
401
+ break;
402
+ case 'replan_continuation_session': {
403
+ const pending = current.pendingContinuationAuthorities ?? [];
404
+ if (epicOperationalHash('p222-yoloop-replan-session-v1', current.continuationSession ?? null) !== op.expectedSessionHash
405
+ || epicOperationalHash('p222-yoloop-replan-pending-v1', pending) !== op.expectedPendingHash
406
+ || pending.length > 1 || pending.some((item) => item.delegated !== false)) {
407
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT');
408
+ }
409
+ const receipts = current.continuationReplanReceipts ?? [];
410
+ if (receipts.length >= 64)
411
+ throw new Error('YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED');
412
+ next = { ...current, continuationSession: op.nextSession, pendingContinuationAuthorities: [], continuationReplanReceipts: [...receipts, op.receipt] };
413
+ break;
414
+ }
415
+ case 'recovery_attempted':
416
+ next = { ...current, recovery_history: capRecoveryHistory([...(current.recovery_history ?? []), op.attempt]) };
417
+ break;
418
+ case 'recovery_resolved': {
419
+ const history = [...(current.recovery_history ?? [])];
420
+ const index = history.findIndex((item) => item.recovery_step_id_tokenized === op.attempt_id);
421
+ if (index >= 0)
422
+ history[index] = { ...history[index], outcome: op.outcome, finished_at: nowIso };
423
+ next = { ...current, recovery_history: history };
424
+ break;
425
+ }
426
+ default: {
427
+ const _exhaustive = op;
428
+ throw new Error(`YOLOOP_STATE_OPERATION_UNSUPPORTED:${String(_exhaustive)}`);
429
+ }
430
+ }
431
+ state.yoloop = { ...state.yoloop, lock, state: next };
432
+ });
433
+ }
434
+ async reserveContinuationAuthority(authority, nowIso) {
435
+ const current = await this.readState(nowIso);
436
+ const pending = current.pendingContinuationAuthorities ?? [];
437
+ const key = typeof authority.idempotencyKey === 'string' ? authority.idempotencyKey : '';
438
+ if (!key)
439
+ throw new Error('YOLOOP_CONTINUATION_AUTHORITY_INVALID');
440
+ const existing = pending.find((item) => item.idempotencyKey === key);
441
+ if (existing) {
442
+ if (stablePublicJson(existing) === stablePublicJson(authority))
443
+ return;
444
+ throw new Error('YOLOOP_CONTINUATION_AUTHORITY_CONFLICT');
445
+ }
446
+ if (pending.length >= 64)
447
+ throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
448
+ await this.writeState({ ...current, pendingContinuationAuthorities: [...pending, authority] });
449
+ }
450
+ async acknowledgeContinuationAuthority(idempotencyKey, receipt, nowIso) {
451
+ const current = await this.readState(nowIso);
452
+ const pending = current.pendingContinuationAuthorities ?? [];
453
+ const index = pending.findIndex((item) => item.idempotencyKey === idempotencyKey);
454
+ if (index < 0)
455
+ throw new Error('YOLOOP_CONTINUATION_RECEIPT_ORPHAN');
456
+ const authority = pending[index];
457
+ if (receipt.idempotencyKey !== idempotencyKey || receipt.sessionId !== authority.sessionId || JSON.stringify(receipt.storyIds) !== JSON.stringify(authority.storyIds))
458
+ throw new Error('YOLOOP_CONTINUATION_RECEIPT_MISMATCH');
459
+ const retained = pending.filter((_, itemIndex) => itemIndex !== index);
460
+ const receipts = current.continuationReceipts ?? [];
461
+ await this.writeState({ ...current, pendingContinuationAuthorities: retained, continuationReceipts: [...receipts.slice(-63), receipt] });
462
+ }
463
+ async repairContinuationAuthority(beforeHash, authority, receipt, nowIso) {
464
+ const current = await this.readState(nowIso);
465
+ const pending = current.pendingContinuationAuthorities ?? [];
466
+ const recoveryReceipts = current.continuationRecoveryReceipts ?? [];
467
+ const receiptKey = typeof receipt.idempotencyKey === 'string' ? receipt.idempotencyKey : '';
468
+ const existingReceipt = recoveryReceipts.find((item) => item.idempotencyKey === receiptKey);
469
+ if (existingReceipt) {
470
+ if (stablePublicJson(existingReceipt) === stablePublicJson(receipt))
471
+ return;
472
+ throw new Error('YOLOOP_CONTINUATION_RECOVERY_RECEIPT_CONFLICT');
473
+ }
474
+ if (pending.length !== 1 || domainHash('p208-legacy-authority-before-v1', pending[0]) !== beforeHash) {
475
+ throw new Error('YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH');
476
+ }
477
+ const afterHash = domainHash('p208-legacy-authority-after-v1', authority);
478
+ if (receipt.beforeHash !== beforeHash || receipt.afterHash !== afterHash) {
479
+ throw new Error('YOLOOP_CONTINUATION_RECOVERY_HASH_MISMATCH');
480
+ }
481
+ const receiptBase = { ...receipt };
482
+ delete receiptBase.idempotencyKey;
483
+ if (!receiptKey || domainHash('p208-legacy-authority-recovery-receipt-v1', receiptBase) !== receiptKey) {
484
+ throw new Error('YOLOOP_CONTINUATION_RECOVERY_RECEIPT_INVALID');
485
+ }
486
+ await this.writeState({
487
+ ...current,
488
+ pendingContinuationAuthorities: [authority],
489
+ continuationRecoveryReceipts: [...recoveryReceipts.slice(-63), receipt],
490
+ });
491
+ }
492
+ async switchContinuationPolicy(policy, session, nowIso) {
493
+ const current = await this.readState(nowIso);
494
+ if (current.currentSession || (current.pendingContinuationAuthorities?.length ?? 0) > 0)
495
+ throw new Error('YOLOOP_POLICY_SWITCH_ACTIVE');
496
+ try {
497
+ await fs.copyFile(this.statePath, `${this.statePath}.policy-backup`);
498
+ }
499
+ catch (error) {
500
+ if (error.code !== 'ENOENT')
501
+ throw error;
502
+ }
503
+ await this.writeState({ ...current, continuationPolicy: policy, continuationSession: session });
504
+ }
505
+ async replanContinuationSession(operation, nowIso) {
506
+ const { type: _type, payloadHash: _payloadHash, receipt: _receipt, ...payload } = operation;
507
+ if (buildYoloopContinuationReplanPayloadHash(payload) !== operation.payloadHash
508
+ || !isSealedYoloopFrozenSession(operation.nextSession)) {
509
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_PAYLOAD_INVALID');
510
+ }
511
+ const expectedReceipt = {
512
+ schemaVersion: 'YoloopContinuationReplanReceipt/1.0',
513
+ operationId: operation.operationId,
514
+ idempotencyKey: operation.idempotencyKey,
515
+ runId: operation.expectedRunId,
516
+ previousSessionHash: operation.expectedSessionHash,
517
+ nextSessionHash: domainHash('p222-yoloop-replan-session-v1', operation.nextSession),
518
+ retiredAuthorityHash: operation.expectedPendingHash,
519
+ retiredAuthorityCount: operation.expectedPendingHash === domainHash('p222-yoloop-replan-pending-v1', []) ? 0 : 1,
520
+ payloadHash: operation.payloadHash,
521
+ outcome: 'graph-replanned',
522
+ publicSafe: true,
523
+ };
524
+ if (stablePublicJson(operation.receipt) !== stablePublicJson(expectedReceipt)) {
525
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_RECEIPT_INVALID');
526
+ }
527
+ const current = await this.readState(nowIso);
528
+ if (await this.readLock() || current.currentSession !== null) {
529
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_ACTIVE');
530
+ }
531
+ const run = current.runControl;
532
+ if (!run || run.runId !== operation.expectedRunId
533
+ || !['success', 'exhausted'].includes(run.status)
534
+ || run.revision !== operation.expectedRevision
535
+ || run.latestSnapshotHash !== operation.expectedSnapshotHash
536
+ || current.continuationPolicy !== 'p205-finite-resume-v2') {
537
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_RUN_CONFLICT');
538
+ }
539
+ const receipts = current.continuationReplanReceipts ?? [];
540
+ const existing = receipts.find((item) => item.idempotencyKey === operation.idempotencyKey
541
+ || item.operationId === operation.operationId);
542
+ if (existing) {
543
+ if (stablePublicJson(existing) === stablePublicJson(expectedReceipt)
544
+ && stablePublicJson(current.continuationSession) === stablePublicJson(operation.nextSession)
545
+ && (current.pendingContinuationAuthorities?.length ?? 0) === 0)
546
+ return;
547
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_REPLAY_CONFLICT');
548
+ }
549
+ const session = current.continuationSession;
550
+ const pending = current.pendingContinuationAuthorities ?? [];
551
+ if (!isSealedYoloopFrozenSession(session)
552
+ || domainHash('p222-yoloop-replan-session-v1', session) !== operation.expectedSessionHash
553
+ || domainHash('p222-yoloop-replan-pending-v1', pending) !== operation.expectedPendingHash
554
+ || pending.length > 1
555
+ || pending.some((authority) => authority.delegated !== false
556
+ || authority.sessionId !== session.sessionId
557
+ || !authority.certificate
558
+ || authority.certificate.sessionId !== session.sessionId)) {
559
+ throw new Error('YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT');
560
+ }
561
+ await this.writeState({
562
+ ...current,
563
+ continuationSession: operation.nextSession,
564
+ pendingContinuationAuthorities: [],
565
+ continuationReplanReceipts: [...receipts.slice(-63), expectedReceipt],
566
+ });
567
+ }
568
+ // ── P104.06: recovery_history audit semantics ───────────────────────────
569
+ /**
570
+ * Append a new recovery attempt to `state.recovery_history[]`. FIFO cap
571
+ * via `capRecoveryHistory`. Idempotent against retry storms: duplicate
572
+ * (story_id + attempted_at) entries are skipped with a warn log.
573
+ */
574
+ async appendRecoveryAttempt(attempt, nowIso) {
575
+ const current = await this.readState(nowIso);
576
+ const history = current.recovery_history ?? [];
577
+ // P104.06 AC8: idempotency via composite key
578
+ const isDuplicate = history.some((a) => a.attempted_at === attempt.attempted_at && a.story_id === attempt.story_id);
579
+ if (isDuplicate) {
580
+ this.logger.warn({ event: 'recovery_attempted_duplicate', story_id: attempt.story_id }, 'duplicate recovery_attempted op; skipping');
581
+ return;
582
+ }
583
+ const next = capRecoveryHistory([...history, attempt]);
584
+ const updated = {
585
+ ...current,
586
+ recovery_history: next,
587
+ };
588
+ await this.writeState(updated);
589
+ this.logger.info({
590
+ event: 'recovery_attempted_persisted',
591
+ story_id: attempt.story_id,
592
+ history_size: next.length,
593
+ }, 'recovery attempt persisted');
594
+ }
595
+ /**
596
+ * Resolve a pending recovery attempt by tokenized step id. Updates
597
+ * `outcome` and stamps `finished_at`. If the attempt was already evicted
598
+ * by the FIFO cap or never existed, emits a warn log (`recovery_resolve_orphan`)
599
+ * but does not throw -- bound state cap is a deliberate trade-off.
600
+ */
601
+ async resolveRecoveryAttempt(attemptId, outcome, nowIso) {
602
+ const current = await this.readState(nowIso);
603
+ const history = current.recovery_history ?? [];
604
+ const idx = history.findIndex((a) => a.recovery_step_id_tokenized === attemptId);
605
+ if (idx === -1) {
606
+ this.logger.warn({ event: 'recovery_resolve_orphan', attempt_id: attemptId }, 'recovery_resolved op had no matching attempt; skipping');
607
+ return;
608
+ }
609
+ const currentAttempt = history[idx];
610
+ if (currentAttempt.outcome === outcome && currentAttempt.finished_at) {
611
+ this.logger.info({ event: 'recovery_resolved_duplicate', attempt_id: attemptId, outcome }, 'duplicate recovery_resolved op; skipping');
612
+ return;
613
+ }
614
+ const updatedHistory = history.slice();
615
+ updatedHistory[idx] = {
616
+ ...currentAttempt,
617
+ outcome,
618
+ finished_at: nowIso,
619
+ };
620
+ const updated = {
621
+ ...current,
622
+ recovery_history: updatedHistory,
623
+ };
624
+ await this.writeState(updated);
625
+ this.logger.info({ event: 'recovery_resolved_persisted', attempt_id: attemptId, outcome }, 'recovery attempt resolved');
626
+ }
627
+ // ── Lock semantics (AC5 + AC6) ──────────────────────────────────────
628
+ /**
629
+ * Atomically acquire the yoloop lock. On EEXIST, performs PID liveness
630
+ * check (same-host) and retries once if orphan detected. Cross-host
631
+ * locks are honored (cannot signal across hosts).
632
+ *
633
+ * The server's `acquire_lock` operation provides the lock payload; we
634
+ * overwrite the `pid` and `hostname` fields with OUR values (the client
635
+ * is the authoritative writer of pid/hostname; server can't know them).
636
+ */
637
+ async acquireLock(lock, nowIso) {
638
+ // Replace server-suggested pid+hostname with real client values.
639
+ const payload = {
640
+ ...lock,
641
+ pid: process.pid,
642
+ hostname: hostname(),
643
+ ownerKind: 'client',
644
+ startedAt: lock.startedAt || nowIso,
645
+ leaseId: lock.leaseId ?? buildPublicLeaseId(lock.sessionId, nowIso),
646
+ leaseState: 'active',
647
+ leaseAcquiredAt: lock.leaseAcquiredAt ?? nowIso,
648
+ leaseDurationMs: lock.leaseDurationMs ?? DEFAULT_LOCK_LEASE_MS,
649
+ leaseExpiresAt: lock.leaseExpiresAt ?? lock.ttlExpiresAt,
650
+ };
651
+ const json = JSON.stringify(payload, null, 2);
652
+ try {
653
+ const fh = await fs.open(this.lockPath, 'wx');
654
+ try {
655
+ await fh.writeFile(json, 'utf8');
656
+ }
657
+ finally {
658
+ await fh.close();
659
+ }
660
+ this.trySetFilePerms(this.lockPath);
661
+ this.logger.info({ event: 'yoloop_lock_acquired', pid: payload.pid, sessionId: payload.sessionId }, 'yoloop lock acquired');
662
+ return;
663
+ }
664
+ catch (err) {
665
+ const e = err;
666
+ if (e.code !== 'EEXIST') {
667
+ this.logger.error({ event: 'yoloop_lock_acquire_error', err: e.message }, 'failed to acquire yoloop lock');
668
+ throw err;
669
+ }
670
+ const existing = await this.readLock();
671
+ if (existing && this.isSameClientSessionLock(existing, payload)) {
672
+ this.logger.info({ event: 'yoloop_lock_acquire_duplicate', sessionId: payload.sessionId }, 'duplicate acquire_lock for current yoloop session; treating as no-op');
673
+ return;
674
+ }
675
+ // EEXIST: try orphan recovery once.
676
+ const recovered = await this.maybeRecoverOrphanLock(nowIso);
677
+ if (!recovered) {
678
+ this.logger.warn({ event: 'yoloop_lock_held' }, 'yoloop lock held by another live process');
679
+ throw err;
680
+ }
681
+ // Retry after orphan recovery.
682
+ const fh = await fs.open(this.lockPath, 'wx');
683
+ try {
684
+ await fh.writeFile(json, 'utf8');
685
+ }
686
+ finally {
687
+ await fh.close();
688
+ }
689
+ this.trySetFilePerms(this.lockPath);
690
+ this.logger.info({ event: 'yoloop_lock_acquired_after_recovery', pid: payload.pid }, 'yoloop lock acquired after orphan recovery');
691
+ }
692
+ }
693
+ /** Release the lock. V1.2 active runs require compare-bound ownership. */
694
+ async releaseLock(binding) {
695
+ const current = await this.readState(new Date().toISOString());
696
+ const lock = await this.readLock();
697
+ if (current.runControl) {
698
+ if (!binding.expectedRunId || !binding.expectedSessionId || !binding.expectedLeaseId) {
699
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
700
+ }
701
+ if (binding.expectedRunId !== current.runControl.runId) {
702
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
703
+ }
704
+ if (!lock
705
+ || binding.expectedSessionId !== lock.sessionId
706
+ || binding.expectedLeaseId !== lock.leaseId) {
707
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
708
+ }
709
+ }
710
+ try {
711
+ await fs.unlink(this.lockPath);
712
+ this.logger.info({ event: 'yoloop_lock_released' }, 'yoloop lock released');
713
+ }
714
+ catch (err) {
715
+ const e = err;
716
+ if (e.code === 'ENOENT') {
717
+ this.logger.info({ event: 'yoloop_lock_release_noop' }, 'yoloop lock release noop (file absent)');
718
+ return;
719
+ }
720
+ this.logger.warn({ event: 'yoloop_lock_release_error', err: e.message }, 'unexpected error releasing yoloop lock');
721
+ }
722
+ }
723
+ /** Read lock file. Returns null on ENOENT / parse error. */
724
+ async readLock() {
725
+ try {
726
+ const raw = await fs.readFile(this.lockPath, 'utf8');
727
+ const parsed = JSON.parse(raw);
728
+ const currentEpicId = parsed.currentEpicId ?? parsed.epicId ?? '';
729
+ return {
730
+ pid: parsed.pid ?? 0,
731
+ hostname: parsed.hostname ?? '',
732
+ ...(parsed.ownerKind ? { ownerKind: parsed.ownerKind } : {}),
733
+ ...(parsed.ownerPidState ? { ownerPidState: parsed.ownerPidState } : {}),
734
+ ...(typeof parsed.leaseId === 'string' ? { leaseId: parsed.leaseId } : {}),
735
+ ...(parsed.leaseState ? { leaseState: parsed.leaseState } : {}),
736
+ ...(typeof parsed.leaseAcquiredAt === 'string' ? { leaseAcquiredAt: parsed.leaseAcquiredAt } : {}),
737
+ ...(typeof parsed.leaseDurationMs === 'number' ? { leaseDurationMs: parsed.leaseDurationMs } : {}),
738
+ ...(typeof parsed.leaseExpiresAt === 'string' ? { leaseExpiresAt: parsed.leaseExpiresAt } : {}),
739
+ startedAt: parsed.startedAt ?? '',
740
+ sessionId: parsed.sessionId ?? '',
741
+ mode: parsed.mode ?? 'epic-id',
742
+ inputPath: parsed.inputPath ?? '',
743
+ epicQueue: parsed.epicQueue ?? (currentEpicId ? [currentEpicId] : []),
744
+ currentEpicIndex: parsed.currentEpicIndex ?? 0,
745
+ currentEpicId,
746
+ ttlExpiresAt: parsed.ttlExpiresAt ?? '',
747
+ epicId: parsed.epicId ?? currentEpicId,
748
+ };
749
+ }
750
+ catch {
751
+ return null;
752
+ }
753
+ }
754
+ // ── State semantics (AC5 + AC7) ─────────────────────────────────────
755
+ /** Read state file; returns defaults on any I/O or parse error. */
756
+ async readState(nowIso) {
757
+ try {
758
+ const raw = await fs.readFile(this.statePath, 'utf8');
759
+ const parsed = JSON.parse(raw);
760
+ return mergeCountersWithDefaults(parsed, nowIso);
761
+ }
762
+ catch (err) {
763
+ const e = err;
764
+ if (e.code !== 'ENOENT') {
765
+ this.logger.warn({ event: 'yoloop_state_read_error', err: e.message }, 'yoloop state file unreadable; using defaults');
766
+ }
767
+ return createDefaultCounters(nowIso);
768
+ }
769
+ }
770
+ /** Durable atomic state write: same-filesystem temp + fsync + rename + dir fsync. */
771
+ async writeState(state) {
772
+ const tmpPath = `${this.statePath}.tmp.${process.pid}.${Date.now()}`;
773
+ const json = JSON.stringify(state, null, 2);
774
+ try {
775
+ const handle = await fs.open(tmpPath, 'w', 0o600);
776
+ try {
777
+ await handle.writeFile(json, 'utf8');
778
+ await handle.sync();
779
+ }
780
+ finally {
781
+ await handle.close();
782
+ }
783
+ this.trySetFilePerms(tmpPath);
784
+ await fs.rename(tmpPath, this.statePath);
785
+ await this.syncStateDirectory();
786
+ }
787
+ catch (err) {
788
+ const e = err;
789
+ this.logger.error({ event: 'yoloop_state_write_error', err: e.message }, 'failed to write yoloop state');
790
+ try {
791
+ await fs.unlink(tmpPath);
792
+ }
793
+ catch {
794
+ // best-effort cleanup
795
+ }
796
+ throw err;
797
+ }
798
+ }
799
+ /**
800
+ * Apply the per-run-cap boundary as one journaled logical transaction over
801
+ * yoloop-state.json and yoloop.lock. The mutation lock serializes local
802
+ * writers; the durable manifest makes every crash point resumable.
803
+ */
804
+ async rolloverSession(operation, nowIso) {
805
+ validateBoundedSessionRollover(operation);
806
+ const manifest = await this.readRolloverManifest();
807
+ if (manifest) {
808
+ if (!sameRolloverIdentity(manifest.operation, operation)) {
809
+ if (manifest.phase === 'prepared')
810
+ throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
811
+ }
812
+ else {
813
+ if (manifest.operation.payloadHash !== operation.payloadHash) {
814
+ throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
815
+ }
816
+ if (manifest.phase === 'applied')
817
+ return;
818
+ await this.completePreparedSessionRollover(manifest.operation, nowIso);
819
+ return;
820
+ }
821
+ }
822
+ const current = await this.readState(nowIso);
823
+ const lock = await this.readLock();
824
+ validateSessionRolloverBeforeState(operation, current, lock);
825
+ await this.writeRolloverManifest({
826
+ schemaVersion: 'YoloopSessionRolloverManifest/1.0',
827
+ phase: 'prepared',
828
+ operation,
829
+ });
830
+ await this.completePreparedSessionRollover(operation, nowIso);
831
+ }
832
+ async completePreparedSessionRollover(operation, nowIso) {
833
+ const current = await this.readState(nowIso);
834
+ let stateApplied = stablePublicJson(current) === stablePublicJson(operation.next);
835
+ if (!stateApplied) {
836
+ const lockBefore = await this.readLock();
837
+ validateSessionRolloverBeforeState(operation, current, lockBefore);
838
+ await this.writeState(operation.next);
839
+ stateApplied = true;
840
+ }
841
+ const lock = await this.readLock();
842
+ if (operation.expectedSessionId === null || operation.expectedLeaseId === null) {
843
+ if (operation.expectedSessionId !== null || operation.expectedLeaseId !== null || lock) {
844
+ throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
845
+ }
846
+ }
847
+ else if (lock) {
848
+ if (lock.sessionId !== operation.expectedSessionId
849
+ || lock.leaseId !== operation.expectedLeaseId) {
850
+ throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
851
+ }
852
+ await fs.unlink(this.lockPath);
853
+ await this.syncStateDirectory();
854
+ }
855
+ else if (!stateApplied) {
856
+ throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
857
+ }
858
+ await this.writeRolloverManifest({
859
+ schemaVersion: 'YoloopSessionRolloverManifest/1.0',
860
+ phase: 'applied',
861
+ operation,
862
+ });
863
+ this.logger.info({
864
+ event: 'yoloop_session_rollover_applied',
865
+ runId: operation.runId,
866
+ operationId: operation.operationId,
867
+ coolDownUntil: operation.coolDownUntil,
868
+ }, 'yoloop per-run-cap session rollover applied');
869
+ }
870
+ async recoverSessionRolloverIfPresent(nowIso) {
871
+ try {
872
+ await fs.access(this.rolloverManifestPath);
873
+ }
874
+ catch (error) {
875
+ if (error.code === 'ENOENT')
876
+ return;
877
+ throw error;
878
+ }
879
+ await this.ensureDir();
880
+ await this.withMutationLock(async () => {
881
+ const manifest = await this.readRolloverManifest();
882
+ if (manifest?.phase === 'prepared') {
883
+ await this.completePreparedSessionRollover(manifest.operation, nowIso);
884
+ }
885
+ });
886
+ }
887
+ async readRolloverManifest() {
888
+ try {
889
+ const parsed = JSON.parse(await fs.readFile(this.rolloverManifestPath, 'utf8'));
890
+ if (parsed.schemaVersion !== 'YoloopSessionRolloverManifest/1.0'
891
+ || (parsed.phase !== 'prepared' && parsed.phase !== 'applied')
892
+ || !parsed.operation
893
+ || parsed.operation.type !== 'rollover_session')
894
+ throw new Error('YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT');
895
+ validateBoundedSessionRollover(parsed.operation);
896
+ return parsed;
897
+ }
898
+ catch (error) {
899
+ if (error.code === 'ENOENT')
900
+ return null;
901
+ if (error.message === 'YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT')
902
+ throw error;
903
+ throw new Error('YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT');
904
+ }
905
+ }
906
+ async writeRolloverManifest(manifest) {
907
+ const tmpPath = `${this.rolloverManifestPath}.tmp.${process.pid}.${Date.now()}`;
908
+ const handle = await fs.open(tmpPath, 'w', 0o600);
909
+ try {
910
+ await handle.writeFile(JSON.stringify(manifest, null, 2), 'utf8');
911
+ await handle.sync();
912
+ }
913
+ finally {
914
+ await handle.close();
915
+ }
916
+ this.trySetFilePerms(tmpPath);
917
+ await fs.rename(tmpPath, this.rolloverManifestPath);
918
+ await this.syncStateDirectory();
919
+ }
920
+ /**
921
+ * Journal the predecessor replacement, completed-story acknowledgement and
922
+ * next authority reservation as one recoverable logical CAS transition.
923
+ * Individual state-file renames remain atomic; the prepared/applied manifest
924
+ * closes every crash window between them.
925
+ */
926
+ async commitSuccessorTransition(operation, nowIso) {
927
+ validateBoundedSuccessorTransition(operation);
928
+ const manifest = await this.readSuccessorTransitionManifest();
929
+ if (manifest) {
930
+ if (sameSuccessorTransitionIdentity(manifest.operation, operation)) {
931
+ if (manifest.operation.payloadHash !== operation.payloadHash) {
932
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
933
+ }
934
+ if (manifest.phase === 'applied')
935
+ return;
936
+ await this.completePreparedSuccessorTransition(manifest.operation, nowIso);
937
+ return;
938
+ }
939
+ if (manifest.phase === 'prepared') {
940
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
941
+ }
942
+ }
943
+ const current = await this.readState(nowIso);
944
+ if (successorTransitionApplied(operation, current)) {
945
+ await this.writeSuccessorTransitionManifest({
946
+ schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
947
+ phase: 'applied',
948
+ operation,
949
+ });
950
+ return;
951
+ }
952
+ validateSuccessorTransitionBeforeState(operation, current);
953
+ await this.writeSuccessorTransitionManifest({
954
+ schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
955
+ phase: 'prepared',
956
+ operation,
957
+ });
958
+ await this.completePreparedSuccessorTransition(operation, nowIso);
959
+ }
960
+ async completePreparedSuccessorTransition(operation, nowIso) {
961
+ let current = await this.readState(nowIso);
962
+ if (!successorTransitionApplied(operation, current)) {
963
+ const successorApplied = stablePublicJson(current.runControl)
964
+ === stablePublicJson(operation.successor.next);
965
+ if (!successorApplied) {
966
+ await this.startSuccessorRun(operation.successor, nowIso);
967
+ current = await this.readState(nowIso);
968
+ }
969
+ const next = buildSuccessorTransitionState(operation, current);
970
+ await this.writeState(next);
971
+ current = await this.readState(nowIso);
972
+ }
973
+ if (!successorTransitionApplied(operation, current)) {
974
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT');
975
+ }
976
+ await this.writeSuccessorTransitionManifest({
977
+ schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
978
+ phase: 'applied',
979
+ operation,
980
+ });
981
+ this.logger.info({
982
+ event: 'yoloop_successor_transition_applied',
983
+ runId: operation.successor.runId,
984
+ operationId: operation.operationId,
985
+ }, 'yoloop successor transition applied');
986
+ }
987
+ async recoverSuccessorTransitionIfPresent(nowIso) {
988
+ try {
989
+ await fs.access(this.successorTransitionManifestPath);
990
+ }
991
+ catch (error) {
992
+ if (error.code === 'ENOENT')
993
+ return;
994
+ throw error;
995
+ }
996
+ await this.ensureDir();
997
+ await this.withMutationLock(async () => {
998
+ const manifest = await this.readSuccessorTransitionManifest();
999
+ if (manifest?.phase === 'prepared') {
1000
+ await this.completePreparedSuccessorTransition(manifest.operation, nowIso);
1001
+ }
1002
+ });
1003
+ }
1004
+ async readSuccessorTransitionManifest() {
1005
+ try {
1006
+ const parsed = JSON.parse(await fs.readFile(this.successorTransitionManifestPath, 'utf8'));
1007
+ if (parsed.schemaVersion !== 'YoloopSuccessorTransitionManifest/1.0'
1008
+ || (parsed.phase !== 'prepared' && parsed.phase !== 'applied')
1009
+ || !parsed.operation
1010
+ || parsed.operation.type !== 'commit_successor_transition')
1011
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT');
1012
+ validateBoundedSuccessorTransition(parsed.operation);
1013
+ return parsed;
1014
+ }
1015
+ catch (error) {
1016
+ if (error.code === 'ENOENT')
1017
+ return null;
1018
+ if (error.message === 'YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT')
1019
+ throw error;
1020
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT');
1021
+ }
1022
+ }
1023
+ async writeSuccessorTransitionManifest(manifest) {
1024
+ const tmpPath = `${this.successorTransitionManifestPath}.tmp.${process.pid}.${Date.now()}`;
1025
+ const handle = await fs.open(tmpPath, 'w', 0o600);
1026
+ try {
1027
+ await handle.writeFile(JSON.stringify(manifest, null, 2), 'utf8');
1028
+ await handle.sync();
1029
+ }
1030
+ finally {
1031
+ await handle.close();
1032
+ }
1033
+ this.trySetFilePerms(tmpPath);
1034
+ await fs.rename(tmpPath, this.successorTransitionManifestPath);
1035
+ await this.syncStateDirectory();
1036
+ }
1037
+ /**
1038
+ * Advance to next epic: read-modify-write on currentSession.currentEpicIndex.
1039
+ * Fail-open if no active session (logs warn; subsequent *yoloop will
1040
+ * emit a fresh acquire_lock).
1041
+ */
1042
+ async advanceEpic(nextIndex, nextEpicId, nowIso) {
1043
+ const current = await this.readState(nowIso);
1044
+ if (!current.currentSession) {
1045
+ this.logger.warn({ event: 'yoloop_advance_no_session', nextEpicId }, 'advance_epic with no active session; skipping');
1046
+ return;
1047
+ }
1048
+ const updated = {
1049
+ ...current,
1050
+ currentSession: {
1051
+ ...current.currentSession,
1052
+ currentEpicIndex: nextIndex,
1053
+ },
1054
+ };
1055
+ await this.writeState(updated);
1056
+ this.logger.info({ event: 'yoloop_epic_advance', nextEpicId, nextIndex }, 'yoloop advanced to next epic');
1057
+ }
1058
+ // ── Internal helpers ────────────────────────────────────────────────
1059
+ async ensureDir() {
1060
+ try {
1061
+ await fs.mkdir(this.stateDir, { recursive: true });
1062
+ this.trySetDirPerms(this.stateDir);
1063
+ }
1064
+ catch (err) {
1065
+ const e = err;
1066
+ this.logger.error({ event: 'yoloop_mkdir_error', err: e.message, path: this.stateDir }, 'failed to ensure .neocortex/ directory');
1067
+ throw err;
1068
+ }
1069
+ }
1070
+ async syncStateDirectory() {
1071
+ try {
1072
+ const directory = await fs.open(this.stateDir, 'r');
1073
+ try {
1074
+ await directory.sync();
1075
+ }
1076
+ finally {
1077
+ await directory.close();
1078
+ }
1079
+ }
1080
+ catch (error) {
1081
+ const code = error.code;
1082
+ if (code !== 'EINVAL' && code !== 'EISDIR' && code !== 'ENOTSUP' && code !== 'EPERM') {
1083
+ throw error;
1084
+ }
1085
+ }
1086
+ }
1087
+ async withMutationLock(operation) {
1088
+ let handle = null;
1089
+ for (let attempt = 0; attempt < MUTATION_LOCK_RETRIES; attempt += 1) {
1090
+ try {
1091
+ handle = await fs.open(this.mutationLockPath, 'wx', 0o600);
1092
+ await handle.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }));
1093
+ await handle.sync();
1094
+ break;
1095
+ }
1096
+ catch (error) {
1097
+ if (error.code !== 'EEXIST')
1098
+ throw error;
1099
+ try {
1100
+ const stat = await fs.stat(this.mutationLockPath);
1101
+ if (Date.now() - stat.mtimeMs > MUTATION_LOCK_STALE_MS) {
1102
+ await fs.unlink(this.mutationLockPath);
1103
+ continue;
1104
+ }
1105
+ }
1106
+ catch {
1107
+ continue;
1108
+ }
1109
+ await new Promise((resolve) => setTimeout(resolve, 5));
1110
+ }
1111
+ }
1112
+ if (!handle)
1113
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1114
+ try {
1115
+ return await operation();
1116
+ }
1117
+ finally {
1118
+ await handle.close().catch(() => undefined);
1119
+ await fs.unlink(this.mutationLockPath).catch(() => undefined);
1120
+ }
1121
+ }
1122
+ async transitionRun(operation, nowIso) {
1123
+ validateBoundedRunOperation(operation);
1124
+ const current = await this.readState(nowIso);
1125
+ const existing = current.runControl;
1126
+ const replay = existing?.receipts.find((receipt) => receipt.operationId === operation.operationId
1127
+ || receipt.idempotencyKey === operation.idempotencyKey);
1128
+ if (replay) {
1129
+ if (replay.operationId === operation.operationId
1130
+ && replay.idempotencyKey === operation.idempotencyKey
1131
+ && replay.payloadHash === operation.payloadHash)
1132
+ return replay;
1133
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1134
+ }
1135
+ if (existing && existing.runId !== operation.runId) {
1136
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1137
+ }
1138
+ const actualRevision = existing?.revision ?? 0;
1139
+ if (actualRevision !== operation.expectedRevision) {
1140
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1141
+ }
1142
+ const actualSnapshotHash = existing?.latestSnapshotHash ?? null;
1143
+ if (actualSnapshotHash !== operation.expectedSnapshotHash) {
1144
+ throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1145
+ }
1146
+ validateProposedRunTransition(existing, operation);
1147
+ const receipt = operation.next.receipts.at(-1);
1148
+ if (!receipt)
1149
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1150
+ await this.writeState({ ...current, version: '1.2', runControl: operation.next });
1151
+ this.logger.info({
1152
+ event: 'yoloop_run_transition_committed',
1153
+ runId: operation.runId,
1154
+ operationId: operation.operationId,
1155
+ revision: receipt.revision,
1156
+ status: receipt.status,
1157
+ }, 'yoloop run transition committed');
1158
+ return receipt;
1159
+ }
1160
+ /** Replace a proven exhausted run, or an explicitly reopened success, with a fresh successor. */
1161
+ async startSuccessorRun(operation, nowIso) {
1162
+ validateBoundedSuccessorRunOperation(operation);
1163
+ const current = await this.readState(nowIso);
1164
+ const existing = current.runControl;
1165
+ const expectedPredecessor = {
1166
+ schemaVersion: 'YoloopRunPredecessor/1.0',
1167
+ runId: operation.previousRunId,
1168
+ revision: operation.expectedRevision,
1169
+ snapshotHash: operation.expectedSnapshotHash,
1170
+ successorOperationId: operation.operationId,
1171
+ publicSafe: true,
1172
+ };
1173
+ const replay = existing?.receipts.find((receipt) => receipt.operationId === operation.operationId
1174
+ || receipt.idempotencyKey === operation.idempotencyKey);
1175
+ if (replay) {
1176
+ if (replay.operationId === operation.operationId
1177
+ && replay.idempotencyKey === operation.idempotencyKey
1178
+ && operation.next.receipts.at(-1)?.payloadHash === replay.payloadHash
1179
+ && stablePublicJson(existing?.predecessor) === stablePublicJson(expectedPredecessor))
1180
+ return replay;
1181
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1182
+ }
1183
+ if (!existing
1184
+ || existing.runId !== operation.previousRunId
1185
+ || !['exhausted', 'success'].includes(existing.status)
1186
+ || existing.revision !== operation.expectedRevision
1187
+ || existing.latestSnapshotHash !== operation.expectedSnapshotHash
1188
+ || current.currentSession !== null)
1189
+ throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1190
+ const createdReceipt = operation.next.receipts.at(-1);
1191
+ if (!createdReceipt
1192
+ || stablePublicJson(operation.next.predecessor) !== stablePublicJson(expectedPredecessor))
1193
+ throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1194
+ const createdTransition = {
1195
+ type: 'transition_run',
1196
+ runId: operation.runId,
1197
+ operationId: operation.operationId,
1198
+ idempotencyKey: operation.idempotencyKey,
1199
+ expectedRevision: 0,
1200
+ expectedSnapshotHash: null,
1201
+ payloadHash: createdReceipt.payloadHash,
1202
+ next: operation.next,
1203
+ };
1204
+ validateBoundedRunOperation(createdTransition);
1205
+ validateProposedRunTransition(undefined, createdTransition);
1206
+ await this.writeState({ ...current, version: '1.2', runControl: operation.next });
1207
+ return createdReceipt;
1208
+ }
1209
+ /**
1210
+ * Fire-and-forget chmod/ACL. Uses sync helpers from secure-config.ts
1211
+ * which are fail-open by contract (never throw).
1212
+ */
1213
+ trySetFilePerms(path) {
1214
+ try {
1215
+ setSecureFilePermissions(path);
1216
+ }
1217
+ catch {
1218
+ // setSecureFilePermissions is fail-open but be defensive
1219
+ }
1220
+ }
1221
+ trySetDirPerms(path) {
1222
+ try {
1223
+ setSecureDirPermissions(path);
1224
+ }
1225
+ catch {
1226
+ // ignore
1227
+ }
1228
+ }
1229
+ /**
1230
+ * Normalize a lock before echoing it to the server. The local client can
1231
+ * check PID liveness for client-owned same-host locks; the server cannot.
1232
+ */
1233
+ async prepareLockForSnapshot(meta, nowIso) {
1234
+ if (!meta)
1235
+ return null;
1236
+ if (meta.ownerKind !== 'client') {
1237
+ return meta;
1238
+ }
1239
+ const leaseState = classifyClientLease(meta, nowIso);
1240
+ const metaWithLease = { ...meta, leaseState };
1241
+ if (leaseState === 'expired') {
1242
+ await this.deleteLockBestEffort();
1243
+ this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'lease_expired' }, 'expired client-owned yoloop lock lease recovered before snapshot echo');
1244
+ return null;
1245
+ }
1246
+ if (!metaWithLease.pid || metaWithLease.pid <= 0) {
1247
+ await this.deleteLockBestEffort();
1248
+ this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'invalid_pid' }, 'invalid PID in client-owned yoloop lock; recovered');
1249
+ return null;
1250
+ }
1251
+ if (metaWithLease.hostname && metaWithLease.hostname !== hostname()) {
1252
+ return { ...metaWithLease, ownerPidState: 'unknown' };
1253
+ }
1254
+ try {
1255
+ process.kill(metaWithLease.pid, 0);
1256
+ return { ...metaWithLease, ownerPidState: 'live' };
1257
+ }
1258
+ catch {
1259
+ await this.deleteLockBestEffort();
1260
+ this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'dead_pid' }, 'stale client-owned yoloop lock recovered before snapshot echo');
1261
+ return null;
1262
+ }
1263
+ }
1264
+ /**
1265
+ * If the existing lock is held by a dead PID on the same host, delete
1266
+ * it and return true (caller retries acquisition). Otherwise return
1267
+ * false (honor the lock).
1268
+ */
1269
+ async maybeRecoverOrphanLock(nowIso) {
1270
+ const meta = await this.readLock();
1271
+ if (!meta) {
1272
+ // Lock file disappeared between EEXIST and our read -> safe to retry
1273
+ return true;
1274
+ }
1275
+ if (classifyClientLease(meta, nowIso) === 'expired') {
1276
+ await this.deleteLockBestEffort();
1277
+ this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'lease_expired' }, 'expired yoloop lock lease recovered');
1278
+ return true;
1279
+ }
1280
+ if (!meta.pid || meta.pid <= 0) {
1281
+ await this.deleteLockBestEffort();
1282
+ this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'invalid_pid' }, 'invalid PID in lock file; recovered');
1283
+ return true;
1284
+ }
1285
+ // Cross-host locks cannot be killed; honor them.
1286
+ if (meta.hostname && meta.hostname !== hostname()) {
1287
+ this.logger.warn({
1288
+ event: 'yoloop_lock_remote_host',
1289
+ ownerKind: meta.ownerKind ?? 'server',
1290
+ ownerPidState: 'unknown',
1291
+ }, 'lock held by a remote host; honoring');
1292
+ return false;
1293
+ }
1294
+ try {
1295
+ process.kill(meta.pid, 0); // liveness check
1296
+ return false; // alive -> honor
1297
+ }
1298
+ catch {
1299
+ await this.deleteLockBestEffort();
1300
+ this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'dead_pid' }, 'orphan yoloop lock recovered');
1301
+ return true;
1302
+ }
1303
+ }
1304
+ async deleteLockBestEffort() {
1305
+ try {
1306
+ await fs.unlink(this.lockPath);
1307
+ }
1308
+ catch {
1309
+ // ignore
1310
+ }
1311
+ }
1312
+ isSameClientSessionLock(existing, next) {
1313
+ return (existing.ownerKind === 'client' &&
1314
+ existing.sessionId === next.sessionId &&
1315
+ existing.pid === process.pid &&
1316
+ existing.hostname === hostname());
1317
+ }
1318
+ /** Remove `yoloop-state.json.tmp.*` orphans from crashed writes. */
1319
+ async cleanupOrphanTempFiles() {
1320
+ try {
1321
+ const dir = dirname(this.statePath);
1322
+ const entries = await fs.readdir(dir);
1323
+ const stems = [
1324
+ `${STATE_FILE}.tmp.`,
1325
+ `${ROLLOVER_MANIFEST_FILE}.tmp.`,
1326
+ `${SUCCESSOR_TRANSITION_MANIFEST_FILE}.tmp.`,
1327
+ ];
1328
+ const candidates = entries.filter((entry) => stems.some((stem) => entry.startsWith(stem)));
1329
+ for (const entry of candidates) {
1330
+ try {
1331
+ await fs.unlink(join(dir, entry));
1332
+ }
1333
+ catch {
1334
+ // ignore individual failures
1335
+ }
1336
+ }
1337
+ }
1338
+ catch {
1339
+ // dir may not exist yet; ignore
1340
+ }
1341
+ }
1342
+ }
1343
+ function stablePublicJson(value) {
1344
+ if (value === null || typeof value !== 'object')
1345
+ return JSON.stringify(value);
1346
+ if (Array.isArray(value))
1347
+ return `[${value.map(stablePublicJson).join(',')}]`;
1348
+ return `{${Object.entries(value).filter(([, child]) => child !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => `${JSON.stringify(key)}:${stablePublicJson(child)}`).join(',')}}`;
1349
+ }
1350
+ function domainHash(domain, value) {
1351
+ return createHash('sha256').update(`${domain}\0${stablePublicJson(value)}`).digest('hex');
1352
+ }
1353
+ export function buildYoloopContinuationReplanPayloadHash(payload) {
1354
+ return domainHash('p222-yoloop-continuation-replan-v1', payload);
1355
+ }
1356
+ function isSealedYoloopFrozenSession(value) {
1357
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1358
+ return false;
1359
+ const { sessionId, ...pinned } = value;
1360
+ return typeof sessionId === 'string'
1361
+ && /^[a-f0-9]{64}$/.test(sessionId)
1362
+ && sessionId === domainHash('p205-yoloop-session-v1', pinned);
1363
+ }
1364
+ export function fingerprintYoloopSession(session) {
1365
+ return domainHash('p9390812898200217364-yoloop-session-v1', session);
1366
+ }
1367
+ export function buildYoloopSessionRolloverPayloadHash(operation) {
1368
+ return domainHash('p9390812898200217364-yoloop-rollover-v1', operation);
1369
+ }
1370
+ export function buildYoloopSuccessorRunPayloadHash(operation) {
1371
+ return domainHash('p9390812898200217364-yoloop-successor-run-v1', operation);
1372
+ }
1373
+ export function buildYoloopSuccessorTransitionPayloadHash(operation) {
1374
+ return domainHash('p9046281030721413419-yoloop-successor-transition-v1', operation);
1375
+ }
1376
+ function sameSuccessorTransitionIdentity(left, right) {
1377
+ return left.operationId === right.operationId
1378
+ && left.idempotencyKey === right.idempotencyKey;
1379
+ }
1380
+ function validateBoundedSuccessorTransition(operation) {
1381
+ const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1382
+ validateBoundedSuccessorRunOperation(operation.successor);
1383
+ const base = {
1384
+ operationId: operation.operationId,
1385
+ idempotencyKey: operation.idempotencyKey,
1386
+ successor: operation.successor,
1387
+ state: operation.state,
1388
+ ...(operation.acknowledgement ? { acknowledgement: operation.acknowledgement } : {}),
1389
+ ...(operation.reservation ? { reservation: operation.reservation } : {}),
1390
+ };
1391
+ const acknowledgementKey = operation.acknowledgement?.idempotencyKey;
1392
+ const acknowledgementReceiptKey = operation.acknowledgement?.receipt.idempotencyKey;
1393
+ const reservationKey = operation.reservation?.authority.idempotencyKey;
1394
+ if (operation.successor.type !== 'start_successor_run'
1395
+ || !boundedId.test(operation.operationId)
1396
+ || !boundedId.test(operation.idempotencyKey)
1397
+ || operation.operationId !== operation.successor.operationId
1398
+ || operation.idempotencyKey !== operation.successor.idempotencyKey
1399
+ || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1400
+ || operation.payloadHash !== buildYoloopSuccessorTransitionPayloadHash(base)
1401
+ || Buffer.byteLength(stablePublicJson(base), 'utf8') > 1_048_576
1402
+ || (operation.acknowledgement !== undefined && (operation.acknowledgement.type !== 'acknowledge_continuation_authority'
1403
+ || typeof acknowledgementKey !== 'string'
1404
+ || !boundedId.test(acknowledgementKey)
1405
+ || acknowledgementReceiptKey !== acknowledgementKey))
1406
+ || (operation.reservation !== undefined && (operation.reservation.type !== 'reserve_continuation_authority'
1407
+ || typeof reservationKey !== 'string'
1408
+ || !boundedId.test(reservationKey)))
1409
+ || (acknowledgementKey !== undefined && reservationKey === acknowledgementKey))
1410
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
1411
+ }
1412
+ function validateSuccessorTransitionBeforeState(operation, current) {
1413
+ validateBoundedSuccessorTransition(operation);
1414
+ const successorAlreadyApplied = stablePublicJson(current.runControl)
1415
+ === stablePublicJson(operation.successor.next);
1416
+ if (!successorAlreadyApplied) {
1417
+ const predecessor = current.runControl;
1418
+ if (!predecessor
1419
+ || predecessor.runId !== operation.successor.previousRunId
1420
+ || !['exhausted', 'success'].includes(predecessor.status)
1421
+ || predecessor.revision !== operation.successor.expectedRevision
1422
+ || predecessor.latestSnapshotHash !== operation.successor.expectedSnapshotHash
1423
+ || current.currentSession !== null)
1424
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT');
1425
+ }
1426
+ validateSuccessorTransitionAuthorityProjection(operation, current);
1427
+ }
1428
+ function validateSuccessorTransitionAuthorityProjection(operation, current) {
1429
+ const pending = current.pendingContinuationAuthorities ?? [];
1430
+ const receipts = current.continuationReceipts ?? [];
1431
+ const acknowledgement = operation.acknowledgement;
1432
+ if (acknowledgement) {
1433
+ const authority = pending.find((item) => item.idempotencyKey === acknowledgement.idempotencyKey);
1434
+ if (!authority
1435
+ || acknowledgement.receipt.sessionId !== authority.sessionId
1436
+ || stablePublicJson(acknowledgement.receipt.storyIds) !== stablePublicJson(authority.storyIds))
1437
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1438
+ const projectedPending = pending.filter((item) => item.idempotencyKey !== acknowledgement.idempotencyKey);
1439
+ const projectedReceipts = [...receipts.slice(-63), acknowledgement.receipt];
1440
+ if (stablePublicJson(operation.state.pendingContinuationAuthorities ?? [])
1441
+ !== stablePublicJson(projectedPending)
1442
+ || stablePublicJson(operation.state.continuationReceipts ?? [])
1443
+ !== stablePublicJson(projectedReceipts))
1444
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1445
+ }
1446
+ else if (stablePublicJson(operation.state.pendingContinuationAuthorities ?? []) !== stablePublicJson(pending)
1447
+ || stablePublicJson(operation.state.continuationReceipts ?? []) !== stablePublicJson(receipts)) {
1448
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1449
+ }
1450
+ const reservation = operation.reservation?.authority;
1451
+ if (!reservation)
1452
+ return;
1453
+ const key = String(reservation.idempotencyKey ?? '');
1454
+ const projected = operation.state.pendingContinuationAuthorities ?? [];
1455
+ const existing = projected.find((item) => item.idempotencyKey === key);
1456
+ if (existing && stablePublicJson(existing) !== stablePublicJson(reservation)) {
1457
+ throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1458
+ }
1459
+ if (!existing && projected.length >= 64) {
1460
+ throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
1461
+ }
1462
+ }
1463
+ function buildSuccessorTransitionState(operation, current) {
1464
+ validateSuccessorTransitionBeforeState(operation, current);
1465
+ const reservation = operation.reservation?.authority;
1466
+ const projected = [...(operation.state.pendingContinuationAuthorities ?? [])];
1467
+ if (reservation && !projected.some((item) => item.idempotencyKey === reservation.idempotencyKey)) {
1468
+ projected.push(reservation);
1469
+ }
1470
+ return {
1471
+ ...operation.state,
1472
+ version: '1.2',
1473
+ runControl: operation.successor.next,
1474
+ ...(reservation || operation.state.pendingContinuationAuthorities !== undefined
1475
+ ? { pendingContinuationAuthorities: projected }
1476
+ : {}),
1477
+ };
1478
+ }
1479
+ function successorTransitionApplied(operation, current) {
1480
+ const reservation = operation.reservation?.authority;
1481
+ const projected = [...(operation.state.pendingContinuationAuthorities ?? [])];
1482
+ if (reservation && !projected.some((item) => item.idempotencyKey === reservation.idempotencyKey)) {
1483
+ projected.push(reservation);
1484
+ }
1485
+ const expected = {
1486
+ ...operation.state,
1487
+ version: '1.2',
1488
+ runControl: operation.successor.next,
1489
+ ...(reservation || operation.state.pendingContinuationAuthorities !== undefined
1490
+ ? { pendingContinuationAuthorities: projected }
1491
+ : {}),
1492
+ };
1493
+ return stablePublicJson(current) === stablePublicJson(expected);
1494
+ }
1495
+ function sameRolloverIdentity(left, right) {
1496
+ return left.operationId === right.operationId
1497
+ && left.idempotencyKey === right.idempotencyKey;
1498
+ }
1499
+ function validateBoundedSessionRollover(operation) {
1500
+ const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1501
+ const base = {
1502
+ runId: operation.runId,
1503
+ operationId: operation.operationId,
1504
+ idempotencyKey: operation.idempotencyKey,
1505
+ expectedRevision: operation.expectedRevision,
1506
+ expectedSnapshotHash: operation.expectedSnapshotHash,
1507
+ expectedSessionId: operation.expectedSessionId,
1508
+ expectedSessionFingerprint: operation.expectedSessionFingerprint,
1509
+ expectedLeaseId: operation.expectedLeaseId,
1510
+ coolDownUntil: operation.coolDownUntil,
1511
+ reason: operation.reason,
1512
+ exhaustedRun: operation.exhaustedRun,
1513
+ next: operation.next,
1514
+ };
1515
+ if (!boundedId.test(operation.runId)
1516
+ || !boundedId.test(operation.operationId)
1517
+ || !boundedId.test(operation.idempotencyKey)
1518
+ || !/^[a-f0-9]{64}$/.test(operation.expectedSessionFingerprint)
1519
+ || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1520
+ || !Number.isSafeInteger(operation.expectedRevision)
1521
+ || operation.expectedRevision < 0
1522
+ || !Number.isFinite(Date.parse(operation.coolDownUntil))
1523
+ || !['YOLOOP_PER_RUN_CAP_REACHED', 'YOLOOP_SESSION_TIMEOUT'].includes(operation.reason)
1524
+ || operation.payloadHash !== buildYoloopSessionRolloverPayloadHash(base)
1525
+ || operation.receipt.schemaVersion !== 'YoloopSessionRolloverReceipt/1.0'
1526
+ || operation.receipt.runId !== operation.runId
1527
+ || operation.receipt.operationId !== operation.operationId
1528
+ || operation.receipt.idempotencyKey !== operation.idempotencyKey
1529
+ || operation.receipt.payloadHash !== operation.payloadHash
1530
+ || operation.receipt.coolDownUntil !== operation.coolDownUntil
1531
+ || operation.receipt.revision !== (operation.exhaustedRun?.revision ?? 0)
1532
+ || operation.receipt.snapshotHash !== (operation.exhaustedRun?.latestSnapshotHash ?? null)
1533
+ || operation.receipt.status !== 'exhausted'
1534
+ || operation.receipt.publicSafe !== true)
1535
+ throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
1536
+ if ((operation.expectedSessionId === null) !== (operation.expectedLeaseId === null)
1537
+ || (operation.expectedSessionId !== null && !boundedId.test(operation.expectedSessionId))
1538
+ || (operation.expectedLeaseId !== null && !boundedId.test(operation.expectedLeaseId)))
1539
+ throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1540
+ }
1541
+ function validateBoundedSuccessorRunOperation(operation) {
1542
+ const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1543
+ const base = {
1544
+ runId: operation.runId,
1545
+ previousRunId: operation.previousRunId,
1546
+ operationId: operation.operationId,
1547
+ idempotencyKey: operation.idempotencyKey,
1548
+ expectedRevision: operation.expectedRevision,
1549
+ expectedSnapshotHash: operation.expectedSnapshotHash,
1550
+ next: operation.next,
1551
+ };
1552
+ if (!boundedId.test(operation.runId)
1553
+ || !boundedId.test(operation.previousRunId)
1554
+ || operation.runId === operation.previousRunId
1555
+ || !boundedId.test(operation.operationId)
1556
+ || !boundedId.test(operation.idempotencyKey)
1557
+ || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1558
+ || !Number.isSafeInteger(operation.expectedRevision)
1559
+ || operation.expectedRevision < 1
1560
+ || operation.payloadHash !== buildYoloopSuccessorRunPayloadHash(base))
1561
+ throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1562
+ }
1563
+ function validateSessionRolloverBeforeState(operation, current, lock) {
1564
+ validateBoundedSessionRollover(operation);
1565
+ const session = current.currentSession;
1566
+ if (!session || fingerprintYoloopSession(session) !== operation.expectedSessionFingerprint) {
1567
+ throw new Error('YOLOOP_SESSION_ROLLOVER_SESSION_CONFLICT');
1568
+ }
1569
+ const currentRun = current.runControl;
1570
+ if ((currentRun?.revision ?? 0) !== operation.expectedRevision
1571
+ || (currentRun?.latestSnapshotHash ?? null) !== operation.expectedSnapshotHash
1572
+ || (currentRun && currentRun.runId !== operation.runId))
1573
+ throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1574
+ if (operation.expectedSessionId === null || operation.expectedLeaseId === null) {
1575
+ if (lock)
1576
+ throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1577
+ }
1578
+ else if (!lock
1579
+ || lock.sessionId !== operation.expectedSessionId
1580
+ || lock.leaseId !== operation.expectedLeaseId) {
1581
+ throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1582
+ }
1583
+ const nextRun = operation.exhaustedRun;
1584
+ if (!currentRun) {
1585
+ const createdReceipt = nextRun?.receipts[0];
1586
+ const exhaustedReceipt = nextRun?.receipts[1];
1587
+ if (nextRun && (nextRun.runId !== operation.runId
1588
+ || nextRun.status !== 'exhausted'
1589
+ || nextRun.revision !== 2
1590
+ || nextRun.events.length !== 2
1591
+ || nextRun.progressSnapshots.length !== 4
1592
+ || nextRun.receipts.length !== 2
1593
+ || createdReceipt?.runId !== operation.runId
1594
+ || createdReceipt.revision !== 1
1595
+ || createdReceipt.status !== 'created'
1596
+ || exhaustedReceipt?.runId !== operation.runId
1597
+ || exhaustedReceipt.revision !== 2
1598
+ || exhaustedReceipt.status !== 'exhausted'
1599
+ || exhaustedReceipt.operationId !== `${operation.operationId}:run`
1600
+ || exhaustedReceipt.idempotencyKey !== `${operation.idempotencyKey}:run`
1601
+ || nextRun.latestSnapshotHash !== exhaustedReceipt.snapshotHash
1602
+ || nextRun.predecessor !== undefined))
1603
+ throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1604
+ }
1605
+ else if (currentRun.status === 'exhausted') {
1606
+ if (stablePublicJson(nextRun) !== stablePublicJson(currentRun)) {
1607
+ throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1608
+ }
1609
+ }
1610
+ else {
1611
+ if (!nextRun || nextRun.status !== 'exhausted') {
1612
+ throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1613
+ }
1614
+ const receipt = nextRun.receipts.at(-1);
1615
+ if (!receipt)
1616
+ throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1617
+ const transition = {
1618
+ type: 'transition_run',
1619
+ runId: currentRun.runId,
1620
+ operationId: receipt.operationId,
1621
+ idempotencyKey: receipt.idempotencyKey,
1622
+ expectedRevision: currentRun.revision,
1623
+ expectedSnapshotHash: currentRun.latestSnapshotHash,
1624
+ payloadHash: receipt.payloadHash,
1625
+ next: nextRun,
1626
+ };
1627
+ validateBoundedRunOperation(transition);
1628
+ validateProposedRunTransition(currentRun, transition);
1629
+ }
1630
+ const expectedNext = {
1631
+ ...current,
1632
+ version: '1.2',
1633
+ coolDownUntil: operation.coolDownUntil,
1634
+ currentSession: null,
1635
+ ...(nextRun ? { runControl: nextRun } : {}),
1636
+ };
1637
+ if (stablePublicJson(expectedNext) !== stablePublicJson(operation.next)) {
1638
+ throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1639
+ }
1640
+ }
1641
+ export function buildYoloopRunTransitionPayloadHash(input) {
1642
+ const { receipts: _receipts, ...nextWithoutReceipts } = input.next;
1643
+ return domainHash('p220-yoloop-run-transition-payload-v1', {
1644
+ runId: input.runId,
1645
+ operationId: input.operationId,
1646
+ idempotencyKey: input.idempotencyKey,
1647
+ expectedRevision: input.expectedRevision,
1648
+ expectedSnapshotHash: input.expectedSnapshotHash,
1649
+ next: nextWithoutReceipts,
1650
+ });
1651
+ }
1652
+ const TERMINAL_RUN_STATUSES = new Set([
1653
+ 'success', 'partial', 'blocked', 'failure', 'exhausted', 'cancelled',
1654
+ ]);
1655
+ const RUN_EDGES = {
1656
+ created: ['running', 'cancelling', 'exhausted'],
1657
+ running: ['pausing', 'cancelling', 'success', 'partial', 'blocked', 'failure', 'exhausted'],
1658
+ pausing: ['paused', 'cancelling', 'failure', 'exhausted'],
1659
+ paused: ['resuming', 'cancelling', 'exhausted'],
1660
+ resuming: ['running', 'cancelling', 'failure', 'exhausted'],
1661
+ cancelling: ['cancelled', 'failure', 'exhausted'],
1662
+ };
1663
+ function localLifecycleRequestTarget(status, action) {
1664
+ if (action === 'pause')
1665
+ return status === 'running' ? 'pausing' : null;
1666
+ if (action === 'resume')
1667
+ return status === 'paused' ? 'resuming' : null;
1668
+ return TERMINAL_RUN_STATUSES.has(status) || status === 'cancelling' ? null : 'cancelling';
1669
+ }
1670
+ function buildLocalLifecycleOperation(input) {
1671
+ const revision = input.current.revision + 1;
1672
+ const reasonCodes = input.action === 'cancel' ? ['YOLOOP_RUN_CANCEL_REQUESTED'] : [];
1673
+ const budget = {
1674
+ schemaVersion: 'YoloopOperationalBudgetSummary/1.0',
1675
+ limits: input.current.operationalBudget.limits,
1676
+ usage: input.current.usage,
1677
+ exhaustedDimension: null,
1678
+ };
1679
+ const beforeBase = {
1680
+ schemaVersion: 'ProgressSnapshot/1.0',
1681
+ runId: input.current.runId,
1682
+ sequence: input.current.progressSnapshots.length + 1,
1683
+ revision,
1684
+ operationId: input.operationId,
1685
+ boundary: input.action,
1686
+ phase: 'before',
1687
+ status: input.current.status,
1688
+ occurredAt: input.occurredAt,
1689
+ parentSnapshotHash: input.current.latestSnapshotHash,
1690
+ budget,
1691
+ reasonCodes,
1692
+ publicSafe: true,
1693
+ };
1694
+ const beforeId = domainHash('p220-yoloop-progress-snapshot-id-v1', {
1695
+ runId: beforeBase.runId, operationId: beforeBase.operationId,
1696
+ sequence: beforeBase.sequence, phase: beforeBase.phase,
1697
+ });
1698
+ const before = {
1699
+ ...beforeBase,
1700
+ snapshotId: beforeId,
1701
+ snapshotHash: domainHash('p220-yoloop-progress-snapshot-v1', { ...beforeBase, snapshotId: beforeId }),
1702
+ };
1703
+ const afterBase = {
1704
+ ...beforeBase,
1705
+ sequence: before.sequence + 1,
1706
+ phase: 'after',
1707
+ status: input.target,
1708
+ parentSnapshotHash: before.snapshotHash,
1709
+ };
1710
+ const afterId = domainHash('p220-yoloop-progress-snapshot-id-v1', {
1711
+ runId: afterBase.runId, operationId: afterBase.operationId,
1712
+ sequence: afterBase.sequence, phase: afterBase.phase,
1713
+ });
1714
+ const after = {
1715
+ ...afterBase,
1716
+ snapshotId: afterId,
1717
+ snapshotHash: domainHash('p220-yoloop-progress-snapshot-v1', { ...afterBase, snapshotId: afterId }),
1718
+ };
1719
+ const eventBase = {
1720
+ schemaVersion: 'YoloopRunEvent/1.0',
1721
+ runId: input.current.runId,
1722
+ operationId: input.operationId,
1723
+ kind: 'intent',
1724
+ sequence: input.current.events.length + 1,
1725
+ revision,
1726
+ occurredAt: input.occurredAt,
1727
+ fromStatus: input.current.status,
1728
+ toStatus: input.target,
1729
+ reasonCodes,
1730
+ budgetDimension: null,
1731
+ parentEventHash: input.current.events.at(-1)?.eventHash ?? null,
1732
+ publicSafe: true,
1733
+ };
1734
+ const eventId = domainHash('p220-yoloop-run-event-id-v1', {
1735
+ runId: eventBase.runId, operationId: eventBase.operationId, sequence: eventBase.sequence,
1736
+ });
1737
+ const event = {
1738
+ ...eventBase,
1739
+ eventId,
1740
+ eventHash: domainHash('p220-yoloop-run-event-v1', { ...eventBase, eventId }),
1741
+ };
1742
+ const nextWithoutReceipt = {
1743
+ ...input.current,
1744
+ revision,
1745
+ status: input.target,
1746
+ events: [...input.current.events, event],
1747
+ progressSnapshots: [...input.current.progressSnapshots, before, after],
1748
+ latestSnapshotHash: after.snapshotHash,
1749
+ receipts: [...input.current.receipts],
1750
+ };
1751
+ const base = {
1752
+ runId: input.current.runId,
1753
+ operationId: input.operationId,
1754
+ idempotencyKey: input.idempotencyKey,
1755
+ expectedRevision: input.current.revision,
1756
+ expectedSnapshotHash: input.current.latestSnapshotHash,
1757
+ next: nextWithoutReceipt,
1758
+ };
1759
+ const payloadHash = buildYoloopRunTransitionPayloadHash(base);
1760
+ const receipt = {
1761
+ schemaVersion: 'YoloopRunTransitionReceipt/1.0',
1762
+ runId: input.current.runId,
1763
+ operationId: input.operationId,
1764
+ idempotencyKey: input.idempotencyKey,
1765
+ payloadHash,
1766
+ revision,
1767
+ snapshotHash: after.snapshotHash,
1768
+ status: input.target,
1769
+ };
1770
+ return {
1771
+ type: 'transition_run', ...base, payloadHash,
1772
+ next: { ...nextWithoutReceipt, receipts: [...nextWithoutReceipt.receipts, receipt] },
1773
+ };
1774
+ }
1775
+ function validateBoundedRunOperation(operation) {
1776
+ const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1777
+ if (!boundedId.test(operation.runId)
1778
+ || !boundedId.test(operation.operationId)
1779
+ || !boundedId.test(operation.idempotencyKey)
1780
+ || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1781
+ || !Number.isSafeInteger(operation.expectedRevision)
1782
+ || operation.expectedRevision < 0)
1783
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1784
+ if (operation.payloadHash !== buildYoloopRunTransitionPayloadHash(operation)) {
1785
+ throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1786
+ }
1787
+ }
1788
+ function validateProposedRunTransition(current, operation) {
1789
+ const next = operation.next;
1790
+ const currentEvents = current?.events ?? [];
1791
+ const currentSnapshots = current?.progressSnapshots ?? [];
1792
+ const currentReceipts = current?.receipts ?? [];
1793
+ if (next.schemaVersion !== 'YoloopRunControl/1.0'
1794
+ || next.runId !== operation.runId
1795
+ || next.revision !== operation.expectedRevision + 1
1796
+ || next.events.length !== currentEvents.length + 1
1797
+ || next.progressSnapshots.length !== currentSnapshots.length + 2
1798
+ || next.receipts.length !== currentReceipts.length + 1
1799
+ || stablePublicJson(next.events.slice(0, currentEvents.length)) !== stablePublicJson(currentEvents)
1800
+ || stablePublicJson(next.progressSnapshots.slice(0, currentSnapshots.length)) !== stablePublicJson(currentSnapshots)
1801
+ || stablePublicJson(next.receipts.slice(0, currentReceipts.length)) !== stablePublicJson(currentReceipts))
1802
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1803
+ if (current) {
1804
+ if (TERMINAL_RUN_STATUSES.has(current.status))
1805
+ throw new Error('YOLOOP_RUN_TERMINAL');
1806
+ const proposedBoundary = next.progressSnapshots.at(-2)?.boundary;
1807
+ const boundaryOnly = current.status === next.status
1808
+ && (proposedBoundary === 'budget' || proposedBoundary === 'retry');
1809
+ if (!(RUN_EDGES[current.status] ?? []).includes(next.status) && !boundaryOnly) {
1810
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1811
+ }
1812
+ if (current.startedAt !== next.startedAt || current.deadlineAt !== next.deadlineAt) {
1813
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1814
+ }
1815
+ if (stablePublicJson(current.predecessor) !== stablePublicJson(next.predecessor)) {
1816
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1817
+ }
1818
+ }
1819
+ else if (next.status !== 'created') {
1820
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1821
+ }
1822
+ const before = next.progressSnapshots.at(-2);
1823
+ const after = next.progressSnapshots.at(-1);
1824
+ const event = next.events.at(-1);
1825
+ const receipt = next.receipts.at(-1);
1826
+ if (!before || !after || !event || !receipt
1827
+ || before.phase !== 'before' || after.phase !== 'after'
1828
+ || before.operationId !== operation.operationId || after.operationId !== operation.operationId
1829
+ || event.operationId !== operation.operationId
1830
+ || before.runId !== operation.runId || after.runId !== operation.runId || event.runId !== operation.runId
1831
+ || before.revision !== next.revision || after.revision !== next.revision || event.revision !== next.revision
1832
+ || before.parentSnapshotHash !== (current?.latestSnapshotHash ?? null)
1833
+ || after.parentSnapshotHash !== before.snapshotHash
1834
+ || next.latestSnapshotHash !== after.snapshotHash
1835
+ || receipt.schemaVersion !== 'YoloopRunTransitionReceipt/1.0'
1836
+ || receipt.runId !== operation.runId
1837
+ || receipt.operationId !== operation.operationId
1838
+ || receipt.idempotencyKey !== operation.idempotencyKey
1839
+ || receipt.payloadHash !== operation.payloadHash
1840
+ || receipt.revision !== next.revision
1841
+ || receipt.snapshotHash !== next.latestSnapshotHash
1842
+ || receipt.status !== next.status
1843
+ || Date.parse(next.deadlineAt) <= Date.parse(next.startedAt))
1844
+ throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1845
+ validateSnapshotHash(before);
1846
+ validateSnapshotHash(after);
1847
+ validateEventHash(event, currentEvents.at(-1)?.eventHash ?? null);
1848
+ }
1849
+ function validateSnapshotHash(snapshot) {
1850
+ const { snapshotHash, ...hashed } = snapshot;
1851
+ if (snapshotHash !== domainHash('p220-yoloop-progress-snapshot-v1', hashed)) {
1852
+ throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1853
+ }
1854
+ }
1855
+ function validateEventHash(event, expectedParent) {
1856
+ const { eventHash, ...hashed } = event;
1857
+ if (event.parentEventHash !== expectedParent
1858
+ || eventHash !== domainHash('p220-yoloop-run-event-v1', hashed))
1859
+ throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1860
+ }
1861
+ function buildPublicLeaseId(sessionId, nowIso) {
1862
+ return `lease-${publicStableHash(`${sessionId}:${nowIso}`)}`;
1863
+ }
1864
+ function publicStableHash(value) {
1865
+ let hash = 0x811c9dc5;
1866
+ for (let i = 0; i < value.length; i += 1) {
1867
+ hash ^= value.charCodeAt(i);
1868
+ hash = Math.imul(hash, 0x01000193) >>> 0;
1869
+ }
1870
+ return hash.toString(36).padStart(7, '0');
1871
+ }
1872
+ function classifyClientLease(lock, nowIso) {
1873
+ const expiresAt = lock.leaseExpiresAt ?? lock.ttlExpiresAt;
1874
+ const expiresMs = Date.parse(expiresAt);
1875
+ const nowMs = Date.parse(nowIso);
1876
+ if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs))
1877
+ return 'unknown';
1878
+ return expiresMs <= nowMs ? 'expired' : 'active';
1879
+ }
1880
+ // ── Pure merge helper (used by readState) ───────────────────────────────
1881
+ function mergeCountersWithDefaults(parsed, nowIso) {
1882
+ const defaults = createDefaultCounters(nowIso);
1883
+ return {
1884
+ // Preserve additive/unknown fields byte-semantically across read/write cycles.
1885
+ ...parsed,
1886
+ version: parsed.version === '1.2' ? '1.2' : '1.1',
1887
+ lastResetAt: parsed.lastResetAt ?? defaults.lastResetAt,
1888
+ storiesRunToday: parsed.storiesRunToday ?? 0,
1889
+ iterationsToday: parsed.iterationsToday ?? 0,
1890
+ iterationsTodayDate: parsed.iterationsTodayDate ?? defaults.iterationsTodayDate,
1891
+ tokensUsedToday: parsed.tokensUsedToday ?? 0,
1892
+ lastRunAt: parsed.lastRunAt ?? null,
1893
+ coolDownUntil: parsed.coolDownUntil ?? null,
1894
+ totalRunsAllTime: parsed.totalRunsAllTime ?? 0,
1895
+ totalStoriesAllTime: parsed.totalStoriesAllTime ?? 0,
1896
+ currentSession: parsed.currentSession ?? null,
1897
+ ...(parsed.runControl !== undefined ? { runControl: parsed.runControl } : {}),
1898
+ ...(parsed.continuationPolicy !== undefined ? { continuationPolicy: parsed.continuationPolicy } : {}),
1899
+ ...(parsed.continuationSession !== undefined ? { continuationSession: parsed.continuationSession } : {}),
1900
+ ...(parsed.pendingContinuationAuthorities !== undefined ? { pendingContinuationAuthorities: parsed.pendingContinuationAuthorities } : {}),
1901
+ ...(parsed.continuationReceipts !== undefined ? { continuationReceipts: parsed.continuationReceipts } : {}),
1902
+ ...(parsed.continuationRecoveryReceipts !== undefined ? { continuationRecoveryReceipts: parsed.continuationRecoveryReceipts } : {}),
1903
+ ...(parsed.continuationReplanReceipts !== undefined ? { continuationReplanReceipts: parsed.continuationReplanReceipts } : {}),
1904
+ // P104.06: preserve recovery_history (defensive default to [])
1905
+ recovery_history: Array.isArray(parsed.recovery_history)
1906
+ ? capRecoveryHistory(parsed.recovery_history)
1907
+ : [],
1908
+ };
1909
+ }
1910
+ // ── P104.06: pure FIFO cap helper (exported for tests) ─────────────────────
1911
+ /**
1912
+ * Enforce FIFO bound on `recovery_history[]`. Returns a new array of length
1913
+ * <= `RECOVERY_HISTORY_CAP`, preserving the most recent entries (drops from
1914
+ * head). Pure: input is not mutated.
1915
+ */
1916
+ export function capRecoveryHistory(history) {
1917
+ if (history.length <= RECOVERY_HISTORY_CAP)
1918
+ return history;
1919
+ return history.slice(history.length - RECOVERY_HISTORY_CAP);
1920
+ }