onto-mcp 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/.onto/processes/reconstruct/actionable-ontology-seed-recomposition-design.md +447 -0
  2. package/.onto/processes/reconstruct/foundry-style-ontology-seed-contract.md +934 -0
  3. package/.onto/processes/reconstruct/reconstruct-boundary-contract.md +303 -725
  4. package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +1645 -0
  5. package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +26 -22
  6. package/.onto/processes/reconstruct/source-profile-contract.md +49 -23
  7. package/.onto/processes/reconstruct/source-profiles/code.md +6 -3
  8. package/.onto/processes/reconstruct/source-profiles/database.md +5 -2
  9. package/.onto/processes/reconstruct/source-profiles/document.md +5 -2
  10. package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +5 -4
  11. package/.onto/processes/review/review-execution-ux-contract.md +40 -0
  12. package/.onto/processes/shared/pipeline-execution-ledger-contract.md +26 -10
  13. package/.onto/processes/shared/target-material-kind-contract.md +29 -16
  14. package/AGENTS.md +6 -4
  15. package/README.md +135 -76
  16. package/dist/cli.js +8 -8
  17. package/dist/core-api/reconstruct-api.js +117 -31
  18. package/dist/core-api/review-api.js +47 -0
  19. package/dist/core-runtime/cli/codex-review-unit-executor.js +39 -2
  20. package/dist/core-runtime/cli/complete-review-session.js +2 -2
  21. package/dist/core-runtime/cli/mock-review-unit-executor.js +1 -1
  22. package/dist/core-runtime/cli/review-invoke.js +9 -9
  23. package/dist/core-runtime/cli/run-review-prompt-execution.js +39 -5
  24. package/dist/core-runtime/cli/spawn-watcher.js +266 -47
  25. package/dist/core-runtime/cli/start-review-session.js +3 -3
  26. package/dist/core-runtime/llm/llm-caller.js +11 -0
  27. package/dist/core-runtime/llm/llm-tool-loop.js +2 -0
  28. package/dist/core-runtime/observability/runtime-stream-observation.js +118 -0
  29. package/dist/core-runtime/onboard/cli-host.js +149 -0
  30. package/dist/core-runtime/onboard/host-target.js +22 -0
  31. package/dist/core-runtime/onboard/json-config-host.js +122 -0
  32. package/dist/core-runtime/onboard/path-scan.js +26 -0
  33. package/dist/core-runtime/onboard/prompt.js +51 -0
  34. package/dist/core-runtime/onboard/register.js +207 -0
  35. package/dist/core-runtime/onboard/types.js +27 -0
  36. package/dist/core-runtime/reconstruct/actionable-seed-validation.js +1777 -0
  37. package/dist/core-runtime/reconstruct/artifact-types.js +10 -4
  38. package/dist/core-runtime/reconstruct/contract-registry.js +623 -0
  39. package/dist/core-runtime/reconstruct/domain-id.js +10 -0
  40. package/dist/core-runtime/reconstruct/governing-snapshot.js +716 -0
  41. package/dist/core-runtime/reconstruct/material-profile-validation.js +191 -0
  42. package/dist/core-runtime/reconstruct/materialize-preparation.js +49 -11
  43. package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +269 -79
  44. package/dist/core-runtime/reconstruct/post-seed-validation.js +1194 -51
  45. package/dist/core-runtime/reconstruct/record.js +104 -20
  46. package/dist/core-runtime/reconstruct/run.js +2107 -413
  47. package/dist/core-runtime/reconstruct/seed-claim-projections.js +268 -0
  48. package/dist/core-runtime/reconstruct/source-profiles.js +93 -4
  49. package/dist/core-runtime/reconstruct/terminal-validation.js +807 -0
  50. package/dist/core-runtime/review/review-invocation-runner.js +4 -4
  51. package/dist/mcp/server.js +110 -38
  52. package/dist/mcp/tool-schemas.js +20 -6
  53. package/package.json +8 -17
  54. package/scripts/onto-review-watch.sh +486 -0
  55. package/scripts/onto-runtime-watch.sh +122 -0
  56. package/scripts/postinstall-hint.js +22 -0
  57. package/.onto/processes/reconstruct/top-level-concept-discovery-contract.md +0 -387
  58. package/dist/core-runtime/cli/bootstrap-review-binding.js +0 -186
  59. package/dist/core-runtime/cli/codex-nested-dispatch.test.js +0 -390
  60. package/dist/core-runtime/cli/codex-nested-teamlead-executor.test.js +0 -335
  61. package/dist/core-runtime/cli/coordinator-helpers.js +0 -583
  62. package/dist/core-runtime/cli/coordinator-state-machine-deliberation.test.js +0 -167
  63. package/dist/core-runtime/cli/coordinator-state-machine.js +0 -794
  64. package/dist/core-runtime/cli/e2e-codex-multi-agent-fixes.test.js +0 -615
  65. package/dist/core-runtime/cli/e2e-start-review-session.test.js +0 -312
  66. package/dist/core-runtime/cli/health.js +0 -44
  67. package/dist/core-runtime/cli/inline-http-review-unit-executor.test.js +0 -567
  68. package/dist/core-runtime/cli/materialize-review-execution-preparation.js +0 -104
  69. package/dist/core-runtime/cli/migrate-session-roots.js +0 -118
  70. package/dist/core-runtime/cli/repo-layout-migration-replace.smoke.test.js +0 -106
  71. package/dist/core-runtime/cli/review-invoke-auto-resolution.test.js +0 -268
  72. package/dist/core-runtime/cli/review-invoke-coordinator-topology.test.js +0 -136
  73. package/dist/core-runtime/cli/review-invoke-resolver-caching.test.js +0 -201
  74. package/dist/core-runtime/cli/review-invoke-topology-dispatch.test.js +0 -192
  75. package/dist/core-runtime/cli/session-root-guard.js +0 -168
  76. package/dist/core-runtime/cli/spawn-watcher.test.js +0 -457
  77. package/dist/core-runtime/cli/strip-wrapping-code-fence.test.js +0 -79
  78. package/dist/core-runtime/cli/teamcreate-lens-deliberation-executor.js +0 -412
  79. package/dist/core-runtime/cli/teamcreate-lens-deliberation-executor.test.js +0 -351
  80. package/dist/core-runtime/cli/topology-executor-mapping.js +0 -139
  81. package/dist/core-runtime/cli/topology-executor-mapping.test.js +0 -173
  82. package/dist/core-runtime/cli/write-review-interpretation.js +0 -81
  83. package/dist/core-runtime/config/onto-config-cli.js +0 -278
  84. package/dist/core-runtime/config/onto-config-key-path.js +0 -288
  85. package/dist/core-runtime/config/onto-config-key-path.test.js +0 -195
  86. package/dist/core-runtime/config/onto-config-preview.js +0 -108
  87. package/dist/core-runtime/config/onto-config-preview.test.js +0 -132
  88. package/dist/core-runtime/discovery/config-chain.js +0 -118
  89. package/dist/core-runtime/discovery/config-chain.test.js +0 -103
  90. package/dist/core-runtime/discovery/config-profile.js +0 -199
  91. package/dist/core-runtime/discovery/config-profile.test.js +0 -233
  92. package/dist/core-runtime/discovery/host-detection.test.js +0 -186
  93. package/dist/core-runtime/discovery/installation-paths.test.js +0 -65
  94. package/dist/core-runtime/discovery/lens-registry.test.js +0 -81
  95. package/dist/core-runtime/discovery/path-normalization.test.js +0 -22
  96. package/dist/core-runtime/discovery/plugin-path.js +0 -72
  97. package/dist/core-runtime/discovery/plugin-path.test.js +0 -95
  98. package/dist/core-runtime/evolve/adapters/code-product/compile/compile-defense.js +0 -344
  99. package/dist/core-runtime/evolve/adapters/code-product/compile/compile-defense.test.js +0 -915
  100. package/dist/core-runtime/evolve/adapters/code-product/compile/compile.js +0 -564
  101. package/dist/core-runtime/evolve/adapters/code-product/compile/compile.test.js +0 -708
  102. package/dist/core-runtime/evolve/adapters/code-product/parsers/brief-parser.js +0 -165
  103. package/dist/core-runtime/evolve/adapters/code-product/parsers/brief-parser.test.js +0 -227
  104. package/dist/core-runtime/evolve/adapters/code-product/validators/validate.js +0 -59
  105. package/dist/core-runtime/evolve/adapters/code-product/validators/validate.test.js +0 -205
  106. package/dist/core-runtime/evolve/adapters/methodology/adapter.js +0 -16
  107. package/dist/core-runtime/evolve/adapters/methodology/adapter.test.js +0 -9
  108. package/dist/core-runtime/evolve/adapters/methodology/perspectives/authority-consistency.js +0 -298
  109. package/dist/core-runtime/evolve/adapters/methodology/perspectives/authority-consistency.test.js +0 -70
  110. package/dist/core-runtime/evolve/adapters/methodology/scope-types/process.js +0 -46
  111. package/dist/core-runtime/evolve/adapters/methodology/scope-types/process.test.js +0 -73
  112. package/dist/core-runtime/evolve/adapters/registry.js +0 -47
  113. package/dist/core-runtime/evolve/adapters/registry.test.js +0 -67
  114. package/dist/core-runtime/evolve/cli.js +0 -256
  115. package/dist/core-runtime/evolve/commands/align.js +0 -194
  116. package/dist/core-runtime/evolve/commands/align.test.js +0 -82
  117. package/dist/core-runtime/evolve/commands/apply.js +0 -161
  118. package/dist/core-runtime/evolve/commands/apply.test.js +0 -138
  119. package/dist/core-runtime/evolve/commands/close.js +0 -39
  120. package/dist/core-runtime/evolve/commands/close.test.js +0 -99
  121. package/dist/core-runtime/evolve/commands/defer.js +0 -40
  122. package/dist/core-runtime/evolve/commands/defer.test.js +0 -134
  123. package/dist/core-runtime/evolve/commands/draft.js +0 -323
  124. package/dist/core-runtime/evolve/commands/draft.test.js +0 -178
  125. package/dist/core-runtime/evolve/commands/e2e-evolve-full-cycle.test.js +0 -208
  126. package/dist/core-runtime/evolve/commands/error-messages.js +0 -125
  127. package/dist/core-runtime/evolve/commands/error-messages.test.js +0 -167
  128. package/dist/core-runtime/evolve/commands/propose-align.js +0 -222
  129. package/dist/core-runtime/evolve/commands/propose-align.test.js +0 -136
  130. package/dist/core-runtime/evolve/commands/reconstruct.js +0 -330
  131. package/dist/core-runtime/evolve/commands/reconstruct.test.js +0 -278
  132. package/dist/core-runtime/evolve/commands/shared.js +0 -22
  133. package/dist/core-runtime/evolve/commands/stale-check.js +0 -103
  134. package/dist/core-runtime/evolve/commands/stale-check.test.js +0 -84
  135. package/dist/core-runtime/evolve/commands/start.js +0 -887
  136. package/dist/core-runtime/evolve/commands/start.test.js +0 -396
  137. package/dist/core-runtime/evolve/config/project-config.js +0 -99
  138. package/dist/core-runtime/evolve/config/project-config.test.js +0 -170
  139. package/dist/core-runtime/evolve/renderers/align-packet.js +0 -280
  140. package/dist/core-runtime/evolve/renderers/align-packet.test.js +0 -332
  141. package/dist/core-runtime/evolve/renderers/draft-packet.js +0 -303
  142. package/dist/core-runtime/evolve/renderers/draft-packet.test.js +0 -377
  143. package/dist/core-runtime/evolve/renderers/format.js +0 -5
  144. package/dist/core-runtime/evolve/renderers/scope-md.js +0 -237
  145. package/dist/core-runtime/evolve/renderers/scope-md.test.js +0 -306
  146. package/dist/core-runtime/govern/cli.js +0 -369
  147. package/dist/core-runtime/govern/cli.test.js +0 -314
  148. package/dist/core-runtime/govern/drift-engine.js +0 -103
  149. package/dist/core-runtime/govern/drift-engine.test.js +0 -319
  150. package/dist/core-runtime/govern/promote-principle.js +0 -206
  151. package/dist/core-runtime/govern/promote-principle.test.js +0 -368
  152. package/dist/core-runtime/govern/queue.js +0 -81
  153. package/dist/core-runtime/govern/types.js +0 -16
  154. package/dist/core-runtime/install/cli.js +0 -530
  155. package/dist/core-runtime/install/detect.js +0 -128
  156. package/dist/core-runtime/install/detect.test.js +0 -155
  157. package/dist/core-runtime/install/gitignore-update.js +0 -74
  158. package/dist/core-runtime/install/gitignore-update.test.js +0 -64
  159. package/dist/core-runtime/install/install-integration.test.js +0 -373
  160. package/dist/core-runtime/install/prompts.js +0 -389
  161. package/dist/core-runtime/install/prompts.test.js +0 -293
  162. package/dist/core-runtime/install/types.js +0 -26
  163. package/dist/core-runtime/install/validation.js +0 -295
  164. package/dist/core-runtime/install/validation.test.js +0 -313
  165. package/dist/core-runtime/install/writer.js +0 -254
  166. package/dist/core-runtime/install/writer.test.js +0 -218
  167. package/dist/core-runtime/learning/extractor.js +0 -461
  168. package/dist/core-runtime/learning/feedback.js +0 -179
  169. package/dist/core-runtime/learning/health-report.js +0 -165
  170. package/dist/core-runtime/learning/health-report.test.js +0 -169
  171. package/dist/core-runtime/learning/loader.js +0 -388
  172. package/dist/core-runtime/learning/loader.test.js +0 -102
  173. package/dist/core-runtime/learning/promote/apply-state.js +0 -240
  174. package/dist/core-runtime/learning/promote/audit-obligation.js +0 -195
  175. package/dist/core-runtime/learning/promote/collector.js +0 -432
  176. package/dist/core-runtime/learning/promote/degraded-state.js +0 -125
  177. package/dist/core-runtime/learning/promote/domain-doc-proposer.js +0 -166
  178. package/dist/core-runtime/learning/promote/e2e-promote.test.js +0 -6385
  179. package/dist/core-runtime/learning/promote/health-snapshot.js +0 -150
  180. package/dist/core-runtime/learning/promote/insight-reclassifier.js +0 -544
  181. package/dist/core-runtime/learning/promote/judgment-auditor.js +0 -517
  182. package/dist/core-runtime/learning/promote/panel-reviewer.js +0 -1158
  183. package/dist/core-runtime/learning/promote/promote-executor.js +0 -1675
  184. package/dist/core-runtime/learning/promote/promoter.js +0 -307
  185. package/dist/core-runtime/learning/promote/retirement.js +0 -122
  186. package/dist/core-runtime/learning/promote/types.js +0 -23
  187. package/dist/core-runtime/learning/prompt-sections.js +0 -51
  188. package/dist/core-runtime/learning/shared/artifact-registry-init.js +0 -45
  189. package/dist/core-runtime/learning/shared/artifact-registry.js +0 -254
  190. package/dist/core-runtime/learning/shared/audit-obligation-kernel.js +0 -73
  191. package/dist/core-runtime/learning/shared/audit-state.js +0 -99
  192. package/dist/core-runtime/learning/shared/duplicate-check.js +0 -28
  193. package/dist/core-runtime/learning/shared/llm-caller.js +0 -831
  194. package/dist/core-runtime/learning/shared/llm-caller.test.js +0 -601
  195. package/dist/core-runtime/learning/shared/llm-tool-loop.js +0 -393
  196. package/dist/core-runtime/learning/shared/mode.js +0 -25
  197. package/dist/core-runtime/learning/shared/paths.js +0 -84
  198. package/dist/core-runtime/learning/shared/paths.test.js +0 -79
  199. package/dist/core-runtime/learning/shared/patterns.js +0 -37
  200. package/dist/core-runtime/learning/shared/recoverability.js +0 -355
  201. package/dist/core-runtime/learning/shared/recovery-context.js +0 -374
  202. package/dist/core-runtime/learning/shared/scope.js +0 -1
  203. package/dist/core-runtime/learning/shared/semantic-classifier.js +0 -94
  204. package/dist/core-runtime/learning/shared/specs/apply-execution-state-spec.js +0 -42
  205. package/dist/core-runtime/learning/shared/specs/audit-state-spec.js +0 -37
  206. package/dist/core-runtime/learning/shared/specs/backup-metadata-spec.js +0 -39
  207. package/dist/core-runtime/learning/shared/specs/emergency-log-spec.js +0 -41
  208. package/dist/core-runtime/learning/shared/specs/layout-version-spec.js +0 -38
  209. package/dist/core-runtime/learning/shared/specs/promote-decisions-spec.js +0 -43
  210. package/dist/core-runtime/learning/shared/specs/promote-report-spec.js +0 -113
  211. package/dist/core-runtime/learning/shared/specs/prune-log-spec.js +0 -36
  212. package/dist/core-runtime/learning/shared/specs/recovery-resolution-spec.js +0 -48
  213. package/dist/core-runtime/learning/shared/specs/restore-manifest-spec.js +0 -43
  214. package/dist/core-runtime/learning/shared/specs/spec-helpers.js +0 -64
  215. package/dist/core-runtime/learning/usage-tracker.js +0 -190
  216. package/dist/core-runtime/learning/usage-tracker.test.js +0 -176
  217. package/dist/core-runtime/onboard/detect-review-axes.js +0 -122
  218. package/dist/core-runtime/onboard/detect-review-axes.test.js +0 -127
  219. package/dist/core-runtime/onboard/write-review-block.js +0 -188
  220. package/dist/core-runtime/onboard/write-review-block.test.js +0 -240
  221. package/dist/core-runtime/readers/brownfield-builder.js +0 -150
  222. package/dist/core-runtime/readers/brownfield-builder.test.js +0 -136
  223. package/dist/core-runtime/readers/code-chunk-collector.js +0 -53
  224. package/dist/core-runtime/readers/code-chunk-collector.test.js +0 -136
  225. package/dist/core-runtime/readers/file-utils.js +0 -240
  226. package/dist/core-runtime/readers/file-utils.test.js +0 -146
  227. package/dist/core-runtime/readers/lexicon-citation-check.js +0 -93
  228. package/dist/core-runtime/readers/lexicon-citation-check.test.js +0 -77
  229. package/dist/core-runtime/readers/mcp-figma.js +0 -30
  230. package/dist/core-runtime/readers/mcp-figma.test.js +0 -82
  231. package/dist/core-runtime/readers/mcp-generic.js +0 -31
  232. package/dist/core-runtime/readers/mcp-generic.test.js +0 -76
  233. package/dist/core-runtime/readers/ontology-index.js +0 -148
  234. package/dist/core-runtime/readers/ontology-index.test.js +0 -245
  235. package/dist/core-runtime/readers/ontology-query.js +0 -168
  236. package/dist/core-runtime/readers/ontology-query.test.js +0 -311
  237. package/dist/core-runtime/readers/ontology-resolve.js +0 -48
  238. package/dist/core-runtime/readers/ontology-resolve.test.js +0 -48
  239. package/dist/core-runtime/readers/patterns/index.js +0 -7
  240. package/dist/core-runtime/readers/review-log.js +0 -213
  241. package/dist/core-runtime/readers/review-log.test.js +0 -313
  242. package/dist/core-runtime/readers/scan-local.js +0 -102
  243. package/dist/core-runtime/readers/scan-local.test.js +0 -102
  244. package/dist/core-runtime/readers/scan-tarball.js +0 -121
  245. package/dist/core-runtime/readers/scan-tarball.test.js +0 -283
  246. package/dist/core-runtime/readers/scan-vault.js +0 -34
  247. package/dist/core-runtime/readers/scan-vault.test.js +0 -81
  248. package/dist/core-runtime/readers/types.js +0 -42
  249. package/dist/core-runtime/readers/types.test.js +0 -94
  250. package/dist/core-runtime/readers/viewpoint-collectors.js +0 -229
  251. package/dist/core-runtime/reconstruct/seed-candidate-validation.js +0 -385
  252. package/dist/core-runtime/review/citation-audit.test.js +0 -165
  253. package/dist/core-runtime/review/execution-plan-resolver.js +0 -247
  254. package/dist/core-runtime/review/execution-plan-resolver.test.js +0 -243
  255. package/dist/core-runtime/review/execution-topology-resolver-axis-first.test.js +0 -246
  256. package/dist/core-runtime/review/execution-topology-resolver.js +0 -401
  257. package/dist/core-runtime/review/execution-topology-resolver.test.js +0 -315
  258. package/dist/core-runtime/review/inline-context-embedder.test.js +0 -154
  259. package/dist/core-runtime/review/legacy-mode-policy.js +0 -88
  260. package/dist/core-runtime/review/materializers-effort-persist.test.js +0 -79
  261. package/dist/core-runtime/review/ontology-path-classifier.js +0 -179
  262. package/dist/core-runtime/review/ontology-path-classifier.test.js +0 -216
  263. package/dist/core-runtime/review/packet-boundary-policy.test.js +0 -107
  264. package/dist/core-runtime/review/participating-lens-paths.test.js +0 -73
  265. package/dist/core-runtime/review/review-config-legacy-translate.js +0 -244
  266. package/dist/core-runtime/review/review-config-legacy-translate.test.js +0 -161
  267. package/dist/core-runtime/review/review-config-validator.js +0 -289
  268. package/dist/core-runtime/review/review-config-validator.test.js +0 -236
  269. package/dist/core-runtime/review/shape-pipeline-audit.test.js +0 -311
  270. package/dist/core-runtime/review/shape-to-topology-id.js +0 -117
  271. package/dist/core-runtime/review/shape-to-topology-id.test.js +0 -132
  272. package/dist/core-runtime/review/topology-shape-derivation.js +0 -155
  273. package/dist/core-runtime/review/topology-shape-derivation.test.js +0 -195
  274. package/dist/core-runtime/scope-runtime/constants.js +0 -12
  275. package/dist/core-runtime/scope-runtime/constraint-pool.js +0 -166
  276. package/dist/core-runtime/scope-runtime/constraint-pool.test.js +0 -674
  277. package/dist/core-runtime/scope-runtime/domain-validation-log.js +0 -135
  278. package/dist/core-runtime/scope-runtime/domain-validation-log.test.js +0 -156
  279. package/dist/core-runtime/scope-runtime/eval-persistence.js +0 -65
  280. package/dist/core-runtime/scope-runtime/eval-persistence.test.js +0 -84
  281. package/dist/core-runtime/scope-runtime/event-pipeline.js +0 -64
  282. package/dist/core-runtime/scope-runtime/event-pipeline.test.js +0 -450
  283. package/dist/core-runtime/scope-runtime/event-store.js +0 -39
  284. package/dist/core-runtime/scope-runtime/event-store.test.js +0 -95
  285. package/dist/core-runtime/scope-runtime/gate-guard.js +0 -348
  286. package/dist/core-runtime/scope-runtime/gate-guard.test.js +0 -1047
  287. package/dist/core-runtime/scope-runtime/hash.js +0 -4
  288. package/dist/core-runtime/scope-runtime/hash.test.js +0 -33
  289. package/dist/core-runtime/scope-runtime/id.js +0 -4
  290. package/dist/core-runtime/scope-runtime/id.test.js +0 -17
  291. package/dist/core-runtime/scope-runtime/reducer.js +0 -297
  292. package/dist/core-runtime/scope-runtime/reducer.test.js +0 -759
  293. package/dist/core-runtime/scope-runtime/scope-manager.js +0 -161
  294. package/dist/core-runtime/scope-runtime/state-machine.js +0 -309
  295. package/dist/core-runtime/scope-runtime/state-machine.test.js +0 -704
  296. package/dist/core-runtime/scope-runtime/types.js +0 -116
  297. package/dist/core-runtime/scope-runtime/types.test.js +0 -69
  298. package/dist/core-runtime/translate/render-for-user.js +0 -169
  299. package/dist/core-runtime/translate/render-for-user.test.js +0 -122
  300. package/dist/providers/capability-contract.js +0 -1
@@ -1,1675 +0,0 @@
1
- /**
2
- * Phase 3 Promote — Phase B Orchestrator (Step 10b).
3
- *
4
- * Design authority:
5
- * - learn-phase3-design-v9.md DD-15 (Phase B atomicity + dual failure modes)
6
- * - learn-phase3-design-v9.md DD-22 (recovery context + canonical attempt selection)
7
- * - learn-phase3-design-v9.md DD-23 (RecoveryResolution operator seat)
8
- * - learn-phase3-design-v8.md DD-16 (recoverability checkpoint)
9
- * - learn-phase3-design-v5.md §1.3 Phase B canonical sequence
10
- * - .onto/processes/learn/promote.md Step 6 (Promotion Execution)
11
- *
12
- * Responsibility:
13
- * - Load PromoteReport + PromoteDecisions for a session.
14
- * - Verify baseline freshness (DD-10) — abort with stale_baseline degraded
15
- * state when files have shifted unless --force-stale.
16
- * - Recovery: gather context, resolve truth, route manual_escalation to
17
- * operator. When --resume, load prior ApplyExecutionState.
18
- * - Create recoverability checkpoint (DD-16) before any mutation.
19
- * - Initialize ApplyExecutionState (DD-15 + DD-22 attempt_id).
20
- * - Apply approved decisions in order, persisting state on each step:
21
- * 1. promotions (append to global file)
22
- * 2. contradiction_replacements (in-place line replace)
23
- * 3. axis_tag_changes (in-place line edit)
24
- * 4. retirements (delete or comment out)
25
- * 5. audit_outcomes (modify/delete based on audit)
26
- * 6. obligation_waive (audit-state transition)
27
- * 7. cross_agent_dedup_approvals (scope-aware line-level mark +
28
- * consolidated append, CG1/CG2/UF1 fixes applied)
29
- * 8. domain_doc_updates (LLM content generation + file update)
30
- * - Transition status: in_progress → completed | failed_resumable |
31
- * apply_verification_failed.
32
- * - Emergency log on state_persistence_failed (DD-15 dual failure).
33
- *
34
- * File-mutation contract:
35
- * - All learning file edits are line-level operations against the .md
36
- * storage. We use simple string-replace because the file format is one
37
- * learning per line + optional `<!-- learning_id: ... -->` comment line.
38
- * - Backups go through createRecoverabilityCheckpoint() before any edit
39
- * so a single restore command can roll back the whole attempt.
40
- */
41
- import crypto from "node:crypto";
42
- import fs from "node:fs";
43
- import os from "node:os";
44
- import path from "node:path";
45
- import { callLlm } from "../shared/llm-caller.js";
46
- import { REGISTRY } from "../shared/artifact-registry.js";
47
- import { loadAuditState, saveAuditState, findObligation, } from "../shared/audit-state.js";
48
- import { createRecoverabilityCheckpoint, } from "../shared/recoverability.js";
49
- import { gatherRecoveryContext, resolveRecoveryTruth, buildEscalationMessage, getSessionPromoteRoot, EMERGENCY_LOG_PATH, } from "../shared/recovery-context.js";
50
- import { verifyBaselineHash } from "./collector.js";
51
- import { generateUlid, initApplyState, loadApplyState, markApplied, markFailed, persistApplyState, transitionStatus, } from "./apply-state.js";
52
- // ---------------------------------------------------------------------------
53
- // Path helpers
54
- // ---------------------------------------------------------------------------
55
- function resolveSessionRoot(config) {
56
- return (config.sessionRoot ?? getSessionPromoteRoot(config.projectRoot, config.sessionId));
57
- }
58
- function resolveAuditStatePath(config) {
59
- if (config.auditStatePath)
60
- return config.auditStatePath;
61
- const home = config.ontoHome ?? path.join(os.homedir(), ".onto");
62
- return path.join(home, "audit-state.yaml");
63
- }
64
- function getGlobalLearningFilePath(agentId, ontoHome) {
65
- const home = ontoHome ?? path.join(os.homedir(), ".onto");
66
- return path.join(home, "learnings", `${agentId}.md`);
67
- }
68
- function getProjectLearningFilePath(projectRoot, agentId) {
69
- return path.join(projectRoot, ".onto", "learnings", `${agentId}.md`);
70
- }
71
- // ---------------------------------------------------------------------------
72
- // Decision applicators (per-kind file mutators)
73
- // ---------------------------------------------------------------------------
74
- function decisionId(kind, identity) {
75
- // Stable string id derived from per-decision identity. Used by
76
- // markApplied/markFailed to track pending → applied/failed transitions.
77
- return `${kind}::${identity}`;
78
- }
79
- /**
80
- * Alias used by the apply-loop guards. Same shape as decisionId, named more
81
- * explicitly so the call sites read as "the decision id for this kind".
82
- */
83
- function decisionIdFor(kind, identity) {
84
- return decisionId(kind, identity);
85
- }
86
- function writeEmergencyLogEntry(args) {
87
- const entry = {
88
- schema_version: "1",
89
- entry_id: crypto.randomBytes(8).toString("hex"),
90
- session_id: args.sessionId,
91
- written_at: new Date().toISOString(),
92
- attempt_id: args.attemptId,
93
- generation: args.generation,
94
- fatal_error_kind: args.fatalErrorKind,
95
- fatal_error_message: args.fatalErrorMessage,
96
- last_known_state_snapshot: {
97
- status: args.snapshot.status,
98
- applied_count: args.snapshot.applied_decisions.length,
99
- failed_count: args.snapshot.failed_decisions.length,
100
- pending_count: args.snapshot.pending_decisions.length,
101
- },
102
- recoverability_checkpoint: null,
103
- partial_decisions_attempted: args.snapshot.applied_decisions,
104
- session_root: args.sessionRoot,
105
- };
106
- try {
107
- fs.mkdirSync(path.dirname(EMERGENCY_LOG_PATH), { recursive: true });
108
- fs.appendFileSync(EMERGENCY_LOG_PATH, JSON.stringify(entry) + "\n", "utf8");
109
- }
110
- catch (logError) {
111
- // Double failure: emergency log itself can't be written. There is
112
- // nothing more durable we can do — surface to stderr so at least the
113
- // process output captures the loss.
114
- process.stderr.write(`[promote-executor] FATAL: emergency log write failed after persistence ` +
115
- `failure. session=${args.sessionId} attempt=${args.attemptId} ` +
116
- `gen=${args.generation} kind=${args.fatalErrorKind} ` +
117
- `original_error=${args.fatalErrorMessage} ` +
118
- `log_error=${logError instanceof Error ? logError.message : String(logError)}\n`);
119
- }
120
- }
121
- function ensureFileExists(filePath) {
122
- const dir = path.dirname(filePath);
123
- if (!fs.existsSync(dir)) {
124
- fs.mkdirSync(dir, { recursive: true });
125
- }
126
- if (!fs.existsSync(filePath)) {
127
- fs.writeFileSync(filePath, "<!-- format_version: 1 -->\n", "utf8");
128
- }
129
- }
130
- /**
131
- * Append a learning line + learning_id comment to the target file.
132
- *
133
- * Phase 2 extractor pattern: line followed by `<!-- learning_id: <hash> -->`
134
- * marker on the next line so future runs can dedup against the durable id.
135
- */
136
- function appendLearningLine(filePath, line, learningId) {
137
- ensureFileExists(filePath);
138
- // Guard against existing files that lack a trailing newline: without this
139
- // the new block would concatenate onto the last existing line (e.g. a
140
- // comment marker written by replaceLineInFile), producing lines like
141
- // `<!-- ... -->- [fact] ...`. Peek at the last byte and prepend a newline
142
- // if needed.
143
- let leading = "";
144
- try {
145
- const stat = fs.statSync(filePath);
146
- if (stat.size > 0) {
147
- const fd = fs.openSync(filePath, "r");
148
- try {
149
- const tail = Buffer.alloc(1);
150
- fs.readSync(fd, tail, 0, 1, stat.size - 1);
151
- if (tail[0] !== 0x0a /* \n */)
152
- leading = "\n";
153
- }
154
- finally {
155
- fs.closeSync(fd);
156
- }
157
- }
158
- }
159
- catch {
160
- // stat/open failure falls through to the plain append path
161
- }
162
- const block = `${leading}${line}\n<!-- learning_id: ${learningId} taxonomy_version: phase3-promoted -->\n`;
163
- fs.appendFileSync(filePath, block, "utf8");
164
- }
165
- /**
166
- * Replace the first line in the file that matches `existingLine` with
167
- * `newLine`. Returns true on success, false when no match was found.
168
- *
169
- * Used by contradiction_replacement and axis_tag_change. NOT used by
170
- * cross_agent_dedup — that path uses replaceLineAtIndex to honor the
171
- * resolved anchor (see SYN-C2 fix).
172
- */
173
- function replaceLineInFile(filePath, existingLine, newLine) {
174
- if (!fs.existsSync(filePath))
175
- return false;
176
- const content = fs.readFileSync(filePath, "utf8");
177
- const lines = content.split("\n");
178
- for (let i = 0; i < lines.length; i++) {
179
- if (lines[i] === existingLine) {
180
- lines[i] = newLine;
181
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
182
- return true;
183
- }
184
- }
185
- return false;
186
- }
187
- /**
188
- * Replace the line at exact `lineIndex` with `newLine`, only if the
189
- * existing content at that index still equals `expectedLine` (optimistic
190
- * concurrency check). Returns true on success, false when the index is
191
- * out of bounds or the on-disk content at that index drifted.
192
- *
193
- * Used by cross_agent_dedup where the caller has a resolved anchor
194
- * (SYN-C2). Keeping the mutation bound to the resolved index prevents
195
- * the "first-verbatim-match" regression that made preflight useless
196
- * for duplicate raw_line files.
197
- *
198
- * NOTE: this helper is a LINE-LEVEL CAS only. It does NOT protect against
199
- * lost updates from concurrent writes to OTHER lines in the same file.
200
- * When that matters (cross_agent_dedup apply), the caller wraps the
201
- * read-modify-write in withFileLock so the entire file-level transition
202
- * is serialized against other processes (4-D1(a)).
203
- */
204
- function replaceLineAtIndex(filePath, lineIndex, expectedLine, newLine) {
205
- if (!fs.existsSync(filePath))
206
- return false;
207
- const content = fs.readFileSync(filePath, "utf8");
208
- const lines = content.split("\n");
209
- if (lineIndex < 0 || lineIndex >= lines.length)
210
- return false;
211
- if (lines[lineIndex] !== expectedLine)
212
- return false;
213
- lines[lineIndex] = newLine;
214
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
215
- return true;
216
- }
217
- // -----------------------------------------------------------------------
218
- // Non-spinning synchronous sleep for withFileLock's backoff.
219
- // -----------------------------------------------------------------------
220
- // Atomics.wait blocks the current thread without CPU spin. Backed by a
221
- // SharedArrayBuffer-based Int32Array so we can call Atomics.wait on it.
222
- // The buffer is reused across calls (module-scoped) so repeated waits
223
- // don't allocate new SharedArrayBuffers. Atomics.wait(view, 0, 0, ms)
224
- // blocks until either (a) the cell at index 0 changes from 0, or (b) the
225
- // timeout ms elapses. We never mutate the cell, so every wait runs the
226
- // full timeout without CPU overhead.
227
- const __lockSleepBuf = new Int32Array(new SharedArrayBuffer(4));
228
- function sleepSyncMs(ms) {
229
- if (ms <= 0)
230
- return;
231
- Atomics.wait(__lockSleepBuf, 0, 0, ms);
232
- }
233
- /**
234
- * Parse the PID from the first line of a lockfile's content.
235
- * Returns null when the content is malformed or unreadable.
236
- */
237
- function readLockHolderPid(lockPath) {
238
- try {
239
- const content = fs.readFileSync(lockPath, "utf8");
240
- const firstLine = content.split("\n")[0]?.trim();
241
- if (!firstLine)
242
- return null;
243
- const pid = Number.parseInt(firstLine, 10);
244
- if (!Number.isInteger(pid) || pid <= 0)
245
- return null;
246
- return pid;
247
- }
248
- catch {
249
- return null;
250
- }
251
- }
252
- /**
253
- * Check whether a given PID is alive on this host. Uses `process.kill(pid, 0)`
254
- * which is the POSIX idiom: the signal 0 doesn't actually signal anything
255
- * but does perform the kernel's existence check.
256
- *
257
- * Return contract (6-SYN-CC1 clarification):
258
- * - `true` → the process provably exists. This covers two sub-cases:
259
- * (a) the kill(0) succeeded — we own or can signal the PID
260
- * (b) the kill(0) failed with EPERM — the PID is registered with the
261
- * kernel but we lack permission to signal it. EPERM is a
262
- * POSITIVE existence signal: the OS only raises EPERM when the
263
- * target exists. We deliberately treat EPERM as "alive" so
264
- * reclaim fails-closed on a live-but-unreachable holder (e.g.
265
- * another user's promote process on a shared host).
266
- * - `false` → the process does NOT exist (ESRCH). Safe to reclaim.
267
- * - `null` → indeterminate (any other error code). Reclaim must NOT
268
- * fire — caller treats null the same as true.
269
- *
270
- * Consumers should interpret "not false" as alive: only a definitive
271
- * ESRCH response authorizes stale-lock reclaim.
272
- */
273
- function isPidAlive(pid) {
274
- try {
275
- process.kill(pid, 0);
276
- return true;
277
- }
278
- catch (err) {
279
- const code = err instanceof Error && "code" in err
280
- ? err.code
281
- : undefined;
282
- if (code === "ESRCH")
283
- return false;
284
- // EPERM: the PID exists (kernel raised EPERM instead of ESRCH) but
285
- // we can't signal it. Per the contract above, treat as alive so we
286
- // never reclaim a lockfile whose holder we just couldn't probe.
287
- if (code === "EPERM")
288
- return true;
289
- return null;
290
- }
291
- }
292
- /**
293
- * Acquire a BEST-EFFORT ADVISORY file-level lock on `targetPath` using a
294
- * sibling `.lock` file opened with O_CREAT|O_EXCL (atomic on POSIX). Run
295
- * `fn` while holding the lock; release via unlink on exit.
296
- *
297
- * ===========================================================================
298
- * CONTRACT (6-SYN-C1 rewording)
299
- * ===========================================================================
300
- * withFileLock provides ADVISORY mutual exclusion suitable for the
301
- * single-operator, single-host Phase 3 deployment model. It is NOT a
302
- * strong POSIX file lock and does NOT provide:
303
- *
304
- * - Kernel-enforced exclusivity: peers that ignore the lockfile
305
- * convention are not blocked. This is a cooperative protocol.
306
- *
307
- * - Network-filesystem or multi-mount semantics: NFS, SMB, and
308
- * overlay filesystems do NOT guarantee O_EXCL atomicity across
309
- * clients. The helper assumes a single local POSIX filesystem.
310
- *
311
- * - A "dead holder only reclaim" guarantee: the stale-lock reclaim
312
- * path runs `stat → PID check → reStat → unlink-by-path`, which
313
- * closes the common TOCTOU cases but NOT the narrow race where a
314
- * fresh live lockfile is created at the same path after our reStat
315
- * and before our unlink. In that window we may delete a peer's
316
- * newly-created lockfile. The probability is low under single-
317
- * operator cadence (reclaim triggers only after staleAfterMs=2s
318
- * of real idle time), but the window is real. Callers that need
319
- * strong exclusivity must switch to flock() or an external lock
320
- * manager.
321
- *
322
- * ===========================================================================
323
- * SCOPE — this helper is NOT a general "all Phase B mutators are serialized"
324
- * ===========================================================================
325
- * withFileLock is intentionally narrow. It exists to serialize the
326
- * multi-file, multi-step cross_agent_dedup apply flow (CG1/CG2/UF1/SYN-*)
327
- * where a single logical transaction touches several files and needs
328
- * in-lock anchor re-resolution to close TOCTOU. Other Phase B applicators
329
- * (applyPromotion, applyAxisTagChange, applyContradictionReplacement,
330
- * applyRetirement, etc.) operate on single-line mutations with their own
331
- * guard semantics (replaceLineInFile + its return-value check). They do
332
- * NOT route through this lock, and "learning files are globally serialized"
333
- * is NOT a claim this helper makes.
334
- *
335
- * ===========================================================================
336
- * SEMANTICS
337
- * ===========================================================================
338
- * - Retries on EEXIST for up to `waitMs` (default 5s) with exponential
339
- * backoff bounded at 100ms per sleep. Uses Atomics.wait for a
340
- * non-spinning synchronous wait — blocks the thread without CPU spin.
341
- * - Stale-lock recovery is owner-aware but best-effort: reads the PID
342
- * from the lockfile, checks process liveness via kill(pid, 0), and
343
- * reclaims only when the holder returns ESRCH (definitely dead).
344
- * Any indeterminate (`null`) or live (`true`, including EPERM)
345
- * response skips reclaim and the caller falls back into the normal
346
- * wait path. Age (staleAfterMs, default 2s) gates the probe so we
347
- * don't run kill() on every poll.
348
- * - Lockfile payload: `<pid>\n<acquired_at_iso>\n<target_path>\n` so
349
- * operators can diagnose holders with `cat` and correlate with `ps`.
350
- * - Cleanup is best-effort via finally-unlink. fn's thrown error
351
- * propagates out; the lock is released before the error escapes.
352
- *
353
- * ===========================================================================
354
- * RUNTIME FLOOR (6-SYN-C3 + 7-wording cleanup)
355
- * ===========================================================================
356
- * Depends on Atomics.wait on a SharedArrayBuffer-backed Int32Array
357
- * (see sleepSyncMs above).
358
- *
359
- * `engines.node` in package.json declares a SUPPORT-POLICY floor, NOT a
360
- * minimal technical floor. Both Atomics.wait and SharedArrayBuffer have
361
- * been generally available since much older Node releases (SharedArrayBuffer
362
- * since Node 10, Atomics.wait since Node 8.3), so technically the helper
363
- * CAN run on runtimes older than the declared engines.node value. The
364
- * >=18 floor reflects:
365
- * (a) the current Node LTS we intentionally support and test against,
366
- * (b) a soft advisory signal to package managers (note: engine-range
367
- * enforcement varies per package manager — npm emits a warning by
368
- * default, yarn's behavior is configurable, pnpm is strict only
369
- * when engine-strict is enabled).
370
- *
371
- * This is NOT a claim that 18.0.0 is where Atomics.wait starts working,
372
- * and it is NOT a universal hard-install gate. Older runtimes may still
373
- * execute the helper successfully if the bundling path bypasses the
374
- * engines check; operators running on pre-18 Node do so outside the
375
- * supported envelope and should not expect the same guarantees.
376
- */
377
- function withFileLock(targetPath, fn, options = {}) {
378
- const lockPath = `${targetPath}.lock`;
379
- const waitMs = options.waitMs ?? 5000;
380
- // Age threshold used as a hint — we only probe PID liveness when the
381
- // lockfile is at least this old. Shortens the default wait-to-reclaim
382
- // window to 2s so a dead holder doesn't force the full 5-min hold we
383
- // had in the prior design.
384
- const staleAfterMs = options.staleAfterMs ?? 2000;
385
- const startedAt = Date.now();
386
- let sleepMs = 10;
387
- // Ensure parent directory exists so the lockfile can be created.
388
- const parent = path.dirname(lockPath);
389
- if (!fs.existsSync(parent)) {
390
- fs.mkdirSync(parent, { recursive: true });
391
- }
392
- while (true) {
393
- try {
394
- const fd = fs.openSync(lockPath, "wx");
395
- try {
396
- fs.writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n${targetPath}\n`);
397
- }
398
- finally {
399
- fs.closeSync(fd);
400
- }
401
- break; // Acquired
402
- }
403
- catch (err) {
404
- const code = err instanceof Error && "code" in err
405
- ? err.code
406
- : undefined;
407
- if (code !== "EEXIST")
408
- throw err;
409
- // Owner-aware stale-lock reclaim (5-RECLAIM fix).
410
- //
411
- // We probe PID liveness when:
412
- // (a) the lockfile is older than staleAfterMs (don't hammer the
413
- // syscall on every retry), AND
414
- // (b) the PID can be parsed from the lockfile content.
415
- //
416
- // The reclaim is atomic in the sense of "unlink + retry loop":
417
- // after unlinking, another contender could win the next open race.
418
- // That's fine — we're acting as peers at this point, and the loser
419
- // falls back into the retry path to wait for the next release.
420
- try {
421
- const stat = fs.statSync(lockPath);
422
- if (Date.now() - stat.mtimeMs >= staleAfterMs) {
423
- const holderPid = readLockHolderPid(lockPath);
424
- if (holderPid !== null && isPidAlive(holderPid) === false) {
425
- // Dead holder — reclaim by unlinking. Verify the file we're
426
- // about to unlink is still the same file (not replaced by
427
- // a newer acquirer between stat and unlink) using the inode
428
- // via fstat. On POSIX we can't atomically "unlink if
429
- // unchanged", so the best we can do is re-stat and compare.
430
- try {
431
- const reStat = fs.statSync(lockPath);
432
- if (reStat.ino === stat.ino && reStat.mtimeMs === stat.mtimeMs) {
433
- fs.unlinkSync(lockPath);
434
- }
435
- }
436
- catch {
437
- // Lock already gone — someone else reclaimed. Retry below.
438
- }
439
- continue; // Try to acquire right away.
440
- }
441
- }
442
- }
443
- catch {
444
- // stat failed (maybe the lock was just released) — fall through
445
- // to the normal retry path.
446
- }
447
- if (Date.now() - startedAt > waitMs) {
448
- throw new Error(`withFileLock: could not acquire lock on ${targetPath} within ${waitMs}ms. ` +
449
- `Another process is likely holding ${lockPath}. ` +
450
- `If no process is active, inspect the lockfile (contains holder pid + ` +
451
- `acquired_at) and remove it manually.`);
452
- }
453
- // Non-spinning wait — Atomics.wait blocks the thread for sleepMs
454
- // without CPU consumption (LOCK-SPIN fix).
455
- sleepSyncMs(Math.min(sleepMs, 100));
456
- sleepMs = Math.min(sleepMs * 2, 100);
457
- continue;
458
- }
459
- }
460
- try {
461
- return fn();
462
- }
463
- finally {
464
- try {
465
- fs.unlinkSync(lockPath);
466
- }
467
- catch {
468
- // Cleanup best-effort. If unlink fails, subsequent acquires will
469
- // eventually recover via the stale-lock path.
470
- }
471
- }
472
- }
473
- /**
474
- * Comment out a line by replacing it with `<!-- retired ({date}): {original} -->`.
475
- *
476
- * promote.md §6 says project entries are tagged `(-> promoted to global, ...)`
477
- * and not deleted. Retirement of GLOBAL entries follows a similar
478
- * preserve-as-comment pattern so the audit trail survives.
479
- */
480
- function commentOutLine(filePath, existingLine) {
481
- if (!fs.existsSync(filePath))
482
- return false;
483
- const content = fs.readFileSync(filePath, "utf8");
484
- const lines = content.split("\n");
485
- const date = new Date().toISOString().slice(0, 10);
486
- for (let i = 0; i < lines.length; i++) {
487
- if (lines[i] === existingLine) {
488
- lines[i] = `<!-- retired (${date}): ${existingLine} -->`;
489
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
490
- return true;
491
- }
492
- }
493
- return false;
494
- }
495
- /**
496
- * Apply a single approved promotion. Promotes the project line to the global
497
- * file under the same agent_id. The original project entry is annotated with
498
- * `(-> promoted to global, <date>)` per promote.md §6.
499
- */
500
- function applyPromotion(decision, ctx) {
501
- if (!decision.approve)
502
- return;
503
- const id = decisionId("promotion", `${decision.candidate_agent_id}|${decision.candidate_line}`);
504
- try {
505
- const globalPath = getGlobalLearningFilePath(decision.candidate_agent_id, ctx.ontoHome);
506
- const learningId = hashLine(decision.candidate_line);
507
- // Append to global. The line itself is the canonical §1.3 form already
508
- // (collector parsed it as a ParsedLearningItem and the operator approved
509
- // the literal text).
510
- appendLearningLine(globalPath, decision.candidate_line, learningId);
511
- // Annotate the project file: mark the source line as "promoted to global".
512
- const projectPath = getProjectLearningFilePath(ctx.projectRoot, decision.candidate_agent_id);
513
- annotateProjectLine(projectPath, decision.candidate_line);
514
- ctx.state = markApplied(ctx.state, {
515
- decision_kind: "promotion",
516
- decision_id: id,
517
- applied_at: new Date().toISOString(),
518
- target_path: globalPath,
519
- result_summary: `appended to ${path.basename(globalPath)}`,
520
- });
521
- ctx.summary.promotions_applied += 1;
522
- }
523
- catch (error) {
524
- ctx.state = markFailed(ctx.state, {
525
- decision_kind: "promotion",
526
- decision_id: id,
527
- attempted_at: new Date().toISOString(),
528
- error_message: error instanceof Error ? error.message : String(error),
529
- resumable: true,
530
- });
531
- ctx.summary.failed_decisions += 1;
532
- }
533
- }
534
- function annotateProjectLine(filePath, line) {
535
- if (!fs.existsSync(filePath))
536
- return;
537
- const content = fs.readFileSync(filePath, "utf8");
538
- const lines = content.split("\n");
539
- const date = new Date().toISOString().slice(0, 10);
540
- const annotation = ` (-> promoted to global, ${date})`;
541
- for (let i = 0; i < lines.length; i++) {
542
- if (lines[i] === line && !lines[i].includes("promoted to global")) {
543
- lines[i] = lines[i] + annotation;
544
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
545
- return;
546
- }
547
- }
548
- }
549
- function applyContradictionReplacement(decision, ctx) {
550
- if (!decision.approve)
551
- return;
552
- const id = decisionId("contradiction_replacement", `${decision.agent_id}|${decision.existing_line}`);
553
- try {
554
- const globalPath = getGlobalLearningFilePath(decision.agent_id, ctx.ontoHome);
555
- // Preserve the replaced entry as a comment for audit trail.
556
- const date = new Date().toISOString().slice(0, 10);
557
- const preservedLine = `<!-- replaced (${date}): ${decision.existing_line} -->`;
558
- const ok = replaceLineInFile(globalPath, decision.existing_line, preservedLine) &&
559
- (() => {
560
- const learningId = hashLine(decision.new_line);
561
- appendLearningLine(globalPath, decision.new_line, learningId);
562
- return true;
563
- })();
564
- if (!ok) {
565
- throw new Error(`existing line not found in ${globalPath}`);
566
- }
567
- ctx.state = markApplied(ctx.state, {
568
- decision_kind: "contradiction_replacement",
569
- decision_id: id,
570
- applied_at: new Date().toISOString(),
571
- target_path: globalPath,
572
- result_summary: `replaced 1 line in ${path.basename(globalPath)}`,
573
- });
574
- ctx.summary.contradiction_replacements_applied += 1;
575
- }
576
- catch (error) {
577
- ctx.state = markFailed(ctx.state, {
578
- decision_kind: "contradiction_replacement",
579
- decision_id: id,
580
- attempted_at: new Date().toISOString(),
581
- error_message: error instanceof Error ? error.message : String(error),
582
- resumable: true,
583
- });
584
- ctx.summary.failed_decisions += 1;
585
- }
586
- }
587
- function applyAxisTagChange(decision, ctx) {
588
- if (!decision.approve)
589
- return;
590
- const id = decisionId("axis_tag_change", `${decision.agent_id}|${decision.original_line}`);
591
- try {
592
- const globalPath = getGlobalLearningFilePath(decision.agent_id, ctx.ontoHome);
593
- const ok = replaceLineInFile(globalPath, decision.original_line, decision.new_line);
594
- if (!ok)
595
- throw new Error(`original line not found in ${globalPath}`);
596
- ctx.state = markApplied(ctx.state, {
597
- decision_kind: "axis_tag_change",
598
- decision_id: id,
599
- applied_at: new Date().toISOString(),
600
- target_path: globalPath,
601
- result_summary: `axis tags updated in ${path.basename(globalPath)}`,
602
- });
603
- ctx.summary.axis_tag_changes_applied += 1;
604
- }
605
- catch (error) {
606
- ctx.state = markFailed(ctx.state, {
607
- decision_kind: "axis_tag_change",
608
- decision_id: id,
609
- attempted_at: new Date().toISOString(),
610
- error_message: error instanceof Error ? error.message : String(error),
611
- resumable: true,
612
- });
613
- ctx.summary.failed_decisions += 1;
614
- }
615
- }
616
- function applyRetirement(decision, ctx, auditState) {
617
- const id = decisionId("retirement", `${decision.agent_id}|${decision.line_excerpt}`);
618
- if (!decision.approve_retire) {
619
- // retention_confirmed: append <!-- retention-confirmed: <date> --> after
620
- // the matching line so future passes know this item was reviewed.
621
- try {
622
- const globalPath = getGlobalLearningFilePath(decision.agent_id, ctx.ontoHome);
623
- const date = new Date().toISOString().slice(0, 10);
624
- const ok = insertCommentAfter(globalPath, decision.line_excerpt, `<!-- retention-confirmed: ${date} -->`);
625
- if (!ok)
626
- throw new Error(`line not found in ${globalPath}`);
627
- ctx.state = markApplied(ctx.state, {
628
- decision_kind: "retirement",
629
- decision_id: id,
630
- applied_at: new Date().toISOString(),
631
- target_path: globalPath,
632
- result_summary: `retention confirmed in ${path.basename(globalPath)}`,
633
- });
634
- }
635
- catch (error) {
636
- ctx.state = markFailed(ctx.state, {
637
- decision_kind: "retirement",
638
- decision_id: id,
639
- attempted_at: new Date().toISOString(),
640
- error_message: error instanceof Error ? error.message : String(error),
641
- resumable: true,
642
- });
643
- ctx.summary.failed_decisions += 1;
644
- }
645
- return;
646
- }
647
- try {
648
- const globalPath = getGlobalLearningFilePath(decision.agent_id, ctx.ontoHome);
649
- const ok = commentOutLine(globalPath, decision.line_excerpt);
650
- if (!ok)
651
- throw new Error(`line not found in ${globalPath}`);
652
- ctx.state = markApplied(ctx.state, {
653
- decision_kind: "retirement",
654
- decision_id: id,
655
- applied_at: new Date().toISOString(),
656
- target_path: globalPath,
657
- result_summary: `retired in ${path.basename(globalPath)}`,
658
- });
659
- ctx.summary.retirements_applied += 1;
660
- }
661
- catch (error) {
662
- ctx.state = markFailed(ctx.state, {
663
- decision_kind: "retirement",
664
- decision_id: id,
665
- attempted_at: new Date().toISOString(),
666
- error_message: error instanceof Error ? error.message : String(error),
667
- resumable: true,
668
- });
669
- ctx.summary.failed_decisions += 1;
670
- }
671
- // auditState parameter unused for retirement; reserved for future
672
- // event-marker side-effects (touching obligations linked to retired items).
673
- void auditState;
674
- }
675
- function insertCommentAfter(filePath, matchLine, comment) {
676
- if (!fs.existsSync(filePath))
677
- return false;
678
- const content = fs.readFileSync(filePath, "utf8");
679
- const lines = content.split("\n");
680
- for (let i = 0; i < lines.length; i++) {
681
- if (lines[i] === matchLine) {
682
- lines.splice(i + 1, 0, comment);
683
- fs.writeFileSync(filePath, lines.join("\n"), "utf8");
684
- return true;
685
- }
686
- }
687
- return false;
688
- }
689
- function applyAuditOutcome(decision, ctx) {
690
- const id = decisionId("audit_outcome", `${decision.agent_id}|${decision.line_excerpt}`);
691
- try {
692
- const globalPath = getGlobalLearningFilePath(decision.agent_id, ctx.ontoHome);
693
- if (decision.decision === "delete") {
694
- const ok = commentOutLine(globalPath, decision.line_excerpt);
695
- if (!ok)
696
- throw new Error(`line not found in ${globalPath}`);
697
- }
698
- else if (decision.decision === "modify") {
699
- if (!decision.modified_content) {
700
- throw new Error(`audit_outcome modify requires modified_content`);
701
- }
702
- const ok = replaceLineInFile(globalPath, decision.line_excerpt, decision.modified_content);
703
- if (!ok)
704
- throw new Error(`line not found in ${globalPath}`);
705
- }
706
- // retain: no-op
707
- ctx.state = markApplied(ctx.state, {
708
- decision_kind: "audit_outcome",
709
- decision_id: id,
710
- applied_at: new Date().toISOString(),
711
- target_path: globalPath,
712
- result_summary: `audit outcome (${decision.decision}) applied`,
713
- });
714
- ctx.summary.audit_outcomes_applied += 1;
715
- }
716
- catch (error) {
717
- ctx.state = markFailed(ctx.state, {
718
- decision_kind: "audit_outcome",
719
- decision_id: id,
720
- attempted_at: new Date().toISOString(),
721
- error_message: error instanceof Error ? error.message : String(error),
722
- resumable: true,
723
- });
724
- ctx.summary.failed_decisions += 1;
725
- }
726
- }
727
- function resolveDedupMemberAnchor(fileLines, member, clusterId) {
728
- const rawLine = member.raw_line;
729
- const date = new Date().toISOString().slice(0, 10);
730
- // The rewritten form includes a date stamp that we can't predict exactly
731
- // at resolution time (prior runs used a different date). Match on the
732
- // stable prefix + cluster_id + raw_line tail instead.
733
- const expectedMarkerPrefix = `<!-- consolidated (`;
734
- const expectedMarkerSuffix = `) into ${clusterId}: ${rawLine} -->`;
735
- void date; // suppress unused warning
736
- const anchoredIdx = member.line_number - 1;
737
- if (anchoredIdx >= 0 && anchoredIdx < fileLines.length) {
738
- const candidate = fileLines[anchoredIdx];
739
- if (candidate === rawLine) {
740
- return { kind: "match_original", lineIndex: anchoredIdx };
741
- }
742
- if (candidate.startsWith(expectedMarkerPrefix) &&
743
- candidate.endsWith(expectedMarkerSuffix)) {
744
- return { kind: "already_consolidated", lineIndex: anchoredIdx };
745
- }
746
- }
747
- // Verbatim scan fallback — must be unambiguous.
748
- let firstMatch = -1;
749
- let matchCount = 0;
750
- for (let i = 0; i < fileLines.length; i++) {
751
- if (fileLines[i] === rawLine) {
752
- if (firstMatch === -1)
753
- firstMatch = i;
754
- matchCount += 1;
755
- if (matchCount > 1)
756
- break;
757
- }
758
- }
759
- if (matchCount === 1 && firstMatch !== -1) {
760
- return { kind: "match_original", lineIndex: firstMatch };
761
- }
762
- if (matchCount > 1) {
763
- return { kind: "ambiguous", lineIndex: null };
764
- }
765
- return { kind: "missing", lineIndex: null };
766
- }
767
- /**
768
- * C1 + CG1 + CG2 + UF1 + SYN-C1 + SYN-C2 fix: scope-aware, primary-member-
769
- * precise (index-based), anchor-authoritative, and marker-closure-aware
770
- * cross-agent dedup apply.
771
- *
772
- * C1: Scope-aware — non-primary members apply at their own source_path.
773
- * Mixed-scope clusters (project + global) correctly mark each member file.
774
- *
775
- * CG1 + SYN-C1: Exact primary member identity via `primary_member_index`
776
- * (zero-based slot in `member_items`). Content-based identity (raw_line)
777
- * failed when multiple shortlist members shared identical content; slot
778
- * identity is unambiguous regardless of content duplication.
779
- *
780
- * CG2 + SYN-C2: Anchor IS the mutation authority. resolveDedupMemberAnchor
781
- * returns the exact `lineIndex` that was validated against the original
782
- * raw_line, and replaceLineAtIndex mutates ONLY that index (with an
783
- * optimistic-concurrency equality check). The previous code validated
784
- * one occurrence in preflight but mutated a different occurrence via
785
- * first-verbatim-match replaceLineInFile.
786
- *
787
- * UF1: Cluster marker closure — on rerun with the cluster marker already
788
- * present in the primary file, we ALSO verify that every member file has
789
- * its expected consolidated marker. Any missing member marker triggers
790
- * re-mark to complete a partial prior apply.
791
- *
792
- * SYN-CC1 contract: if cluster marker is ABSENT but some members are
793
- * already_consolidated (crash mid-apply AFTER the ordering flip), the
794
- * apply fails-closed with an explicit manual-recovery message. This is
795
- * intentional — automatic recovery of a split state risks data corruption
796
- * when the shortlist used to produce the partial apply might not match
797
- * the current one. Operators reset by restoring from the recoverability
798
- * checkpoint or manually rolling the member markers back.
799
- */
800
- function applyCrossAgentDedup(decision, cluster, ctx) {
801
- if (!decision.approve)
802
- return;
803
- const id = decisionId("cross_agent_dedup", decision.cluster_id);
804
- try {
805
- // Structural guard — primary_member_index must point at a valid slot.
806
- //
807
- // 4-Rec4 / 4-UF2: This is intentional defense-in-depth and duplicates
808
- // the validation that PromoteReportSpec.validate() performs at the
809
- // load-time (spec/registry) boundary. The duplication is NOT a
810
- // redundant check:
811
- //
812
- // - PromoteReportSpec guards the REGISTRY-load path. Every report
813
- // that reaches this function via REGISTRY.loadFromFile has already
814
- // been validated and its primary_member_index field is sound.
815
- //
816
- // - This applicator-side guard protects the PROGRAMMATIC path: tests
817
- // that push a cluster directly onto promoter.report.cross_agent_dedup_clusters
818
- // and re-serialize it through fs.writeFileSync (bypassing REGISTRY),
819
- // or future in-process callers that construct a cluster object
820
- // without going through REGISTRY.saveToFile.
821
- //
822
- // Both guards exist because both entry paths are real. The error
823
- // message is applicator-specific ("regenerate the report")
824
- // so the operator-legible owner is the applicator; the spec-level
825
- // message is load-time ("legacy schema v1 detected"). They target
826
- // different failure modes.
827
- if (!Number.isInteger(cluster.primary_member_index) ||
828
- cluster.primary_member_index < 0 ||
829
- cluster.primary_member_index >= cluster.member_items.length) {
830
- throw new Error(`cross_agent_dedup cluster ${decision.cluster_id}: ` +
831
- `primary_member_index ${cluster.primary_member_index} out of range ` +
832
- `(member_items.length=${cluster.member_items.length}). ` +
833
- `This likely means the report was generated by an older Phase A ` +
834
- `build or constructed programmatically without going through the ` +
835
- `panel-reviewer selector. Regenerate the report before applying.`);
836
- }
837
- // Primary owner: ALWAYS the global file of the primary_owner_agent.
838
- // Mixed-scope clusters promote the consolidated principle to global
839
- // regardless of the primary member's origin scope.
840
- const primaryPath = getGlobalLearningFilePath(cluster.primary_owner_agent, ctx.ontoHome);
841
- const clusterMarker = `<!-- cluster_id: ${decision.cluster_id} -->`;
842
- // SYN-C1: non-primary members are every item EXCEPT the one at
843
- // primary_member_index. Index-based filter handles same-content
844
- // duplicates correctly — two members with identical raw_line can
845
- // occupy different slots, and we only skip the specific slot the
846
- // panel-reviewer picked.
847
- const nonPrimaryMembers = cluster.member_items.filter((_, idx) => idx !== cluster.primary_member_index);
848
- // UF1: cluster marker in the primary file is ONLY evidence of success
849
- // when every non-primary member also has its own marker. Otherwise the
850
- // prior attempt crashed after writing the cluster marker but before
851
- // finishing member marks — we must finish the unfinished work.
852
- const clusterMarkerPresent = fs.existsSync(primaryPath) &&
853
- fs.readFileSync(primaryPath, "utf8").includes(clusterMarker);
854
- if (clusterMarkerPresent) {
855
- // 5-LINEINDEX cleanup: preflight here only classifies whether a
856
- // member is already_consolidated, still needs marking, or drifted.
857
- // The actual write re-resolves the anchor INSIDE the lock (below),
858
- // so the preflight line index would be stale by the time the lock
859
- // is acquired. Pass-through the member ref; the write path is the
860
- // single source of truth for the current lineIndex.
861
- const unmarkedMembers = [];
862
- for (const member of nonPrimaryMembers) {
863
- if (!fs.existsSync(member.source_path)) {
864
- // A previously marked member file that subsequently disappeared —
865
- // treat as resumable failure so the operator investigates.
866
- throw new Error(`cross_agent_dedup resume: expected member file ${member.source_path} ` +
867
- `missing for ${member.agent_id}`);
868
- }
869
- const memberLines = fs
870
- .readFileSync(member.source_path, "utf8")
871
- .split("\n");
872
- const resolution = resolveDedupMemberAnchor(memberLines, member, decision.cluster_id);
873
- if (resolution.kind === "already_consolidated")
874
- continue;
875
- if (resolution.kind === "match_original") {
876
- unmarkedMembers.push(member);
877
- continue;
878
- }
879
- // ambiguous or missing — neither the original nor the expected
880
- // marker is locatable. Fail-closed.
881
- throw new Error(`cross_agent_dedup resume: member ${member.agent_id} in ` +
882
- `${member.source_path} is ${resolution.kind} (cluster_id=${decision.cluster_id})`);
883
- }
884
- if (unmarkedMembers.length === 0) {
885
- // Clean idempotent success — cluster AND all members are consolidated.
886
- ctx.state = markApplied(ctx.state, {
887
- decision_kind: "cross_agent_dedup",
888
- decision_id: id,
889
- applied_at: new Date().toISOString(),
890
- target_path: primaryPath,
891
- result_summary: `cluster ${decision.cluster_id} fully consolidated, skipped`,
892
- });
893
- ctx.summary.cross_agent_dedup_applied += 1;
894
- return;
895
- }
896
- // Finish the partial apply: mark only the still-original members.
897
- // SYN-C2: use the resolved lineIndex as the mutation authority.
898
- // 4-D1(a): wrap each per-file read-modify-write in withFileLock to
899
- // serialize against concurrent writers. We re-resolve the anchor
900
- // INSIDE the lock so no window exists between validation and write.
901
- const date = new Date().toISOString().slice(0, 10);
902
- let finishedCount = 0;
903
- for (const member of unmarkedMembers) {
904
- withFileLock(member.source_path, () => {
905
- const memberLines = fs
906
- .readFileSync(member.source_path, "utf8")
907
- .split("\n");
908
- const reResolution = resolveDedupMemberAnchor(memberLines, member, decision.cluster_id);
909
- if (reResolution.kind === "already_consolidated") {
910
- // Another resumed process finished this one — skip gracefully.
911
- return;
912
- }
913
- if (reResolution.kind !== "match_original") {
914
- throw new Error(`cross_agent_dedup resume: member ${member.agent_id} in ` +
915
- `${member.source_path} became ${reResolution.kind} ` +
916
- `inside the lock window (cluster_id=${decision.cluster_id})`);
917
- }
918
- const marker = `<!-- consolidated (${date}) into ${decision.cluster_id}: ${member.raw_line} -->`;
919
- const ok = replaceLineAtIndex(member.source_path, reResolution.lineIndex, member.raw_line, marker);
920
- if (!ok) {
921
- throw new Error(`cross_agent_dedup resume: replaceLineAtIndex failed under lock ` +
922
- `for ${member.agent_id} (${member.source_path})`);
923
- }
924
- finishedCount += 1;
925
- });
926
- }
927
- ctx.state = markApplied(ctx.state, {
928
- decision_kind: "cross_agent_dedup",
929
- decision_id: id,
930
- applied_at: new Date().toISOString(),
931
- target_path: primaryPath,
932
- result_summary: `cluster ${decision.cluster_id} resumed; ` +
933
- `${finishedCount} additional member entries marked to close prior partial apply`,
934
- });
935
- ctx.summary.cross_agent_dedup_applied += 1;
936
- return;
937
- }
938
- // No cluster marker — fresh apply. CG2 anchor resolution per member.
939
- // 5-LINEINDEX: we only classify here (valid target / drifted / ambig /
940
- // already_marked). The concrete lineIndex is re-resolved inside the
941
- // locked write below so the preflight index wouldn't be authoritative
942
- // even if we saved it.
943
- const preflightFailures = [];
944
- const resolvedMembers = [];
945
- for (const member of nonPrimaryMembers) {
946
- if (!fs.existsSync(member.source_path)) {
947
- preflightFailures.push(`${member.agent_id} (${member.scope}): file ${member.source_path} does not exist`);
948
- continue;
949
- }
950
- const fileLines = fs
951
- .readFileSync(member.source_path, "utf8")
952
- .split("\n");
953
- const resolution = resolveDedupMemberAnchor(fileLines, member, decision.cluster_id);
954
- if (resolution.kind === "missing") {
955
- preflightFailures.push(`${member.agent_id} (${member.scope}): raw_line not locatable at line ${member.line_number} or via verbatim scan in ${member.source_path}`);
956
- continue;
957
- }
958
- if (resolution.kind === "ambiguous") {
959
- preflightFailures.push(`${member.agent_id} (${member.scope}): multiple verbatim matches for raw_line in ${member.source_path} and line_number anchor did not resolve`);
960
- continue;
961
- }
962
- if (resolution.kind === "already_consolidated") {
963
- // SYN-CC1 + 4-D2(a): cluster marker absent but this member IS
964
- // already marked — the "crash mid-apply after ordering flip"
965
- // state. Fail-closed by intent; automatic finish would risk data
966
- // corruption if the partial apply came from a different shortlist
967
- // composition than the current cluster.
968
- //
969
- // Operator-guidance: the error message includes both recovery
970
- // options with concrete paths/commands so the operator can act
971
- // without chasing docs:
972
- // 1. Restore the specific member file from the recoverability
973
- // checkpoint at the session-specific manifest path, OR
974
- // 2. Manually remove the stray consolidated marker from the
975
- // member file and re-run apply for this session.
976
- const sessionId = ctx.state.session_id;
977
- const checkpointManifestPath = path.join(os.homedir(), ".onto", "backups", sessionId, "restore-manifest.yaml");
978
- preflightFailures.push(`${member.agent_id} (${member.scope}): already carries consolidated ` +
979
- `marker for cluster ${decision.cluster_id} in ${member.source_path} ` +
980
- `despite missing cluster marker in primary file (${primaryPath}). ` +
981
- `Manual recovery required — SYN-CC1 fail-closed contract. ` +
982
- `Options: ` +
983
- `(A) Restore this file from the checkpoint manifest at ` +
984
- `${checkpointManifestPath} (follow the backup entry whose ` +
985
- `source_path matches ${member.source_path}); or ` +
986
- `(B) Manually remove the stray '<!-- consolidated (...) into ` +
987
- `${decision.cluster_id}: ... -->' line from ${member.source_path} ` +
988
- `and re-run apply for session ${sessionId}.`);
989
- continue;
990
- }
991
- // match_original — classified valid. Index is re-derived inside
992
- // the lock window during the write loop.
993
- resolvedMembers.push(member);
994
- }
995
- if (preflightFailures.length > 0) {
996
- throw new Error(`cross_agent_dedup preflight failed for cluster ${decision.cluster_id}: ` +
997
- preflightFailures.join("; "));
998
- }
999
- // Preflight passed — mark each member first, THEN write consolidated
1000
- // line + cluster marker on the primary file. UF1 ordering: cluster
1001
- // marker is the "commit marker" written last. SYN-C2: mutation uses
1002
- // replaceLineAtIndex. 4-D1(a): each read-modify-write cycle runs
1003
- // under a file-level lock so concurrent peers cannot lose updates on
1004
- // unrelated lines of the same file. We re-resolve the anchor INSIDE
1005
- // the lock so there is no TOCTOU window between validation and write.
1006
- const date = new Date().toISOString().slice(0, 10);
1007
- let consolidatedCount = 0;
1008
- for (const member of resolvedMembers) {
1009
- withFileLock(member.source_path, () => {
1010
- const memberLines = fs
1011
- .readFileSync(member.source_path, "utf8")
1012
- .split("\n");
1013
- const reResolution = resolveDedupMemberAnchor(memberLines, member, decision.cluster_id);
1014
- if (reResolution.kind !== "match_original") {
1015
- // 5-OVERCLAIM fix: the prior message said "no member file was
1016
- // left in an inconsistent state", which was false — any earlier
1017
- // members in this cluster that ALREADY succeeded inside this
1018
- // loop are already on disk. We no longer overclaim. Operators
1019
- // use apply-state (promote-execution-result.json) to see which
1020
- // members were marked before this failure and restore from the
1021
- // recoverability checkpoint.
1022
- throw new Error(`cross_agent_dedup post-preflight race: ${member.agent_id} ` +
1023
- `(${member.source_path}) became ${reResolution.kind} inside the ` +
1024
- `lock window — another process mutated this member file between ` +
1025
- `preflight and the locked write. ${consolidatedCount} earlier ` +
1026
- `member(s) in this cluster were already marked before this ` +
1027
- `failure; consult apply-state (promote-execution-result.json in ` +
1028
- `the session root) to see the committed subset and use the ` +
1029
- `recoverability checkpoint to restore if needed.`);
1030
- }
1031
- const marker = `<!-- consolidated (${date}) into ${decision.cluster_id}: ${member.raw_line} -->`;
1032
- const ok = replaceLineAtIndex(member.source_path, reResolution.lineIndex, member.raw_line, marker);
1033
- if (!ok) {
1034
- throw new Error(`cross_agent_dedup: replaceLineAtIndex failed under lock for ` +
1035
- `${member.agent_id} (${member.source_path})`);
1036
- }
1037
- consolidatedCount += 1;
1038
- });
1039
- }
1040
- // All member marks complete — now write the consolidated line + cluster
1041
- // marker as the commit step. Locked at the primary file level to
1042
- // serialize against peers that may be appending to the same file.
1043
- withFileLock(primaryPath, () => {
1044
- const learningId = hashLine(cluster.consolidated_line);
1045
- appendLearningLine(primaryPath, cluster.consolidated_line, learningId);
1046
- fs.appendFileSync(primaryPath, `${clusterMarker}\n`, "utf8");
1047
- });
1048
- ctx.state = markApplied(ctx.state, {
1049
- decision_kind: "cross_agent_dedup",
1050
- decision_id: id,
1051
- applied_at: new Date().toISOString(),
1052
- target_path: primaryPath,
1053
- result_summary: `consolidated to ${path.basename(primaryPath)}; ` +
1054
- `${consolidatedCount} member entries marked (scope-aware, anchor-resolved)`,
1055
- });
1056
- ctx.summary.cross_agent_dedup_applied += 1;
1057
- }
1058
- catch (error) {
1059
- ctx.state = markFailed(ctx.state, {
1060
- decision_kind: "cross_agent_dedup",
1061
- decision_id: id,
1062
- attempted_at: new Date().toISOString(),
1063
- error_message: error instanceof Error ? error.message : String(error),
1064
- resumable: true,
1065
- });
1066
- ctx.summary.failed_decisions += 1;
1067
- }
1068
- }
1069
- // ---------------------------------------------------------------------------
1070
- // Domain doc update — DD-19 Phase B
1071
- // ---------------------------------------------------------------------------
1072
- const DOMAIN_DOC_SYSTEM_PROMPT = `You are updating a domain document with a newly promoted learning.
1073
-
1074
- Output ONE JSON object:
1075
- {
1076
- "reflection_form": "add_term" | "modify_definition" | "add_question" | "modify_question" | "add_sub_area" | "modify_scope" | "add_standard",
1077
- "content": "<the markdown block to insert into the document — 1-5 lines, no fences>"
1078
- }
1079
-
1080
- Reflection form selection by target document:
1081
- - concepts.md → "add_term" | "modify_definition"
1082
- - competency_qs.md → "add_question" | "modify_question"
1083
- - domain_scope.md → "add_sub_area" | "modify_scope" | "add_standard"
1084
-
1085
- Respond ONLY with valid JSON (no markdown fences).`;
1086
- function buildDomainDocUserPrompt(candidate) {
1087
- return [
1088
- `Target document: ${candidate.target_doc}`,
1089
- `Domain: ${candidate.domain}`,
1090
- `Originating agent: ${candidate.agent_id}`,
1091
- "",
1092
- "Promoted learning:",
1093
- candidate.candidate_summary,
1094
- "",
1095
- `Generate a JSON object with reflection_form (matching the target doc) and content (the markdown block).`,
1096
- ].join("\n");
1097
- }
1098
- function getDomainDocPath(domain, targetDoc, ontoHome) {
1099
- const home = ontoHome ?? path.join(os.homedir(), ".onto");
1100
- return path.join(home, "domains", domain, targetDoc);
1101
- }
1102
- /**
1103
- * Allowed reflection_form values per target document. m-4 fix: previously
1104
- * the LLM could return any string and the applicator would accept it; now
1105
- * the value is validated against the per-target allow-list. The mapping
1106
- * mirrors the prompt at DOMAIN_DOC_SYSTEM_PROMPT line ~635.
1107
- */
1108
- const VALID_REFLECTION_FORMS = {
1109
- "concepts.md": ["add_term", "modify_definition"],
1110
- "competency_qs.md": ["add_question", "modify_question"],
1111
- "domain_scope.md": ["add_sub_area", "modify_scope", "add_standard"],
1112
- };
1113
- async function callDomainDocLlm(candidate, modelId) {
1114
- const userPrompt = buildDomainDocUserPrompt(candidate);
1115
- const result = await callLlm(DOMAIN_DOC_SYSTEM_PROMPT, userPrompt, {
1116
- max_tokens: 1024,
1117
- ...(modelId ? { model_id: modelId } : {}),
1118
- });
1119
- let cleaned = result.text.trim();
1120
- if (cleaned.startsWith("```")) {
1121
- cleaned = cleaned.replace(/^```(?:json)?\s*/, "").replace(/```\s*$/, "");
1122
- }
1123
- const parsed = JSON.parse(cleaned);
1124
- if (typeof parsed.reflection_form !== "string" ||
1125
- typeof parsed.content !== "string" ||
1126
- parsed.content.length === 0) {
1127
- throw new Error(`domain doc LLM returned invalid shape: reflection_form=${typeof parsed.reflection_form}, content=${typeof parsed.content}`);
1128
- }
1129
- // m-4 enum validation: reflection_form must be in the per-target allow-list.
1130
- const allowed = VALID_REFLECTION_FORMS[candidate.target_doc];
1131
- if (!allowed.includes(parsed.reflection_form)) {
1132
- throw new Error(`domain doc LLM returned invalid reflection_form "${parsed.reflection_form}" ` +
1133
- `for target ${candidate.target_doc}. Allowed: ${allowed.join(", ")}`);
1134
- }
1135
- return {
1136
- reflection_form: parsed.reflection_form,
1137
- content: parsed.content,
1138
- llm_model_id: result.model_id,
1139
- };
1140
- }
1141
- /**
1142
- * Apply an approved domain doc update.
1143
- *
1144
- * Phase B contract:
1145
- * 1. Look up the approved DomainDocCandidate (by slot_id + instance_id).
1146
- * Lookup happens in the caller; this function receives the candidate.
1147
- * 2. Call the LLM to generate reflection_form + content.
1148
- * 3. Append the content under a generated heading at
1149
- * `~/.onto/domains/{domain}/{target_doc}` (creating the file if absent).
1150
- *
1151
- * The slot_id is included in a comment so subsequent runs can detect that
1152
- * this slot has already been written and skip duplicate insertions. We
1153
- * intentionally append rather than replace because domain docs grow
1154
- * incrementally and overwrite would lose prior content.
1155
- */
1156
- async function applyDomainDocUpdate(candidate, ctx, modelId) {
1157
- const id = decisionId("domain_doc_update", `${candidate.slot_id}|${candidate.instance_id}`);
1158
- try {
1159
- const docPath = getDomainDocPath(candidate.domain, candidate.target_doc, ctx.ontoHome);
1160
- // Skip if this slot was already written by an earlier attempt.
1161
- if (fs.existsSync(docPath)) {
1162
- const existing = fs.readFileSync(docPath, "utf8");
1163
- if (existing.includes(`<!-- slot_id: ${candidate.slot_id} -->`)) {
1164
- ctx.state = markApplied(ctx.state, {
1165
- decision_kind: "domain_doc_update",
1166
- decision_id: id,
1167
- applied_at: new Date().toISOString(),
1168
- target_path: docPath,
1169
- result_summary: `slot ${candidate.slot_id} already present, skipped`,
1170
- });
1171
- ctx.summary.domain_doc_updates_applied += 1;
1172
- return;
1173
- }
1174
- }
1175
- const llmResult = await callDomainDocLlm(candidate, modelId);
1176
- // Build the appended block. Each entry is wrapped in slot/instance
1177
- // markers so future regeneration can detect it.
1178
- const date = new Date().toISOString().slice(0, 10);
1179
- const block = [
1180
- "",
1181
- `<!-- slot_id: ${candidate.slot_id} -->`,
1182
- `<!-- instance_id: ${candidate.instance_id} -->`,
1183
- `<!-- reflection_form: ${llmResult.reflection_form} | source_promotion: ${candidate.approved_promotion_id} | added: ${date} -->`,
1184
- llmResult.content.trim(),
1185
- "",
1186
- ].join("\n");
1187
- fs.mkdirSync(path.dirname(docPath), { recursive: true });
1188
- if (!fs.existsSync(docPath)) {
1189
- fs.writeFileSync(docPath, `# ${candidate.target_doc.replace(".md", "")} — ${candidate.domain}\n`, "utf8");
1190
- }
1191
- fs.appendFileSync(docPath, block, "utf8");
1192
- ctx.state = markApplied(ctx.state, {
1193
- decision_kind: "domain_doc_update",
1194
- decision_id: id,
1195
- applied_at: new Date().toISOString(),
1196
- target_path: docPath,
1197
- result_summary: `appended ${llmResult.reflection_form} block to ${candidate.target_doc} (model=${llmResult.llm_model_id})`,
1198
- });
1199
- ctx.summary.domain_doc_updates_applied += 1;
1200
- }
1201
- catch (error) {
1202
- ctx.state = markFailed(ctx.state, {
1203
- decision_kind: "domain_doc_update",
1204
- decision_id: id,
1205
- attempted_at: new Date().toISOString(),
1206
- error_message: error instanceof Error ? error.message : String(error),
1207
- // LLM call failures are resumable (network blip), but JSON parse
1208
- // failures are not (the model can't generate valid JSON for this
1209
- // candidate without prompt changes). We mark as resumable so the
1210
- // operator can re-run; the next attempt may use a different model.
1211
- resumable: true,
1212
- });
1213
- ctx.summary.failed_decisions += 1;
1214
- }
1215
- }
1216
- function applyObligationWaive(obligationId, reason, ctx, auditState, sessionId) {
1217
- const id = decisionId("obligation_waive", obligationId);
1218
- try {
1219
- const ob = findObligation(auditState, obligationId);
1220
- if (!ob)
1221
- throw new Error(`obligation ${obligationId} not found`);
1222
- ob.transition("waived", reason, { session_id: sessionId });
1223
- ctx.state = markApplied(ctx.state, {
1224
- decision_kind: "obligation_waive",
1225
- decision_id: id,
1226
- applied_at: new Date().toISOString(),
1227
- target_path: "audit-state.yaml",
1228
- result_summary: `waived obligation ${obligationId}`,
1229
- });
1230
- ctx.summary.obligations_waived += 1;
1231
- }
1232
- catch (error) {
1233
- ctx.state = markFailed(ctx.state, {
1234
- decision_kind: "obligation_waive",
1235
- decision_id: id,
1236
- attempted_at: new Date().toISOString(),
1237
- error_message: error instanceof Error ? error.message : String(error),
1238
- resumable: false,
1239
- });
1240
- ctx.summary.failed_decisions += 1;
1241
- }
1242
- }
1243
- function hashLine(line) {
1244
- // Mirror Phase 2 generateLearningId pattern: 12-char sha256 prefix.
1245
- return crypto.createHash("sha256").update(line).digest("hex").slice(0, 12);
1246
- }
1247
- // ---------------------------------------------------------------------------
1248
- // Pending decision enumeration
1249
- // ---------------------------------------------------------------------------
1250
- function enumeratePendingDecisions(decisions) {
1251
- const refs = [];
1252
- for (const d of decisions.promotions) {
1253
- if (!d.approve)
1254
- continue;
1255
- refs.push({
1256
- decision_kind: "promotion",
1257
- decision_id: decisionId("promotion", `${d.candidate_agent_id}|${d.candidate_line}`),
1258
- });
1259
- }
1260
- for (const d of decisions.contradiction_replacements) {
1261
- if (!d.approve)
1262
- continue;
1263
- refs.push({
1264
- decision_kind: "contradiction_replacement",
1265
- decision_id: decisionId("contradiction_replacement", `${d.agent_id}|${d.existing_line}`),
1266
- });
1267
- }
1268
- for (const d of decisions.axis_tag_changes) {
1269
- if (!d.approve)
1270
- continue;
1271
- refs.push({
1272
- decision_kind: "axis_tag_change",
1273
- decision_id: decisionId("axis_tag_change", `${d.agent_id}|${d.original_line}`),
1274
- });
1275
- }
1276
- for (const d of decisions.retirements) {
1277
- refs.push({
1278
- decision_kind: "retirement",
1279
- decision_id: decisionId("retirement", `${d.agent_id}|${d.line_excerpt}`),
1280
- });
1281
- }
1282
- for (const d of decisions.audit_outcomes) {
1283
- refs.push({
1284
- decision_kind: "audit_outcome",
1285
- decision_id: decisionId("audit_outcome", `${d.agent_id}|${d.line_excerpt}`),
1286
- });
1287
- }
1288
- for (const d of decisions.audit_obligations_waived) {
1289
- refs.push({
1290
- decision_kind: "obligation_waive",
1291
- decision_id: decisionId("obligation_waive", d.obligation_id),
1292
- });
1293
- }
1294
- for (const d of decisions.cross_agent_dedup_approvals) {
1295
- if (!d.approve)
1296
- continue;
1297
- refs.push({
1298
- decision_kind: "cross_agent_dedup",
1299
- decision_id: decisionId("cross_agent_dedup", d.cluster_id),
1300
- });
1301
- }
1302
- for (const d of decisions.domain_doc_updates) {
1303
- if (!d.approve)
1304
- continue;
1305
- refs.push({
1306
- decision_kind: "domain_doc_update",
1307
- decision_id: decisionId("domain_doc_update", `${d.slot_id}|${d.instance_id}`),
1308
- });
1309
- }
1310
- return refs;
1311
- }
1312
- // ---------------------------------------------------------------------------
1313
- // Main entry point
1314
- // ---------------------------------------------------------------------------
1315
- export async function runPromoteExecutor(config) {
1316
- const sessionRoot = resolveSessionRoot(config);
1317
- const auditStatePath = resolveAuditStatePath(config);
1318
- // -------------------------------------------------------------------------
1319
- // Step 1: Load report + decisions
1320
- // -------------------------------------------------------------------------
1321
- const reportPath = path.join(sessionRoot, "promote-report.json");
1322
- const decisionsPath = path.join(sessionRoot, "promote-decisions.json");
1323
- const report = REGISTRY.loadFromFile("promote_report", reportPath);
1324
- const decisions = REGISTRY.loadFromFile("promote_decisions", decisionsPath);
1325
- // -------------------------------------------------------------------------
1326
- // Step 2: Baseline freshness check (DD-10)
1327
- // -------------------------------------------------------------------------
1328
- const mismatches = verifyBaselineHash(report.collection.baseline_hash);
1329
- if (mismatches.length > 0 && !config.forceStale) {
1330
- return {
1331
- kind: "stale_baseline",
1332
- mismatches,
1333
- message: `Baseline hash check failed for ${mismatches.length} file(s). ` +
1334
- `Regenerate the report or pass --force-stale ` +
1335
- `to proceed (UNSAFE: source files have shifted since Phase A).`,
1336
- };
1337
- }
1338
- // -------------------------------------------------------------------------
1339
- // Step 3: Recovery context (only on --resume)
1340
- // -------------------------------------------------------------------------
1341
- let priorState = null;
1342
- if (config.resume) {
1343
- const context = await gatherRecoveryContext(config.sessionId, config.projectRoot);
1344
- const truth = resolveRecoveryTruth(context, config.projectRoot, config.recoveryPolicy);
1345
- if (truth.kind === "manual_escalation_required") {
1346
- return {
1347
- kind: "manual_escalation_required",
1348
- message: buildEscalationMessage(truth),
1349
- };
1350
- }
1351
- if (truth.kind === "resolved" && truth.latest_source.kind === "apply_state") {
1352
- priorState = truth.latest_source.state;
1353
- }
1354
- if (truth.kind === "no_recovery_data") {
1355
- // Nothing to resume from. Treat as fresh attempt.
1356
- priorState = null;
1357
- }
1358
- }
1359
- // -------------------------------------------------------------------------
1360
- // Step 4: Pending decision enumeration
1361
- // -------------------------------------------------------------------------
1362
- const pendingDecisions = enumeratePendingDecisions(decisions);
1363
- if (pendingDecisions.length === 0) {
1364
- return {
1365
- kind: "no_decisions",
1366
- message: "promote-decisions.json contains no approved decisions. Nothing to apply.",
1367
- };
1368
- }
1369
- // -------------------------------------------------------------------------
1370
- // Step 5: Recoverability checkpoint (DD-16)
1371
- // -------------------------------------------------------------------------
1372
- const attemptId = priorState?.attempt_id ?? generateUlid();
1373
- const generation = priorState?.generation ?? 0;
1374
- let checkpointPath = null;
1375
- if (!config.dryRun) {
1376
- // U3 fix: forward ontoHome / auditStatePath overrides into checkpoint
1377
- // creation so backup scope tracks actual mutation scope.
1378
- const checkpointOverride = {};
1379
- if (config.ontoHome !== undefined) {
1380
- checkpointOverride.ontoHome = config.ontoHome;
1381
- }
1382
- if (config.auditStatePath !== undefined) {
1383
- checkpointOverride.auditStatePath = config.auditStatePath;
1384
- }
1385
- const prep = await createRecoverabilityCheckpoint(config.sessionId, config.projectRoot, attemptId, generation, checkpointOverride);
1386
- if (prep.kind === "created" && prep.checkpoint) {
1387
- checkpointPath = prep.checkpoint.manifest_path;
1388
- }
1389
- }
1390
- // -------------------------------------------------------------------------
1391
- // Step 6: Initialize / restore ApplyExecutionState
1392
- // -------------------------------------------------------------------------
1393
- let state = priorState ??
1394
- initApplyState({
1395
- sessionId: config.sessionId,
1396
- attemptId,
1397
- pendingDecisions,
1398
- recoverabilityCheckpointPath: checkpointPath,
1399
- });
1400
- // Resume edge case: prior state may have already-applied decisions. Filter
1401
- // pending list against applied/failed so we don't double-apply.
1402
- if (priorState) {
1403
- const alreadyHandled = new Set([
1404
- ...priorState.applied_decisions.map((d) => `${d.decision_kind}:${d.decision_id}`),
1405
- ...priorState.failed_decisions.map((d) => `${d.decision_kind}:${d.decision_id}`),
1406
- ]);
1407
- state = {
1408
- ...state,
1409
- pending_decisions: pendingDecisions.filter((p) => !alreadyHandled.has(`${p.decision_kind}:${p.decision_id}`)),
1410
- };
1411
- }
1412
- if (config.dryRun) {
1413
- return {
1414
- kind: "completed",
1415
- state,
1416
- statePath: path.join(sessionRoot, "promote-execution-result.json"),
1417
- summary: emptySummary(),
1418
- };
1419
- }
1420
- // -------------------------------------------------------------------------
1421
- // Step 7: Apply approved decisions (each one persists state)
1422
- //
1423
- // B-A fix: build a Set of pending decision keys (decision_kind:decision_id)
1424
- // and filter every decision from the input arrays through it before
1425
- // applying. This guards the resume path: previously, the apply loop
1426
- // iterated `decisions.X` directly, so already-applied decisions would
1427
- // re-mutate files and then crash markApplied with "not found in pending".
1428
- // -------------------------------------------------------------------------
1429
- const auditState = loadAuditState(auditStatePath);
1430
- const summary = emptySummary();
1431
- const ctx = {
1432
- projectRoot: config.projectRoot,
1433
- state,
1434
- sessionRoot,
1435
- summary,
1436
- ...(config.ontoHome !== undefined ? { ontoHome: config.ontoHome } : {}),
1437
- };
1438
- // Snapshot the pending key set BEFORE the loop. The set is captured once;
1439
- // we don't recompute from ctx.state.pending_decisions on each iteration
1440
- // because each markApplied removes the key, which would cause subsequent
1441
- // pending checks to skip everything.
1442
- const pendingKeys = new Set(state.pending_decisions.map((p) => `${p.decision_kind}:${p.decision_id}`));
1443
- const isPending = (kind, id) => pendingKeys.has(`${kind}:${id}`);
1444
- // Helper that wraps each per-decision step. It checks pending membership,
1445
- // calls the applicator, then persists state. Persistence failures are
1446
- // routed through writeEmergencyLogEntry (B-B fix) so applied side effects
1447
- // never go un-recorded.
1448
- const applyAndPersist = async (kind, decisionId, apply) => {
1449
- if (!isPending(kind, decisionId)) {
1450
- // Already applied (resume case) — skip without re-mutating.
1451
- return;
1452
- }
1453
- await apply();
1454
- try {
1455
- persistApplyState(sessionRoot, ctx.state);
1456
- }
1457
- catch (persistError) {
1458
- // B-B fix: persistence failure path. Write an emergency-log entry so
1459
- // the side effects don't go un-recorded, then re-throw to abort the
1460
- // loop. The catastrophic catch below will surface this as
1461
- // failed_resumable to the caller.
1462
- writeEmergencyLogEntry({
1463
- sessionId: config.sessionId,
1464
- sessionRoot,
1465
- attemptId: ctx.state.attempt_id,
1466
- generation: ctx.state.generation,
1467
- fatalErrorKind: "state_persistence_failed",
1468
- fatalErrorMessage: persistError instanceof Error
1469
- ? persistError.message
1470
- : String(persistError),
1471
- snapshot: ctx.state,
1472
- });
1473
- throw persistError;
1474
- }
1475
- };
1476
- try {
1477
- for (const d of decisions.promotions) {
1478
- const id = decisionIdFor("promotion", `${d.candidate_agent_id}|${d.candidate_line}`);
1479
- await applyAndPersist("promotion", id, () => applyPromotion(d, ctx));
1480
- }
1481
- for (const d of decisions.contradiction_replacements) {
1482
- const id = decisionIdFor("contradiction_replacement", `${d.agent_id}|${d.existing_line}`);
1483
- await applyAndPersist("contradiction_replacement", id, () => applyContradictionReplacement(d, ctx));
1484
- }
1485
- for (const d of decisions.axis_tag_changes) {
1486
- const id = decisionIdFor("axis_tag_change", `${d.agent_id}|${d.original_line}`);
1487
- await applyAndPersist("axis_tag_change", id, () => applyAxisTagChange(d, ctx));
1488
- }
1489
- for (const d of decisions.retirements) {
1490
- const id = decisionIdFor("retirement", `${d.agent_id}|${d.line_excerpt}`);
1491
- await applyAndPersist("retirement", id, () => applyRetirement(d, ctx, auditState));
1492
- }
1493
- for (const d of decisions.audit_outcomes) {
1494
- const id = decisionIdFor("audit_outcome", `${d.agent_id}|${d.line_excerpt}`);
1495
- await applyAndPersist("audit_outcome", id, () => applyAuditOutcome(d, ctx));
1496
- }
1497
- for (const d of decisions.audit_obligations_waived) {
1498
- const id = decisionIdFor("obligation_waive", d.obligation_id);
1499
- // M-B fix: save audit-state IMMEDIATELY after each successful waive so
1500
- // a mid-loop crash doesn't leave apply-state ahead of the canonical
1501
- // ledger. Previously the audit-state save was deferred to the end of
1502
- // the loop.
1503
- await applyAndPersist("obligation_waive", id, () => {
1504
- applyObligationWaive(d.obligation_id, d.reason, ctx, auditState, config.sessionId);
1505
- });
1506
- // Save audit-state right after the per-step persistence. We do it
1507
- // here (not inside applyAndPersist) because only obligation_waive
1508
- // mutates audit-state.
1509
- try {
1510
- saveAuditState(auditState, auditStatePath);
1511
- }
1512
- catch (auditPersistError) {
1513
- writeEmergencyLogEntry({
1514
- sessionId: config.sessionId,
1515
- sessionRoot,
1516
- attemptId: ctx.state.attempt_id,
1517
- generation: ctx.state.generation,
1518
- fatalErrorKind: "state_persistence_failed",
1519
- fatalErrorMessage: "audit-state save after obligation_waive failed: " +
1520
- (auditPersistError instanceof Error
1521
- ? auditPersistError.message
1522
- : String(auditPersistError)),
1523
- snapshot: ctx.state,
1524
- });
1525
- throw auditPersistError;
1526
- }
1527
- }
1528
- // Cross-agent dedup: look up the cluster from the report by cluster_id.
1529
- const clusterById = new Map(report.cross_agent_dedup_clusters.map((c) => [c.cluster_id, c]));
1530
- for (const d of decisions.cross_agent_dedup_approvals) {
1531
- if (!d.approve)
1532
- continue;
1533
- const id = decisionIdFor("cross_agent_dedup", d.cluster_id);
1534
- if (!isPending("cross_agent_dedup", id))
1535
- continue;
1536
- const cluster = clusterById.get(d.cluster_id);
1537
- if (!cluster) {
1538
- ctx.state = markFailed(ctx.state, {
1539
- decision_kind: "cross_agent_dedup",
1540
- decision_id: id,
1541
- attempted_at: new Date().toISOString(),
1542
- error_message: `cluster_id ${d.cluster_id} not in report.cross_agent_dedup_clusters`,
1543
- resumable: false,
1544
- });
1545
- ctx.summary.failed_decisions += 1;
1546
- persistApplyState(sessionRoot, ctx.state);
1547
- continue;
1548
- }
1549
- applyCrossAgentDedup(d, cluster, ctx);
1550
- try {
1551
- persistApplyState(sessionRoot, ctx.state);
1552
- }
1553
- catch (persistError) {
1554
- writeEmergencyLogEntry({
1555
- sessionId: config.sessionId,
1556
- sessionRoot,
1557
- attemptId: ctx.state.attempt_id,
1558
- generation: ctx.state.generation,
1559
- fatalErrorKind: "state_persistence_failed",
1560
- fatalErrorMessage: persistError instanceof Error
1561
- ? persistError.message
1562
- : String(persistError),
1563
- snapshot: ctx.state,
1564
- });
1565
- throw persistError;
1566
- }
1567
- }
1568
- // Domain doc updates: look up the candidate from the report by slot_id +
1569
- // instance_id, then call the LLM to generate the content.
1570
- const candidateBySlotInstance = new Map(report.domain_doc_candidates.map((c) => [
1571
- `${c.slot_id}|${c.instance_id}`,
1572
- c,
1573
- ]));
1574
- for (const d of decisions.domain_doc_updates) {
1575
- if (!d.approve)
1576
- continue;
1577
- const id = decisionIdFor("domain_doc_update", `${d.slot_id}|${d.instance_id}`);
1578
- if (!isPending("domain_doc_update", id))
1579
- continue;
1580
- const candidate = candidateBySlotInstance.get(`${d.slot_id}|${d.instance_id}`);
1581
- if (!candidate) {
1582
- ctx.state = markFailed(ctx.state, {
1583
- decision_kind: "domain_doc_update",
1584
- decision_id: id,
1585
- attempted_at: new Date().toISOString(),
1586
- error_message: `domain doc candidate ${d.slot_id}|${d.instance_id} not in report.domain_doc_candidates`,
1587
- resumable: false,
1588
- });
1589
- ctx.summary.failed_decisions += 1;
1590
- persistApplyState(sessionRoot, ctx.state);
1591
- continue;
1592
- }
1593
- await applyDomainDocUpdate(candidate, ctx, config.modelId);
1594
- try {
1595
- persistApplyState(sessionRoot, ctx.state);
1596
- }
1597
- catch (persistError) {
1598
- writeEmergencyLogEntry({
1599
- sessionId: config.sessionId,
1600
- sessionRoot,
1601
- attemptId: ctx.state.attempt_id,
1602
- generation: ctx.state.generation,
1603
- fatalErrorKind: "state_persistence_failed",
1604
- fatalErrorMessage: persistError instanceof Error
1605
- ? persistError.message
1606
- : String(persistError),
1607
- snapshot: ctx.state,
1608
- });
1609
- throw persistError;
1610
- }
1611
- }
1612
- }
1613
- catch (error) {
1614
- // Catastrophic mid-loop failure (e.g., file system error). Mark state as
1615
- // failed_resumable and persist before propagating.
1616
- ctx.state = transitionStatus(ctx.state, "failed_resumable");
1617
- const statePath = persistApplyState(sessionRoot, ctx.state);
1618
- return {
1619
- kind: "failed_resumable",
1620
- state: ctx.state,
1621
- statePath,
1622
- summary,
1623
- reason: error instanceof Error ? error.message : String(error),
1624
- };
1625
- }
1626
- // M-B fix: audit-state is now saved per-step inside the obligation_waive
1627
- // applicator above (immediately after each successful waive), so a
1628
- // mid-loop crash leaves apply-state and audit-state consistent. The
1629
- // trailing save here would be redundant.
1630
- // -------------------------------------------------------------------------
1631
- // Step 8: Determine final status
1632
- // -------------------------------------------------------------------------
1633
- const finalStatus = summary.failed_decisions > 0 ? "failed_resumable" : "completed";
1634
- ctx.state = transitionStatus(ctx.state, finalStatus);
1635
- const statePath = persistApplyState(sessionRoot, ctx.state);
1636
- if (finalStatus === "failed_resumable") {
1637
- return {
1638
- kind: "failed_resumable",
1639
- state: ctx.state,
1640
- statePath,
1641
- summary,
1642
- reason: `${summary.failed_decisions} decision(s) failed during apply`,
1643
- };
1644
- }
1645
- return {
1646
- kind: "completed",
1647
- state: ctx.state,
1648
- statePath,
1649
- summary,
1650
- };
1651
- }
1652
- function emptySummary() {
1653
- return {
1654
- promotions_applied: 0,
1655
- contradiction_replacements_applied: 0,
1656
- axis_tag_changes_applied: 0,
1657
- retirements_applied: 0,
1658
- audit_outcomes_applied: 0,
1659
- obligations_waived: 0,
1660
- cross_agent_dedup_applied: 0,
1661
- domain_doc_updates_applied: 0,
1662
- failed_decisions: 0,
1663
- };
1664
- }
1665
- // loadApplyState exported for adapters needing to inspect state without
1666
- // running the executor.
1667
- export { loadApplyState };
1668
- // Test-only exports. Kept minimal (6-SYN-D2) — only primitives directly
1669
- // exercised by tests are exposed. Additional helpers (isPidAlive,
1670
- // readLockHolderPid, replaceLineAtIndex) are covered indirectly via the
1671
- // withFileLock and applyCrossAgentDedup paths. Production code MUST NOT
1672
- // import __testExports.
1673
- export const __testExports = {
1674
- withFileLock,
1675
- };