onto-mcp 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.onto/processes/reconstruct/actionable-ontology-seed-recomposition-design.md +447 -0
- package/.onto/processes/reconstruct/foundry-style-ontology-seed-contract.md +934 -0
- package/.onto/processes/reconstruct/reconstruct-boundary-contract.md +303 -725
- package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +1645 -0
- package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +26 -22
- package/.onto/processes/reconstruct/source-profile-contract.md +49 -23
- package/.onto/processes/reconstruct/source-profiles/code.md +6 -3
- package/.onto/processes/reconstruct/source-profiles/database.md +5 -2
- package/.onto/processes/reconstruct/source-profiles/document.md +5 -2
- package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +5 -4
- package/.onto/processes/review/review-execution-ux-contract.md +40 -0
- package/.onto/processes/shared/pipeline-execution-ledger-contract.md +26 -10
- package/.onto/processes/shared/target-material-kind-contract.md +29 -16
- package/AGENTS.md +6 -4
- package/README.md +149 -76
- package/dist/cli.js +8 -8
- package/dist/core-api/reconstruct-api.js +117 -31
- package/dist/core-api/review-api.js +47 -0
- package/dist/core-runtime/cli/codex-review-unit-executor.js +39 -2
- package/dist/core-runtime/cli/complete-review-session.js +2 -2
- package/dist/core-runtime/cli/mock-review-unit-executor.js +1 -1
- package/dist/core-runtime/cli/review-invoke.js +9 -9
- package/dist/core-runtime/cli/run-review-prompt-execution.js +39 -5
- package/dist/core-runtime/cli/spawn-watcher.js +266 -47
- package/dist/core-runtime/cli/start-review-session.js +3 -3
- package/dist/core-runtime/llm/llm-caller.js +11 -0
- package/dist/core-runtime/llm/llm-tool-loop.js +2 -0
- package/dist/core-runtime/observability/runtime-stream-observation.js +118 -0
- package/dist/core-runtime/onboard/cli-host.js +174 -0
- package/dist/core-runtime/onboard/host-target.js +22 -0
- package/dist/core-runtime/onboard/json-config-host.js +122 -0
- package/dist/core-runtime/onboard/path-scan.js +26 -0
- package/dist/core-runtime/onboard/prompt.js +51 -0
- package/dist/core-runtime/onboard/register.js +214 -0
- package/dist/core-runtime/onboard/types.js +27 -0
- package/dist/core-runtime/reconstruct/actionable-seed-validation.js +1777 -0
- package/dist/core-runtime/reconstruct/artifact-types.js +10 -4
- package/dist/core-runtime/reconstruct/contract-registry.js +623 -0
- package/dist/core-runtime/reconstruct/domain-id.js +10 -0
- package/dist/core-runtime/reconstruct/governing-snapshot.js +716 -0
- package/dist/core-runtime/reconstruct/material-profile-validation.js +191 -0
- package/dist/core-runtime/reconstruct/materialize-preparation.js +49 -11
- package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +269 -79
- package/dist/core-runtime/reconstruct/post-seed-validation.js +1194 -51
- package/dist/core-runtime/reconstruct/record.js +104 -20
- package/dist/core-runtime/reconstruct/run.js +2107 -413
- package/dist/core-runtime/reconstruct/seed-claim-projections.js +268 -0
- package/dist/core-runtime/reconstruct/source-profiles.js +93 -4
- package/dist/core-runtime/reconstruct/terminal-validation.js +807 -0
- package/dist/core-runtime/review/review-invocation-runner.js +4 -4
- package/dist/mcp/server.js +110 -38
- package/dist/mcp/tool-schemas.js +20 -6
- package/package.json +8 -17
- package/scripts/onto-review-watch.sh +486 -0
- package/scripts/onto-runtime-watch.sh +122 -0
- package/scripts/postinstall-hint.js +22 -0
- package/.onto/processes/reconstruct/top-level-concept-discovery-contract.md +0 -387
- package/dist/core-runtime/cli/bootstrap-review-binding.js +0 -186
- package/dist/core-runtime/cli/codex-nested-dispatch.test.js +0 -390
- package/dist/core-runtime/cli/codex-nested-teamlead-executor.test.js +0 -335
- package/dist/core-runtime/cli/coordinator-helpers.js +0 -583
- package/dist/core-runtime/cli/coordinator-state-machine-deliberation.test.js +0 -167
- package/dist/core-runtime/cli/coordinator-state-machine.js +0 -794
- package/dist/core-runtime/cli/e2e-codex-multi-agent-fixes.test.js +0 -615
- package/dist/core-runtime/cli/e2e-start-review-session.test.js +0 -312
- package/dist/core-runtime/cli/health.js +0 -44
- package/dist/core-runtime/cli/inline-http-review-unit-executor.test.js +0 -567
- package/dist/core-runtime/cli/materialize-review-execution-preparation.js +0 -104
- package/dist/core-runtime/cli/migrate-session-roots.js +0 -118
- package/dist/core-runtime/cli/repo-layout-migration-replace.smoke.test.js +0 -106
- package/dist/core-runtime/cli/review-invoke-auto-resolution.test.js +0 -268
- package/dist/core-runtime/cli/review-invoke-coordinator-topology.test.js +0 -136
- package/dist/core-runtime/cli/review-invoke-resolver-caching.test.js +0 -201
- package/dist/core-runtime/cli/review-invoke-topology-dispatch.test.js +0 -192
- package/dist/core-runtime/cli/session-root-guard.js +0 -168
- package/dist/core-runtime/cli/spawn-watcher.test.js +0 -457
- package/dist/core-runtime/cli/strip-wrapping-code-fence.test.js +0 -79
- package/dist/core-runtime/cli/teamcreate-lens-deliberation-executor.js +0 -412
- package/dist/core-runtime/cli/teamcreate-lens-deliberation-executor.test.js +0 -351
- package/dist/core-runtime/cli/topology-executor-mapping.js +0 -139
- package/dist/core-runtime/cli/topology-executor-mapping.test.js +0 -173
- package/dist/core-runtime/cli/write-review-interpretation.js +0 -81
- package/dist/core-runtime/config/onto-config-cli.js +0 -278
- package/dist/core-runtime/config/onto-config-key-path.js +0 -288
- package/dist/core-runtime/config/onto-config-key-path.test.js +0 -195
- package/dist/core-runtime/config/onto-config-preview.js +0 -108
- package/dist/core-runtime/config/onto-config-preview.test.js +0 -132
- package/dist/core-runtime/discovery/config-chain.js +0 -118
- package/dist/core-runtime/discovery/config-chain.test.js +0 -103
- package/dist/core-runtime/discovery/config-profile.js +0 -199
- package/dist/core-runtime/discovery/config-profile.test.js +0 -233
- package/dist/core-runtime/discovery/host-detection.test.js +0 -186
- package/dist/core-runtime/discovery/installation-paths.test.js +0 -65
- package/dist/core-runtime/discovery/lens-registry.test.js +0 -81
- package/dist/core-runtime/discovery/path-normalization.test.js +0 -22
- package/dist/core-runtime/discovery/plugin-path.js +0 -72
- package/dist/core-runtime/discovery/plugin-path.test.js +0 -95
- package/dist/core-runtime/evolve/adapters/code-product/compile/compile-defense.js +0 -344
- package/dist/core-runtime/evolve/adapters/code-product/compile/compile-defense.test.js +0 -915
- package/dist/core-runtime/evolve/adapters/code-product/compile/compile.js +0 -564
- package/dist/core-runtime/evolve/adapters/code-product/compile/compile.test.js +0 -708
- package/dist/core-runtime/evolve/adapters/code-product/parsers/brief-parser.js +0 -165
- package/dist/core-runtime/evolve/adapters/code-product/parsers/brief-parser.test.js +0 -227
- package/dist/core-runtime/evolve/adapters/code-product/validators/validate.js +0 -59
- package/dist/core-runtime/evolve/adapters/code-product/validators/validate.test.js +0 -205
- package/dist/core-runtime/evolve/adapters/methodology/adapter.js +0 -16
- package/dist/core-runtime/evolve/adapters/methodology/adapter.test.js +0 -9
- package/dist/core-runtime/evolve/adapters/methodology/perspectives/authority-consistency.js +0 -298
- package/dist/core-runtime/evolve/adapters/methodology/perspectives/authority-consistency.test.js +0 -70
- package/dist/core-runtime/evolve/adapters/methodology/scope-types/process.js +0 -46
- package/dist/core-runtime/evolve/adapters/methodology/scope-types/process.test.js +0 -73
- package/dist/core-runtime/evolve/adapters/registry.js +0 -47
- package/dist/core-runtime/evolve/adapters/registry.test.js +0 -67
- package/dist/core-runtime/evolve/cli.js +0 -256
- package/dist/core-runtime/evolve/commands/align.js +0 -194
- package/dist/core-runtime/evolve/commands/align.test.js +0 -82
- package/dist/core-runtime/evolve/commands/apply.js +0 -161
- package/dist/core-runtime/evolve/commands/apply.test.js +0 -138
- package/dist/core-runtime/evolve/commands/close.js +0 -39
- package/dist/core-runtime/evolve/commands/close.test.js +0 -99
- package/dist/core-runtime/evolve/commands/defer.js +0 -40
- package/dist/core-runtime/evolve/commands/defer.test.js +0 -134
- package/dist/core-runtime/evolve/commands/draft.js +0 -323
- package/dist/core-runtime/evolve/commands/draft.test.js +0 -178
- package/dist/core-runtime/evolve/commands/e2e-evolve-full-cycle.test.js +0 -208
- package/dist/core-runtime/evolve/commands/error-messages.js +0 -125
- package/dist/core-runtime/evolve/commands/error-messages.test.js +0 -167
- package/dist/core-runtime/evolve/commands/propose-align.js +0 -222
- package/dist/core-runtime/evolve/commands/propose-align.test.js +0 -136
- package/dist/core-runtime/evolve/commands/reconstruct.js +0 -330
- package/dist/core-runtime/evolve/commands/reconstruct.test.js +0 -278
- package/dist/core-runtime/evolve/commands/shared.js +0 -22
- package/dist/core-runtime/evolve/commands/stale-check.js +0 -103
- package/dist/core-runtime/evolve/commands/stale-check.test.js +0 -84
- package/dist/core-runtime/evolve/commands/start.js +0 -887
- package/dist/core-runtime/evolve/commands/start.test.js +0 -396
- package/dist/core-runtime/evolve/config/project-config.js +0 -99
- package/dist/core-runtime/evolve/config/project-config.test.js +0 -170
- package/dist/core-runtime/evolve/renderers/align-packet.js +0 -280
- package/dist/core-runtime/evolve/renderers/align-packet.test.js +0 -332
- package/dist/core-runtime/evolve/renderers/draft-packet.js +0 -303
- package/dist/core-runtime/evolve/renderers/draft-packet.test.js +0 -377
- package/dist/core-runtime/evolve/renderers/format.js +0 -5
- package/dist/core-runtime/evolve/renderers/scope-md.js +0 -237
- package/dist/core-runtime/evolve/renderers/scope-md.test.js +0 -306
- package/dist/core-runtime/govern/cli.js +0 -369
- package/dist/core-runtime/govern/cli.test.js +0 -314
- package/dist/core-runtime/govern/drift-engine.js +0 -103
- package/dist/core-runtime/govern/drift-engine.test.js +0 -319
- package/dist/core-runtime/govern/promote-principle.js +0 -206
- package/dist/core-runtime/govern/promote-principle.test.js +0 -368
- package/dist/core-runtime/govern/queue.js +0 -81
- package/dist/core-runtime/govern/types.js +0 -16
- package/dist/core-runtime/install/cli.js +0 -530
- package/dist/core-runtime/install/detect.js +0 -128
- package/dist/core-runtime/install/detect.test.js +0 -155
- package/dist/core-runtime/install/gitignore-update.js +0 -74
- package/dist/core-runtime/install/gitignore-update.test.js +0 -64
- package/dist/core-runtime/install/install-integration.test.js +0 -373
- package/dist/core-runtime/install/prompts.js +0 -389
- package/dist/core-runtime/install/prompts.test.js +0 -293
- package/dist/core-runtime/install/types.js +0 -26
- package/dist/core-runtime/install/validation.js +0 -295
- package/dist/core-runtime/install/validation.test.js +0 -313
- package/dist/core-runtime/install/writer.js +0 -254
- package/dist/core-runtime/install/writer.test.js +0 -218
- package/dist/core-runtime/learning/extractor.js +0 -461
- package/dist/core-runtime/learning/feedback.js +0 -179
- package/dist/core-runtime/learning/health-report.js +0 -165
- package/dist/core-runtime/learning/health-report.test.js +0 -169
- package/dist/core-runtime/learning/loader.js +0 -388
- package/dist/core-runtime/learning/loader.test.js +0 -102
- package/dist/core-runtime/learning/promote/apply-state.js +0 -240
- package/dist/core-runtime/learning/promote/audit-obligation.js +0 -195
- package/dist/core-runtime/learning/promote/collector.js +0 -432
- package/dist/core-runtime/learning/promote/degraded-state.js +0 -125
- package/dist/core-runtime/learning/promote/domain-doc-proposer.js +0 -166
- package/dist/core-runtime/learning/promote/e2e-promote.test.js +0 -6385
- package/dist/core-runtime/learning/promote/health-snapshot.js +0 -150
- package/dist/core-runtime/learning/promote/insight-reclassifier.js +0 -544
- package/dist/core-runtime/learning/promote/judgment-auditor.js +0 -517
- package/dist/core-runtime/learning/promote/panel-reviewer.js +0 -1158
- package/dist/core-runtime/learning/promote/promote-executor.js +0 -1675
- package/dist/core-runtime/learning/promote/promoter.js +0 -307
- package/dist/core-runtime/learning/promote/retirement.js +0 -122
- package/dist/core-runtime/learning/promote/types.js +0 -23
- package/dist/core-runtime/learning/prompt-sections.js +0 -51
- package/dist/core-runtime/learning/shared/artifact-registry-init.js +0 -45
- package/dist/core-runtime/learning/shared/artifact-registry.js +0 -254
- package/dist/core-runtime/learning/shared/audit-obligation-kernel.js +0 -73
- package/dist/core-runtime/learning/shared/audit-state.js +0 -99
- package/dist/core-runtime/learning/shared/duplicate-check.js +0 -28
- package/dist/core-runtime/learning/shared/llm-caller.js +0 -831
- package/dist/core-runtime/learning/shared/llm-caller.test.js +0 -601
- package/dist/core-runtime/learning/shared/llm-tool-loop.js +0 -393
- package/dist/core-runtime/learning/shared/mode.js +0 -25
- package/dist/core-runtime/learning/shared/paths.js +0 -84
- package/dist/core-runtime/learning/shared/paths.test.js +0 -79
- package/dist/core-runtime/learning/shared/patterns.js +0 -37
- package/dist/core-runtime/learning/shared/recoverability.js +0 -355
- package/dist/core-runtime/learning/shared/recovery-context.js +0 -374
- package/dist/core-runtime/learning/shared/scope.js +0 -1
- package/dist/core-runtime/learning/shared/semantic-classifier.js +0 -94
- package/dist/core-runtime/learning/shared/specs/apply-execution-state-spec.js +0 -42
- package/dist/core-runtime/learning/shared/specs/audit-state-spec.js +0 -37
- package/dist/core-runtime/learning/shared/specs/backup-metadata-spec.js +0 -39
- package/dist/core-runtime/learning/shared/specs/emergency-log-spec.js +0 -41
- package/dist/core-runtime/learning/shared/specs/layout-version-spec.js +0 -38
- package/dist/core-runtime/learning/shared/specs/promote-decisions-spec.js +0 -43
- package/dist/core-runtime/learning/shared/specs/promote-report-spec.js +0 -113
- package/dist/core-runtime/learning/shared/specs/prune-log-spec.js +0 -36
- package/dist/core-runtime/learning/shared/specs/recovery-resolution-spec.js +0 -48
- package/dist/core-runtime/learning/shared/specs/restore-manifest-spec.js +0 -43
- package/dist/core-runtime/learning/shared/specs/spec-helpers.js +0 -64
- package/dist/core-runtime/learning/usage-tracker.js +0 -190
- package/dist/core-runtime/learning/usage-tracker.test.js +0 -176
- package/dist/core-runtime/onboard/detect-review-axes.js +0 -122
- package/dist/core-runtime/onboard/detect-review-axes.test.js +0 -127
- package/dist/core-runtime/onboard/write-review-block.js +0 -188
- package/dist/core-runtime/onboard/write-review-block.test.js +0 -240
- package/dist/core-runtime/readers/brownfield-builder.js +0 -150
- package/dist/core-runtime/readers/brownfield-builder.test.js +0 -136
- package/dist/core-runtime/readers/code-chunk-collector.js +0 -53
- package/dist/core-runtime/readers/code-chunk-collector.test.js +0 -136
- package/dist/core-runtime/readers/file-utils.js +0 -240
- package/dist/core-runtime/readers/file-utils.test.js +0 -146
- package/dist/core-runtime/readers/lexicon-citation-check.js +0 -93
- package/dist/core-runtime/readers/lexicon-citation-check.test.js +0 -77
- package/dist/core-runtime/readers/mcp-figma.js +0 -30
- package/dist/core-runtime/readers/mcp-figma.test.js +0 -82
- package/dist/core-runtime/readers/mcp-generic.js +0 -31
- package/dist/core-runtime/readers/mcp-generic.test.js +0 -76
- package/dist/core-runtime/readers/ontology-index.js +0 -148
- package/dist/core-runtime/readers/ontology-index.test.js +0 -245
- package/dist/core-runtime/readers/ontology-query.js +0 -168
- package/dist/core-runtime/readers/ontology-query.test.js +0 -311
- package/dist/core-runtime/readers/ontology-resolve.js +0 -48
- package/dist/core-runtime/readers/ontology-resolve.test.js +0 -48
- package/dist/core-runtime/readers/patterns/index.js +0 -7
- package/dist/core-runtime/readers/review-log.js +0 -213
- package/dist/core-runtime/readers/review-log.test.js +0 -313
- package/dist/core-runtime/readers/scan-local.js +0 -102
- package/dist/core-runtime/readers/scan-local.test.js +0 -102
- package/dist/core-runtime/readers/scan-tarball.js +0 -121
- package/dist/core-runtime/readers/scan-tarball.test.js +0 -283
- package/dist/core-runtime/readers/scan-vault.js +0 -34
- package/dist/core-runtime/readers/scan-vault.test.js +0 -81
- package/dist/core-runtime/readers/types.js +0 -42
- package/dist/core-runtime/readers/types.test.js +0 -94
- package/dist/core-runtime/readers/viewpoint-collectors.js +0 -229
- package/dist/core-runtime/reconstruct/seed-candidate-validation.js +0 -385
- package/dist/core-runtime/review/citation-audit.test.js +0 -165
- package/dist/core-runtime/review/execution-plan-resolver.js +0 -247
- package/dist/core-runtime/review/execution-plan-resolver.test.js +0 -243
- package/dist/core-runtime/review/execution-topology-resolver-axis-first.test.js +0 -246
- package/dist/core-runtime/review/execution-topology-resolver.js +0 -401
- package/dist/core-runtime/review/execution-topology-resolver.test.js +0 -315
- package/dist/core-runtime/review/inline-context-embedder.test.js +0 -154
- package/dist/core-runtime/review/legacy-mode-policy.js +0 -88
- package/dist/core-runtime/review/materializers-effort-persist.test.js +0 -79
- package/dist/core-runtime/review/ontology-path-classifier.js +0 -179
- package/dist/core-runtime/review/ontology-path-classifier.test.js +0 -216
- package/dist/core-runtime/review/packet-boundary-policy.test.js +0 -107
- package/dist/core-runtime/review/participating-lens-paths.test.js +0 -73
- package/dist/core-runtime/review/review-config-legacy-translate.js +0 -244
- package/dist/core-runtime/review/review-config-legacy-translate.test.js +0 -161
- package/dist/core-runtime/review/review-config-validator.js +0 -289
- package/dist/core-runtime/review/review-config-validator.test.js +0 -236
- package/dist/core-runtime/review/shape-pipeline-audit.test.js +0 -311
- package/dist/core-runtime/review/shape-to-topology-id.js +0 -117
- package/dist/core-runtime/review/shape-to-topology-id.test.js +0 -132
- package/dist/core-runtime/review/topology-shape-derivation.js +0 -155
- package/dist/core-runtime/review/topology-shape-derivation.test.js +0 -195
- package/dist/core-runtime/scope-runtime/constants.js +0 -12
- package/dist/core-runtime/scope-runtime/constraint-pool.js +0 -166
- package/dist/core-runtime/scope-runtime/constraint-pool.test.js +0 -674
- package/dist/core-runtime/scope-runtime/domain-validation-log.js +0 -135
- package/dist/core-runtime/scope-runtime/domain-validation-log.test.js +0 -156
- package/dist/core-runtime/scope-runtime/eval-persistence.js +0 -65
- package/dist/core-runtime/scope-runtime/eval-persistence.test.js +0 -84
- package/dist/core-runtime/scope-runtime/event-pipeline.js +0 -64
- package/dist/core-runtime/scope-runtime/event-pipeline.test.js +0 -450
- package/dist/core-runtime/scope-runtime/event-store.js +0 -39
- package/dist/core-runtime/scope-runtime/event-store.test.js +0 -95
- package/dist/core-runtime/scope-runtime/gate-guard.js +0 -348
- package/dist/core-runtime/scope-runtime/gate-guard.test.js +0 -1047
- package/dist/core-runtime/scope-runtime/hash.js +0 -4
- package/dist/core-runtime/scope-runtime/hash.test.js +0 -33
- package/dist/core-runtime/scope-runtime/id.js +0 -4
- package/dist/core-runtime/scope-runtime/id.test.js +0 -17
- package/dist/core-runtime/scope-runtime/reducer.js +0 -297
- package/dist/core-runtime/scope-runtime/reducer.test.js +0 -759
- package/dist/core-runtime/scope-runtime/scope-manager.js +0 -161
- package/dist/core-runtime/scope-runtime/state-machine.js +0 -309
- package/dist/core-runtime/scope-runtime/state-machine.test.js +0 -704
- package/dist/core-runtime/scope-runtime/types.js +0 -116
- package/dist/core-runtime/scope-runtime/types.test.js +0 -69
- package/dist/core-runtime/translate/render-for-user.js +0 -169
- package/dist/core-runtime/translate/render-for-user.test.js +0 -122
- 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
|
-
};
|