openplanr 1.13.2 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +46 -4
  2. package/dist/cli/commands/artifact.d.ts.map +1 -1
  3. package/dist/cli/commands/artifact.js +17 -6
  4. package/dist/cli/commands/artifact.js.map +1 -1
  5. package/dist/cli/commands/operate.d.ts +3 -0
  6. package/dist/cli/commands/operate.d.ts.map +1 -0
  7. package/dist/cli/commands/operate.js +568 -0
  8. package/dist/cli/commands/operate.js.map +1 -0
  9. package/dist/cli/index.js +42 -2
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/services/ai-service.d.ts +9 -2
  12. package/dist/services/ai-service.d.ts.map +1 -1
  13. package/dist/services/ai-service.js +14 -6
  14. package/dist/services/ai-service.js.map +1 -1
  15. package/dist/services/artifact-pipeline-service.d.ts +1 -0
  16. package/dist/services/artifact-pipeline-service.d.ts.map +1 -1
  17. package/dist/services/artifact-pipeline-service.js.map +1 -1
  18. package/dist/services/delta-apply-service.js +1 -1
  19. package/dist/services/delta-apply-service.js.map +1 -1
  20. package/dist/services/operate/advisors.d.ts +219 -0
  21. package/dist/services/operate/advisors.d.ts.map +1 -0
  22. package/dist/services/operate/advisors.js +745 -0
  23. package/dist/services/operate/advisors.js.map +1 -0
  24. package/dist/services/operate/artifact-route-generation.d.ts +111 -0
  25. package/dist/services/operate/artifact-route-generation.d.ts.map +1 -0
  26. package/dist/services/operate/artifact-route-generation.js +459 -0
  27. package/dist/services/operate/artifact-route-generation.js.map +1 -0
  28. package/dist/services/operate/artifacts.d.ts +26 -0
  29. package/dist/services/operate/artifacts.d.ts.map +1 -0
  30. package/dist/services/operate/artifacts.js +92 -0
  31. package/dist/services/operate/artifacts.js.map +1 -0
  32. package/dist/services/operate/canonical.d.ts +18 -0
  33. package/dist/services/operate/canonical.d.ts.map +1 -0
  34. package/dist/services/operate/canonical.js +157 -0
  35. package/dist/services/operate/canonical.js.map +1 -0
  36. package/dist/services/operate/config.d.ts +56 -0
  37. package/dist/services/operate/config.d.ts.map +1 -0
  38. package/dist/services/operate/config.js +644 -0
  39. package/dist/services/operate/config.js.map +1 -0
  40. package/dist/services/operate/consolidation.d.ts +18 -0
  41. package/dist/services/operate/consolidation.d.ts.map +1 -0
  42. package/dist/services/operate/consolidation.js +539 -0
  43. package/dist/services/operate/consolidation.js.map +1 -0
  44. package/dist/services/operate/doctor.d.ts +12 -0
  45. package/dist/services/operate/doctor.d.ts.map +1 -0
  46. package/dist/services/operate/doctor.js +307 -0
  47. package/dist/services/operate/doctor.js.map +1 -0
  48. package/dist/services/operate/engine.d.ts +53 -0
  49. package/dist/services/operate/engine.d.ts.map +1 -0
  50. package/dist/services/operate/engine.js +1097 -0
  51. package/dist/services/operate/engine.js.map +1 -0
  52. package/dist/services/operate/event-store.d.ts +57 -0
  53. package/dist/services/operate/event-store.d.ts.map +1 -0
  54. package/dist/services/operate/event-store.js +232 -0
  55. package/dist/services/operate/event-store.js.map +1 -0
  56. package/dist/services/operate/evidence-cache.d.ts +17 -0
  57. package/dist/services/operate/evidence-cache.d.ts.map +1 -0
  58. package/dist/services/operate/evidence-cache.js +96 -0
  59. package/dist/services/operate/evidence-cache.js.map +1 -0
  60. package/dist/services/operate/evidence-import.d.ts +40 -0
  61. package/dist/services/operate/evidence-import.d.ts.map +1 -0
  62. package/dist/services/operate/evidence-import.js +340 -0
  63. package/dist/services/operate/evidence-import.js.map +1 -0
  64. package/dist/services/operate/evidence-readiness.d.ts +8 -0
  65. package/dist/services/operate/evidence-readiness.d.ts.map +1 -0
  66. package/dist/services/operate/evidence-readiness.js +78 -0
  67. package/dist/services/operate/evidence-readiness.js.map +1 -0
  68. package/dist/services/operate/evidence.d.ts +71 -0
  69. package/dist/services/operate/evidence.d.ts.map +1 -0
  70. package/dist/services/operate/evidence.js +1022 -0
  71. package/dist/services/operate/evidence.js.map +1 -0
  72. package/dist/services/operate/ids.d.ts +7 -0
  73. package/dist/services/operate/ids.d.ts.map +1 -0
  74. package/dist/services/operate/ids.js +23 -0
  75. package/dist/services/operate/ids.js.map +1 -0
  76. package/dist/services/operate/index.d.ts +14 -0
  77. package/dist/services/operate/index.d.ts.map +1 -0
  78. package/dist/services/operate/index.js +881 -0
  79. package/dist/services/operate/index.js.map +1 -0
  80. package/dist/services/operate/journal.d.ts +34 -0
  81. package/dist/services/operate/journal.d.ts.map +1 -0
  82. package/dist/services/operate/journal.js +229 -0
  83. package/dist/services/operate/journal.js.map +1 -0
  84. package/dist/services/operate/legacy-import-service.d.ts +58 -0
  85. package/dist/services/operate/legacy-import-service.d.ts.map +1 -0
  86. package/dist/services/operate/legacy-import-service.js +922 -0
  87. package/dist/services/operate/legacy-import-service.js.map +1 -0
  88. package/dist/services/operate/lifecycle.d.ts +88 -0
  89. package/dist/services/operate/lifecycle.d.ts.map +1 -0
  90. package/dist/services/operate/lifecycle.js +556 -0
  91. package/dist/services/operate/lifecycle.js.map +1 -0
  92. package/dist/services/operate/lock-service.d.ts +37 -0
  93. package/dist/services/operate/lock-service.d.ts.map +1 -0
  94. package/dist/services/operate/lock-service.js +208 -0
  95. package/dist/services/operate/lock-service.js.map +1 -0
  96. package/dist/services/operate/maintenance.d.ts +38 -0
  97. package/dist/services/operate/maintenance.d.ts.map +1 -0
  98. package/dist/services/operate/maintenance.js +699 -0
  99. package/dist/services/operate/maintenance.js.map +1 -0
  100. package/dist/services/operate/outcomes.d.ts +48 -0
  101. package/dist/services/operate/outcomes.d.ts.map +1 -0
  102. package/dist/services/operate/outcomes.js +312 -0
  103. package/dist/services/operate/outcomes.js.map +1 -0
  104. package/dist/services/operate/pipeline-handoff.d.ts +138 -0
  105. package/dist/services/operate/pipeline-handoff.d.ts.map +1 -0
  106. package/dist/services/operate/pipeline-handoff.js +175 -0
  107. package/dist/services/operate/pipeline-handoff.js.map +1 -0
  108. package/dist/services/operate/projection-persistence.d.ts +55 -0
  109. package/dist/services/operate/projection-persistence.d.ts.map +1 -0
  110. package/dist/services/operate/projection-persistence.js +393 -0
  111. package/dist/services/operate/projection-persistence.js.map +1 -0
  112. package/dist/services/operate/projection.d.ts +4 -0
  113. package/dist/services/operate/projection.d.ts.map +1 -0
  114. package/dist/services/operate/projection.js +128 -0
  115. package/dist/services/operate/projection.js.map +1 -0
  116. package/dist/services/operate/protocol.d.ts +60 -0
  117. package/dist/services/operate/protocol.d.ts.map +1 -0
  118. package/dist/services/operate/protocol.js +62 -0
  119. package/dist/services/operate/protocol.js.map +1 -0
  120. package/dist/services/operate/read-only-providers.d.ts +38 -0
  121. package/dist/services/operate/read-only-providers.d.ts.map +1 -0
  122. package/dist/services/operate/read-only-providers.js +286 -0
  123. package/dist/services/operate/read-only-providers.js.map +1 -0
  124. package/dist/services/operate/redaction.d.ts +47 -0
  125. package/dist/services/operate/redaction.d.ts.map +1 -0
  126. package/dist/services/operate/redaction.js +279 -0
  127. package/dist/services/operate/redaction.js.map +1 -0
  128. package/dist/services/operate/reducer.d.ts +10 -0
  129. package/dist/services/operate/reducer.d.ts.map +1 -0
  130. package/dist/services/operate/reducer.js +11 -0
  131. package/dist/services/operate/reducer.js.map +1 -0
  132. package/dist/services/operate/routes.d.ts +49 -0
  133. package/dist/services/operate/routes.d.ts.map +1 -0
  134. package/dist/services/operate/routes.js +996 -0
  135. package/dist/services/operate/routes.js.map +1 -0
  136. package/dist/services/operate/shipment-observer.d.ts +14 -0
  137. package/dist/services/operate/shipment-observer.d.ts.map +1 -0
  138. package/dist/services/operate/shipment-observer.js +321 -0
  139. package/dist/services/operate/shipment-observer.js.map +1 -0
  140. package/dist/services/operate/stalled-item-service.d.ts +18 -0
  141. package/dist/services/operate/stalled-item-service.d.ts.map +1 -0
  142. package/dist/services/operate/stalled-item-service.js +121 -0
  143. package/dist/services/operate/stalled-item-service.js.map +1 -0
  144. package/dist/services/operate/subprocess-env.d.ts +4 -0
  145. package/dist/services/operate/subprocess-env.d.ts.map +1 -0
  146. package/dist/services/operate/subprocess-env.js +45 -0
  147. package/dist/services/operate/subprocess-env.js.map +1 -0
  148. package/dist/services/operate/types.d.ts +729 -0
  149. package/dist/services/operate/types.d.ts.map +1 -0
  150. package/dist/services/operate/types.js +13 -0
  151. package/dist/services/operate/types.js.map +1 -0
  152. package/dist/services/operate/workspace.d.ts +59 -0
  153. package/dist/services/operate/workspace.d.ts.map +1 -0
  154. package/dist/services/operate/workspace.js +316 -0
  155. package/dist/services/operate/workspace.js.map +1 -0
  156. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  157. package/dist/services/runtime-manager-service.js +71 -2
  158. package/dist/services/runtime-manager-service.js.map +1 -1
  159. package/package.json +10 -4
@@ -0,0 +1,996 @@
1
+ import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createOperatingArtifactGenerationPlan, generatedArtifactWrites, generateOperatingRouteArtifact, readStoredOperatingArtifactGeneration, resolveOperatingArtifactGenerator, } from './artifact-route-generation.js';
4
+ import { canonicalDigest, canonicalize, sha256Digest } from './canonical.js';
5
+ import { operatingProjectKey } from './config.js';
6
+ import { OperatingEventStore } from './event-store.js';
7
+ import { applyJournalTransaction, prepareJournalTransaction, readJournal, rollbackJournalTransaction, } from './journal.js';
8
+ import { withOperatingLock } from './lock-service.js';
9
+ import { assertPlanningProducer, completePipelinePoHandoff, hasPipelinePoCompletionProvenance, inspectPlanningProducer, loadPipelinePoBridge, preparePipelinePoHandoff, } from './pipeline-handoff.js';
10
+ import { persistOperatingProjections } from './projection-persistence.js';
11
+ import { assertOperatingArtifact } from './protocol.js';
12
+ import { sanitizeGeneratedPlainText } from './redaction.js';
13
+ import { OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
14
+ import { refreshOperatingWorkspaceManifest, resolveContainedPath, resolveOperatingPaths, } from './workspace.js';
15
+ const ROUTE_MANAGED_WORKSPACE_PATHS = ['.planr/specs', '.planr/provenance.jsonl'];
16
+ function actionKind(finding) {
17
+ if (finding.lane === 'OWNER')
18
+ return 'create-decision';
19
+ if (finding.lane === 'AGENT')
20
+ return 'create-cycle-artifact';
21
+ if (finding.category.includes('instrument'))
22
+ return 'create-instrumentation-spec';
23
+ return 'create-spec';
24
+ }
25
+ function slugify(value) {
26
+ return (value
27
+ .normalize('NFKD')
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/^-+|-+$/g, '')
31
+ .slice(0, 60) || 'operating-action');
32
+ }
33
+ export async function nextOperatingSpecOrdinal(projectRoot) {
34
+ const root = path.join(projectRoot, '.planr', 'specs');
35
+ let maximum = 0;
36
+ for (const name of await readdir(root).catch(() => [])) {
37
+ const match = name.match(/^SPEC-(\d+)(?:-|$)/);
38
+ if (match)
39
+ maximum = Math.max(maximum, Number(match[1]));
40
+ }
41
+ return maximum + 1;
42
+ }
43
+ function routeDestinationPaths(route) {
44
+ const action = route.actions[0];
45
+ if (!action?.targetPath)
46
+ return [];
47
+ if (action.kind === 'create-spec' || action.kind === 'create-instrumentation-spec') {
48
+ const specId = action.targetPath.match(/(?:^|\/)(SPEC-\d+)(?:-|\/)/)?.[1];
49
+ if (!specId)
50
+ return [action.targetPath];
51
+ const ordinal = specId.slice('SPEC-'.length);
52
+ return [
53
+ action.targetPath,
54
+ `.planr/operate/spec-links/${specId}.json`,
55
+ `.planr/operate/outcomes/OUT-${ordinal}.json`,
56
+ `.planr/operate/handoffs/${route.id}.json`,
57
+ ];
58
+ }
59
+ if (action.kind === 'create-cycle-artifact') {
60
+ const artifactId = `ART-${route.id.slice('ACT-'.length)}`;
61
+ return [
62
+ action.targetPath,
63
+ `${path.posix.dirname(action.targetPath)}/${artifactId}.session.json`,
64
+ ];
65
+ }
66
+ return [action.targetPath];
67
+ }
68
+ export async function createOperatingRoutePlan(input) {
69
+ const now = input.now ?? new Date().toISOString();
70
+ const id = `ACT-${String(input.sequence).padStart(3, '0')}`;
71
+ const kind = actionKind(input.finding);
72
+ const slug = slugify(input.finding.title);
73
+ const targetPath = kind === 'create-spec' || kind === 'create-instrumentation-spec'
74
+ ? `.planr/specs/${input.specId ?? `SPEC-${String(input.sequence).padStart(3, '0')}`}-${slug}/${input.specId ?? `SPEC-${String(input.sequence).padStart(3, '0')}`}-${slug}.md`
75
+ : kind === 'create-cycle-artifact'
76
+ ? `.planr/operate/cycles/${input.cycleId}/artifacts/ART-${id.slice('ACT-'.length)}-${slug}.md`
77
+ : `.planr/operate/decisions/${id}.json`;
78
+ const action = {
79
+ id,
80
+ findingId: input.finding.id,
81
+ lane: input.finding.lane,
82
+ owner: input.finding.owner,
83
+ kind,
84
+ dependsOn: [],
85
+ evidenceRefs: [...input.finding.evidenceRefs].sort(),
86
+ reversible: true,
87
+ requiresConfirmation: true,
88
+ targetPath,
89
+ };
90
+ const routeWorkspace = await refreshOperatingWorkspaceManifest(input.projectRoot, {
91
+ localRoot: input.localRoot,
92
+ ignoredControlPaths: [...ROUTE_MANAGED_WORKSPACE_PATHS],
93
+ });
94
+ const destinationShape = {
95
+ actions: [action],
96
+ id,
97
+ };
98
+ const destinationDigest = canonicalDigest(routeDestinationPaths(destinationShape).map((destination) => ({
99
+ path: destination,
100
+ beforeDigest: null,
101
+ })));
102
+ const inputDigest = canonicalDigest({
103
+ project: routeWorkspace.workspaceDigest,
104
+ cycleId: input.cycleId,
105
+ findingId: input.finding.id,
106
+ evidenceHead: input.evidenceDigest,
107
+ eventHead: input.eventHead,
108
+ providerPolicy: input.providerDigest,
109
+ destinations: destinationDigest,
110
+ actions: [action],
111
+ planningEngine: input.config.planningEngine,
112
+ });
113
+ const provisional = {
114
+ kind: 'operating-route-plan',
115
+ schemaVersion: OPERATE_SCHEMA_VERSION,
116
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
117
+ id,
118
+ cycleId: input.cycleId,
119
+ inputDigest,
120
+ routeDigest: `sha256:${'0'.repeat(64)}`,
121
+ previewDigest: `sha256:${'0'.repeat(64)}`,
122
+ workspaceDigest: routeWorkspace.workspaceDigest,
123
+ evidenceDigest: input.evidenceDigest,
124
+ providerDigest: input.providerDigest,
125
+ destinationDigest,
126
+ eventHead: structuredClone(input.eventHead),
127
+ state: 'proposed',
128
+ actions: [action],
129
+ createdAt: now,
130
+ };
131
+ const plannedWrites = await buildRouteWrites({
132
+ projectRoot: input.projectRoot,
133
+ route: provisional,
134
+ finding: input.finding,
135
+ config: input.config,
136
+ now,
137
+ });
138
+ const previewDigest = routeWritesPreviewDigest(inputDigest, plannedWrites.writes, plannedWrites.generationPlan?.planDigest);
139
+ const routeDigest = canonicalDigest({
140
+ id,
141
+ cycleId: input.cycleId,
142
+ inputDigest,
143
+ previewDigest,
144
+ workspaceDigest: routeWorkspace.workspaceDigest,
145
+ evidenceDigest: input.evidenceDigest,
146
+ providerDigest: input.providerDigest,
147
+ destinationDigest,
148
+ eventHead: input.eventHead,
149
+ actions: [action],
150
+ });
151
+ const route = {
152
+ kind: 'operating-route-plan',
153
+ schemaVersion: OPERATE_SCHEMA_VERSION,
154
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
155
+ id,
156
+ cycleId: input.cycleId,
157
+ inputDigest,
158
+ routeDigest,
159
+ previewDigest,
160
+ workspaceDigest: routeWorkspace.workspaceDigest,
161
+ evidenceDigest: input.evidenceDigest,
162
+ providerDigest: input.providerDigest,
163
+ destinationDigest,
164
+ eventHead: structuredClone(input.eventHead),
165
+ state: 'proposed',
166
+ actions: [action],
167
+ createdAt: now,
168
+ };
169
+ return assertOperatingArtifact('operating-route-plan', route);
170
+ }
171
+ async function assertRouteWorkspaceCurrent(input) {
172
+ const observed = await refreshOperatingWorkspaceManifest(input.projectRoot, {
173
+ localRoot: input.localRoot,
174
+ ignoredControlPaths: [...ROUTE_MANAGED_WORKSPACE_PATHS],
175
+ });
176
+ if (observed.workspaceDigest !== input.route.workspaceDigest) {
177
+ throw new OperateError('E_OPERATE_HEAD_DIVERGED', 'Workspace revisions, branches, remotes, or material dirty fingerprints changed after the route preview.', {
178
+ expectedWorkspaceDigest: input.route.workspaceDigest,
179
+ actualWorkspaceDigest: observed.workspaceDigest,
180
+ });
181
+ }
182
+ }
183
+ export async function readOperatingRoute(projectRoot, routeId) {
184
+ const target = path.join(resolveOperatingPaths(projectRoot).routes, `${routeId}.json`);
185
+ const route = JSON.parse(await readFile(target, 'utf8'));
186
+ return assertOperatingArtifact('operating-route-plan', route);
187
+ }
188
+ function localPlanningHandoffPath(projectRoot, routeId, localRoot) {
189
+ return path.join(resolveOperatingPaths(projectRoot, { localRoot }).localRoot, 'planning-handoffs', `${routeId}.json`);
190
+ }
191
+ async function readOperatingRuntime(projectRoot, localRoot) {
192
+ const target = path.join(resolveOperatingPaths(projectRoot, { localRoot }).localRoot, 'preferences.json');
193
+ try {
194
+ const parsed = JSON.parse(await readFile(target, 'utf8'));
195
+ return typeof parsed.runtime === 'string' && parsed.runtime.trim() ? parsed.runtime : 'auto';
196
+ }
197
+ catch {
198
+ return 'auto';
199
+ }
200
+ }
201
+ function validateStoredPlanningHandoff(value, expected) {
202
+ const handoff = value;
203
+ if (!handoff ||
204
+ handoff.kind !== 'operating-planning-handoff' ||
205
+ handoff.routeId !== expected.route.id ||
206
+ handoff.transactionId !== expected.transactionId ||
207
+ handoff.cycleId !== expected.route.cycleId ||
208
+ handoff.planningEngine !== expected.planningEngine ||
209
+ handoff.inputDigest !== expected.route.inputDigest ||
210
+ handoff.state !== 'awaiting-plan' ||
211
+ handoff.shipInvoked !== false ||
212
+ typeof handoff.specId !== 'string' ||
213
+ typeof handoff.feature !== 'string' ||
214
+ typeof handoff.targetPath !== 'string' ||
215
+ typeof handoff.runtime !== 'string' ||
216
+ typeof handoff.invocation !== 'string' ||
217
+ typeof handoff.createdAt !== 'string') {
218
+ throw new OperateError('E_OPERATE_PLANNER_CONFLICT', 'The machine-local planning handoff does not match the accepted route.');
219
+ }
220
+ if (expected.planningEngine === 'pipeline-po' &&
221
+ (!handoff.prepared ||
222
+ handoff.prepared.planningEngine !== 'pipeline-po' ||
223
+ canonicalDigest(handoff.prepared.prepared) !== handoff.prepared.preparedDigest ||
224
+ handoff.prepared.shipInvoked !== false)) {
225
+ throw new OperateError('E_OPERATE_PLANNER_CONFLICT', 'The pipeline PO handoff digest is invalid.');
226
+ }
227
+ if (expected.planningEngine === 'openplanr' && handoff.prepared !== null) {
228
+ throw new OperateError('E_OPERATE_PLANNER_CONFLICT', 'The OpenPlanr handoff contains an unexpected pipeline preparation.');
229
+ }
230
+ return handoff;
231
+ }
232
+ async function ensurePlanningHandoff(input) {
233
+ const action = input.route.actions[0];
234
+ const targetPath = path.posix.dirname(action.targetPath);
235
+ const specId = action.targetPath?.match(/(?:^|\/)(SPEC-\d+)(?:-|\/)/)?.[1];
236
+ if (!specId) {
237
+ throw new OperateError('E_OPERATE_TRANSACTION_INVALID', 'DEV route target does not encode a canonical SPEC id.');
238
+ }
239
+ const target = localPlanningHandoffPath(input.projectRoot, input.route.id, input.localRoot);
240
+ const existing = await readFile(target, 'utf8').catch((error) => {
241
+ if (error.code === 'ENOENT')
242
+ return null;
243
+ throw error;
244
+ });
245
+ if (existing) {
246
+ return validateStoredPlanningHandoff(JSON.parse(existing), {
247
+ route: input.route,
248
+ transactionId: input.transactionId,
249
+ planningEngine: input.config.planningEngine,
250
+ });
251
+ }
252
+ await assertPlanningProducer({
253
+ projectRoot: input.projectRoot,
254
+ targetPath,
255
+ selected: input.config.planningEngine,
256
+ });
257
+ const runtime = await readOperatingRuntime(input.projectRoot, input.localRoot);
258
+ const feature = slugify(String(input.finding.title ?? action.findingId));
259
+ const prepared = input.config.planningEngine === 'pipeline-po'
260
+ ? await preparePipelinePoHandoff({
261
+ bridge: await loadPipelinePoBridge(),
262
+ projectRoot: input.projectRoot,
263
+ feature,
264
+ runtime,
265
+ runId: `operate-${input.route.id.toLowerCase()}`,
266
+ targetPath,
267
+ })
268
+ : null;
269
+ const handoff = {
270
+ kind: 'operating-planning-handoff',
271
+ routeId: input.route.id,
272
+ transactionId: input.transactionId,
273
+ cycleId: input.route.cycleId,
274
+ specId,
275
+ feature,
276
+ targetPath,
277
+ planningEngine: input.config.planningEngine,
278
+ runtime,
279
+ invocation: prepared?.invocation ?? `planr spec decompose ${JSON.stringify(specId)}`,
280
+ state: 'awaiting-plan',
281
+ inputDigest: input.route.inputDigest,
282
+ prepared,
283
+ shipInvoked: false,
284
+ createdAt: new Date().toISOString(),
285
+ };
286
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
287
+ const temporary = `${target}.${process.pid}.${input.route.id}.tmp`;
288
+ await writeFile(temporary, `${canonicalize(handoff)}\n`, { mode: 0o600 });
289
+ await rename(temporary, target);
290
+ return handoff;
291
+ }
292
+ async function completePlanningHandoffIfReady(input) {
293
+ const inspection = await inspectPlanningProducer({
294
+ projectRoot: input.projectRoot,
295
+ targetPath: input.handoff.targetPath,
296
+ });
297
+ if (!inspection.populated)
298
+ return false;
299
+ await assertPlanningProducer({
300
+ projectRoot: input.projectRoot,
301
+ targetPath: input.handoff.targetPath,
302
+ selected: input.handoff.planningEngine,
303
+ });
304
+ if (input.handoff.planningEngine === 'pipeline-po') {
305
+ const completionRecorded = await hasPipelinePoCompletionProvenance({
306
+ projectRoot: input.projectRoot,
307
+ targetPath: input.handoff.targetPath,
308
+ runId: input.handoff.prepared?.runId ?? input.handoff.transactionId,
309
+ });
310
+ if (!completionRecorded) {
311
+ await completePipelinePoHandoff({
312
+ bridge: await loadPipelinePoBridge(),
313
+ projectRoot: input.projectRoot,
314
+ runtime: input.handoff.runtime,
315
+ handoff: input.handoff.prepared,
316
+ nativePlanCompleted: true,
317
+ });
318
+ }
319
+ await assertPlanningProducer({
320
+ projectRoot: input.projectRoot,
321
+ targetPath: input.handoff.targetPath,
322
+ selected: 'pipeline-po',
323
+ });
324
+ if (!(await hasPipelinePoCompletionProvenance({
325
+ projectRoot: input.projectRoot,
326
+ targetPath: input.handoff.targetPath,
327
+ runId: input.handoff.prepared?.runId ?? input.handoff.transactionId,
328
+ }))) {
329
+ throw new OperateError('E_OPERATE_PLANNER_CONFLICT', 'Pipeline PO completion did not record the expected route-bound provenance.');
330
+ }
331
+ }
332
+ return true;
333
+ }
334
+ function routeWritesPreviewDigest(inputDigest, writes, generationPlanDigest) {
335
+ return canonicalDigest({
336
+ inputDigest,
337
+ generationPlanDigest: generationPlanDigest ?? null,
338
+ writes: writes.map((write) => ({
339
+ path: write.relativePath,
340
+ operation: write.operation,
341
+ contentDigest: sha256Digest(write.content),
342
+ })),
343
+ });
344
+ }
345
+ async function routeWriteMaterialState(projectRoot, writes) {
346
+ const observed = await Promise.all(writes.map(async (write) => {
347
+ const target = await resolveContainedPath(projectRoot, write.relativePath);
348
+ const bytes = await readFile(target).catch((error) => {
349
+ if (error.code === 'ENOENT')
350
+ return null;
351
+ throw error;
352
+ });
353
+ if (!bytes)
354
+ return 'absent';
355
+ return sha256Digest(bytes) === sha256Digest(write.content)
356
+ ? 'exact'
357
+ : 'drift';
358
+ }));
359
+ if (observed.every((state) => state === 'absent'))
360
+ return 'absent';
361
+ if (observed.every((state) => state === 'exact'))
362
+ return 'exact';
363
+ throw new OperateError('E_OPERATE_ROUTE_DRIFT', 'A prepared route has partial or changed destination bytes; recover the journal before retrying.');
364
+ }
365
+ async function buildRouteWrites(input) {
366
+ const action = input.route.actions[0];
367
+ if (!action?.targetPath) {
368
+ throw new OperateError('E_OPERATE_TRANSACTION_INVALID', `Route ${input.route.id} has no target path.`);
369
+ }
370
+ const title = sanitizeGeneratedPlainText(String(input.finding.title ?? action.findingId));
371
+ const problem = sanitizeGeneratedPlainText(String(input.finding.problem ?? ''));
372
+ const proposal = sanitizeGeneratedPlainText(String(input.finding.proposal ?? ''));
373
+ if (action.kind === 'create-cycle-artifact') {
374
+ const generationPlan = createOperatingArtifactGenerationPlan({
375
+ cycleId: input.route.cycleId,
376
+ destination: action.targetPath,
377
+ evidenceRefs: action.evidenceRefs,
378
+ title,
379
+ proposal,
380
+ problem,
381
+ });
382
+ if (!input.artifactGeneration) {
383
+ return { writes: [], generationPlan };
384
+ }
385
+ if (input.artifactGeneration.planDigest !== generationPlan.planDigest) {
386
+ throw new OperateError('E_OPERATE_ROUTE_DRIFT', 'The generated artifact no longer matches the accepted generation contract.');
387
+ }
388
+ const writes = generatedArtifactWrites(input.artifactGeneration);
389
+ const committedSession = input.artifactGeneration
390
+ .session;
391
+ await assertOperatingArtifact('operating-artifact-session', committedSession);
392
+ return {
393
+ writes,
394
+ artifactSession: committedSession,
395
+ generationPlan,
396
+ };
397
+ }
398
+ if (action.kind === 'create-decision') {
399
+ return {
400
+ writes: [
401
+ {
402
+ relativePath: action.targetPath,
403
+ operation: 'create',
404
+ content: `${canonicalize({
405
+ id: input.route.id,
406
+ cycleId: input.route.cycleId,
407
+ findingId: action.findingId,
408
+ owner: action.owner,
409
+ question: problem,
410
+ recommendation: proposal,
411
+ evidenceRefs: action.evidenceRefs,
412
+ status: 'open',
413
+ })}\n`,
414
+ },
415
+ ],
416
+ };
417
+ }
418
+ const specId = action.targetPath.match(/(?:^|\/)(SPEC-\d+)(?:-|\/)/)?.[1];
419
+ if (!specId) {
420
+ throw new OperateError('E_OPERATE_TRANSACTION_INVALID', 'DEV route target does not encode a canonical SPEC id.');
421
+ }
422
+ const ordinal = specId.slice('SPEC-'.length);
423
+ const slug = slugify(title);
424
+ const nowDate = new Date(input.now);
425
+ const baselineTo = new Date(nowDate.getTime() - 1).toISOString();
426
+ const baselineFrom = new Date(nowDate.getTime() - 30 * 24 * 60 * 60 * 1_000).toISOString();
427
+ const targetFrom = nowDate.toISOString();
428
+ const targetTo = new Date(nowDate.getTime() + 30 * 24 * 60 * 60 * 1_000).toISOString();
429
+ const verifyAfter = new Date(nowDate.getTime() + 31 * 24 * 60 * 60 * 1_000)
430
+ .toISOString()
431
+ .slice(0, 10);
432
+ const sidecar = {
433
+ kind: 'operating-spec-link',
434
+ schemaVersion: OPERATE_SCHEMA_VERSION,
435
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
436
+ specId,
437
+ sourceCycle: input.route.cycleId,
438
+ sourceFinding: action.findingId,
439
+ planningEngine: input.config.planningEngine,
440
+ evidenceRefs: [...action.evidenceRefs],
441
+ outcome: {
442
+ kind: action.kind === 'create-instrumentation-spec' ? 'operational' : 'metric',
443
+ metric: `validated completion of ${slug}`,
444
+ unit: 'accepted-checks',
445
+ queryIdentity: `openplanr.operate.${input.route.id.toLowerCase()}.v1`,
446
+ direction: 'increase',
447
+ operator: 'gte',
448
+ aggregation: 'latest',
449
+ baselineWindow: { from: baselineFrom, to: baselineTo },
450
+ targetWindow: { from: targetFrom, to: targetTo },
451
+ threshold: { value: 1 },
452
+ minimumCoverage: 1,
453
+ minimumSample: 1,
454
+ stalePolicy: 'create-gap',
455
+ missingPolicy: 'create-gap',
456
+ guardrailPrecedence: 'block-on-breach',
457
+ source: 'openplanr-operating-review',
458
+ observationWindow: '30d',
459
+ verifyAfter,
460
+ },
461
+ guardrails: ['No security, privacy, payment-integrity, or tenant-isolation regression.'],
462
+ rollout: 'Implement through the reviewed PLAN artifact after owner acceptance.',
463
+ rollback: 'Roll back the implementation; preserve this operating decision and its evidence.',
464
+ };
465
+ await assertOperatingArtifact('operating-spec-link', sidecar);
466
+ const outcome = {
467
+ kind: 'operating-outcome',
468
+ schemaVersion: OPERATE_SCHEMA_VERSION,
469
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
470
+ id: `OUT-${ordinal}`,
471
+ sourceCycle: input.route.cycleId,
472
+ sourceFinding: action.findingId,
473
+ specId,
474
+ outcomeKind: sidecar.outcome.kind,
475
+ metric: sidecar.outcome.metric,
476
+ unit: sidecar.outcome.unit,
477
+ queryIdentity: sidecar.outcome.queryIdentity,
478
+ direction: sidecar.outcome.direction,
479
+ operator: sidecar.outcome.operator,
480
+ aggregation: sidecar.outcome.aggregation,
481
+ baselineWindow: sidecar.outcome.baselineWindow,
482
+ targetWindow: sidecar.outcome.targetWindow,
483
+ threshold: sidecar.outcome.threshold,
484
+ minimumCoverage: sidecar.outcome.minimumCoverage,
485
+ minimumSample: sidecar.outcome.minimumSample,
486
+ stalePolicy: sidecar.outcome.stalePolicy,
487
+ missingPolicy: sidecar.outcome.missingPolicy,
488
+ guardrailPrecedence: sidecar.outcome.guardrailPrecedence,
489
+ guardrails: [],
490
+ source: sidecar.outcome.source,
491
+ observationWindow: sidecar.outcome.observationWindow,
492
+ verifyAfter: sidecar.outcome.verifyAfter,
493
+ rollout: sidecar.rollout,
494
+ rollback: sidecar.rollback,
495
+ status: 'pending',
496
+ evidenceRefs: [...action.evidenceRefs],
497
+ createdAt: input.now,
498
+ updatedAt: input.now,
499
+ };
500
+ await assertOperatingArtifact('operating-outcome', outcome);
501
+ const spec = [
502
+ '---',
503
+ `id: ${JSON.stringify(specId)}`,
504
+ `title: ${JSON.stringify(title)}`,
505
+ `slug: ${JSON.stringify(slug)}`,
506
+ 'schemaVersion: "1.0.0"',
507
+ 'status: "shaping"',
508
+ 'priority: "P1"',
509
+ `created: ${JSON.stringify(input.now.slice(0, 10))}`,
510
+ `updated: ${JSON.stringify(input.now.slice(0, 10))}`,
511
+ 'ui_files: []',
512
+ 'tech_dependencies: []',
513
+ '---',
514
+ '',
515
+ `# ${specId} — ${title}`,
516
+ '',
517
+ '## Context',
518
+ problem,
519
+ '',
520
+ '## Proposed outcome',
521
+ proposal,
522
+ '',
523
+ '## Functional requirements',
524
+ '- Preserve current behavior outside the explicitly reviewed scope.',
525
+ '- Implement the evidence-backed proposal and satisfy the outcome contract.',
526
+ '',
527
+ '## Evidence',
528
+ ...action.evidenceRefs.map((reference) => `- ${reference}`),
529
+ '',
530
+ '## Acceptance criteria',
531
+ `- The implementation satisfies ${sidecar.outcome.metric}.`,
532
+ '- No listed operating guardrail regresses.',
533
+ '- PLAN artifacts receive human review before any SHIP invocation.',
534
+ '',
535
+ '## Preserve',
536
+ '- Existing hand-written project instructions and unrelated planning artifacts.',
537
+ '',
538
+ ].join('\n');
539
+ const handoff = {
540
+ operationId: input.route.id,
541
+ cycleId: input.route.cycleId,
542
+ findingId: action.findingId,
543
+ specId,
544
+ planningEngine: input.config.planningEngine,
545
+ state: 'awaiting-plan',
546
+ invocation: input.config.planningEngine === 'pipeline-po'
547
+ ? `planr pipeline plan ${JSON.stringify(slug)} --runtime auto`
548
+ : `planr spec decompose ${specId}`,
549
+ pipelinePreparation: {
550
+ required: input.config.planningEngine === 'pipeline-po',
551
+ api: input.config.planningEngine === 'pipeline-po' ? 'preparePlan' : null,
552
+ completeApi: input.config.planningEngine === 'pipeline-po' ? 'completePlan' : null,
553
+ },
554
+ shipInvoked: false,
555
+ evidenceRefs: action.evidenceRefs,
556
+ inputDigest: input.route.inputDigest,
557
+ };
558
+ return {
559
+ writes: [
560
+ { relativePath: action.targetPath, operation: 'create', content: spec },
561
+ {
562
+ relativePath: `.planr/operate/spec-links/${specId}.json`,
563
+ operation: 'create',
564
+ content: `${canonicalize(sidecar)}\n`,
565
+ },
566
+ {
567
+ relativePath: `.planr/operate/outcomes/OUT-${ordinal}.json`,
568
+ operation: 'create',
569
+ content: `${canonicalize(outcome)}\n`,
570
+ },
571
+ {
572
+ relativePath: `.planr/operate/handoffs/${input.route.id}.json`,
573
+ operation: 'create',
574
+ content: `${canonicalize(handoff)}\n`,
575
+ },
576
+ ],
577
+ specLink: sidecar,
578
+ outcome,
579
+ };
580
+ }
581
+ export async function applyOperatingRoute(input) {
582
+ const isAgentArtifact = input.route.actions[0]?.kind === 'create-cycle-artifact';
583
+ const existingGeneration = isAgentArtifact
584
+ ? await readStoredOperatingArtifactGeneration({
585
+ projectRoot: input.projectRoot,
586
+ localRoot: input.localRoot,
587
+ route: input.route,
588
+ })
589
+ : null;
590
+ const acceptedApplyDigest = input.confirmationDigest === input.route.previewDigest ||
591
+ (existingGeneration?.state === 'generated' &&
592
+ input.confirmationDigest === existingGeneration.exactPreviewDigest);
593
+ if (!acceptedApplyDigest) {
594
+ throw new OperateError('E_OPERATE_ROUTE_CONFIRMATION_REQUIRED', 'Route application requires confirmation of the exact preview digest.', {
595
+ previewDigest: existingGeneration?.state === 'generated'
596
+ ? existingGeneration.exactPreviewDigest
597
+ : input.route.previewDigest,
598
+ });
599
+ }
600
+ const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
601
+ const initial = await store.replay();
602
+ const initialState = await store.state();
603
+ const projectedRoute = initialState.routes.find((route) => route.id === input.route.id);
604
+ const finding = initialState.findings.find((candidate) => candidate.id === input.route.actions[0]?.findingId);
605
+ if (!finding) {
606
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Route ${input.route.id} references an unknown finding.`);
607
+ }
608
+ const acceptedConfirmation = projectedRoute?.confirmationDigest;
609
+ if (projectedRoute?.state === 'applied' && projectedRoute.transactionId) {
610
+ return {
611
+ transactionId: String(projectedRoute.transactionId),
612
+ eventHead: initial.eventHead,
613
+ state: 'applied',
614
+ shipInvoked: false,
615
+ };
616
+ }
617
+ if (!['accepted', 'prepared'].includes(String(projectedRoute?.state)) ||
618
+ typeof acceptedConfirmation !== 'string') {
619
+ throw new OperateError('E_OPERATE_ROUTE_CONFIRMATION_REQUIRED', 'Route must be accepted or recoverably prepared before it can be applied.');
620
+ }
621
+ return withOperatingLock(input.projectRoot, {
622
+ projectKey: operatingProjectKey(input.projectRoot),
623
+ expectedEventHead: initial.eventHead,
624
+ currentEventHead: initial.eventHead,
625
+ localRoot: input.localRoot,
626
+ }, async (lock) => {
627
+ await assertRouteWorkspaceCurrent(input);
628
+ let preparedHead = initial.eventHead;
629
+ if (projectedRoute?.state === 'accepted') {
630
+ const preparedEvent = await store.append({
631
+ type: 'route.prepared',
632
+ cycleId: input.route.cycleId,
633
+ entityId: input.route.id,
634
+ payload: {
635
+ routeDigest: input.route.routeDigest,
636
+ previewDigest: input.route.previewDigest,
637
+ },
638
+ expectedHead: initial.eventHead.hash,
639
+ });
640
+ preparedHead = {
641
+ sequence: preparedEvent.sequence,
642
+ hash: preparedEvent.eventHash,
643
+ };
644
+ await lock.advanceEventHead(initial.eventHead, preparedHead);
645
+ }
646
+ let committedTransaction = null;
647
+ let routeApplied = false;
648
+ let bytesCommitted = false;
649
+ try {
650
+ let artifactGeneration = isAgentArtifact
651
+ ? await readStoredOperatingArtifactGeneration({
652
+ projectRoot: input.projectRoot,
653
+ localRoot: input.localRoot,
654
+ route: input.route,
655
+ })
656
+ : null;
657
+ let built = await buildRouteWrites({
658
+ projectRoot: input.projectRoot,
659
+ route: input.route,
660
+ finding,
661
+ config: input.config,
662
+ now: input.route.createdAt,
663
+ ...(artifactGeneration?.state === 'generated' ? { artifactGeneration } : {}),
664
+ });
665
+ const plannedDigest = routeWritesPreviewDigest(input.route.inputDigest, built.writes, built.generationPlan?.planDigest);
666
+ if (!built.generationPlan && plannedDigest !== input.route.previewDigest) {
667
+ throw new OperateError('E_OPERATE_ROUTE_DRIFT', 'The exact route write set no longer matches the accepted preview.');
668
+ }
669
+ if (built.generationPlan) {
670
+ const initialGenerationDigest = routeWritesPreviewDigest(input.route.inputDigest, [], built.generationPlan.planDigest);
671
+ if (initialGenerationDigest !== input.route.previewDigest) {
672
+ throw new OperateError('E_OPERATE_ROUTE_DRIFT', 'The artifact generation contract no longer matches the accepted route.');
673
+ }
674
+ if (artifactGeneration?.state !== 'generated') {
675
+ artifactGeneration = await generateOperatingRouteArtifact({
676
+ projectRoot: input.projectRoot,
677
+ localRoot: input.localRoot,
678
+ route: input.route,
679
+ plan: built.generationPlan,
680
+ adapter: input.artifactGenerator ??
681
+ (await resolveOperatingArtifactGenerator({
682
+ projectRoot: input.projectRoot,
683
+ route: input.route,
684
+ localRoot: input.localRoot,
685
+ })),
686
+ now: input.route.createdAt,
687
+ onAttemptFailed: async () => {
688
+ await input.faultInjector?.('artifact-attempt-failed');
689
+ },
690
+ });
691
+ await input.faultInjector?.('artifact-generated');
692
+ built = await buildRouteWrites({
693
+ projectRoot: input.projectRoot,
694
+ route: input.route,
695
+ finding,
696
+ config: input.config,
697
+ now: input.route.createdAt,
698
+ artifactGeneration,
699
+ });
700
+ }
701
+ if (artifactGeneration.state !== 'generated' || !artifactGeneration.exactPreviewDigest) {
702
+ throw new OperateError('E_OPERATE_ARTIFACT_REJECTED', 'The AGENT artifact is not ready for exact-byte review.');
703
+ }
704
+ const exactDigest = routeWritesPreviewDigest(input.route.inputDigest, built.writes, built.generationPlan?.planDigest);
705
+ if (exactDigest !== artifactGeneration.exactPreviewDigest ||
706
+ input.confirmationDigest !== artifactGeneration.exactPreviewDigest) {
707
+ const preparedState = await store.state();
708
+ await store.writeCheckpoint(preparedState);
709
+ await persistOperatingProjections({
710
+ projectRoot: input.projectRoot,
711
+ localRoot: input.localRoot,
712
+ state: preparedState,
713
+ revalidateEventHead: async () => (await store.replay()).eventHead,
714
+ });
715
+ return {
716
+ eventHead: preparedHead,
717
+ state: 'awaiting-artifact-review',
718
+ previewDigest: artifactGeneration.exactPreviewDigest,
719
+ artifact: {
720
+ destination: artifactGeneration.session.destination,
721
+ content: artifactGeneration.content,
722
+ outputDigest: artifactGeneration.session.outputDigest,
723
+ attempts: artifactGeneration.attempts,
724
+ },
725
+ shipInvoked: false,
726
+ };
727
+ }
728
+ }
729
+ await assertRouteWorkspaceCurrent(input);
730
+ const transactionPreviewDigest = isAgentArtifact && artifactGeneration?.state === 'generated'
731
+ ? artifactGeneration.exactPreviewDigest
732
+ : input.route.previewDigest;
733
+ const transactionId = `TXN-${input.route.id}-${transactionPreviewDigest.slice(7, 23)}`;
734
+ const materialState = await routeWriteMaterialState(input.projectRoot, built.writes);
735
+ const transaction = materialState === 'exact'
736
+ ? {
737
+ root: path.join(resolveOperatingPaths(input.projectRoot, {
738
+ localRoot: input.localRoot,
739
+ }).transactions, transactionId),
740
+ manifestPath: path.join(resolveOperatingPaths(input.projectRoot, {
741
+ localRoot: input.localRoot,
742
+ }).transactions, transactionId, 'journal.json'),
743
+ record: await readJournal(path.join(resolveOperatingPaths(input.projectRoot, {
744
+ localRoot: input.localRoot,
745
+ }).transactions, transactionId, 'journal.json')),
746
+ }
747
+ : await prepareJournalTransaction(input.projectRoot, {
748
+ writes: built.writes,
749
+ eventHead: preparedHead,
750
+ previewDigest: transactionPreviewDigest,
751
+ transactionId,
752
+ localRoot: input.localRoot,
753
+ });
754
+ if (materialState === 'exact' && transaction.record.state !== 'committed') {
755
+ throw new OperateError('E_OPERATE_TRANSACTION_INVALID', 'Prepared route bytes exist without a committed journal.');
756
+ }
757
+ if (materialState === 'absent') {
758
+ await applyJournalTransaction(input.projectRoot, transaction, {
759
+ currentEventHead: preparedHead,
760
+ revalidateEventHead: async () => (await store.replay()).eventHead,
761
+ });
762
+ }
763
+ committedTransaction = transaction;
764
+ bytesCommitted = true;
765
+ await input.faultInjector?.('bytes-committed');
766
+ if (built.specLink) {
767
+ const handoff = await ensurePlanningHandoff({
768
+ projectRoot: input.projectRoot,
769
+ localRoot: input.localRoot,
770
+ route: input.route,
771
+ config: input.config,
772
+ finding,
773
+ transactionId: transaction.record.transactionId,
774
+ });
775
+ const planCompleted = await completePlanningHandoffIfReady({
776
+ projectRoot: input.projectRoot,
777
+ handoff,
778
+ });
779
+ if (!planCompleted) {
780
+ const preparedState = await store.state();
781
+ await store.writeCheckpoint(preparedState);
782
+ await persistOperatingProjections({
783
+ projectRoot: input.projectRoot,
784
+ localRoot: input.localRoot,
785
+ state: preparedState,
786
+ revalidateEventHead: async () => (await store.replay()).eventHead,
787
+ });
788
+ return {
789
+ transactionId: transaction.record.transactionId,
790
+ eventHead: preparedHead,
791
+ state: 'awaiting-plan',
792
+ invocation: handoff.invocation,
793
+ shipInvoked: false,
794
+ };
795
+ }
796
+ }
797
+ let finalHead = preparedHead;
798
+ const refreshedState = await store.state();
799
+ if (built.specLink &&
800
+ !refreshedState.specLinks.some((link) => link.specId === built.specLink?.specId)) {
801
+ const linked = await store.append({
802
+ type: 'spec.linked',
803
+ cycleId: input.route.cycleId,
804
+ entityId: built.specLink.specId,
805
+ evidenceRefs: built.specLink.evidenceRefs,
806
+ payload: { record: built.specLink },
807
+ expectedHead: finalHead.hash,
808
+ });
809
+ const linkedHead = {
810
+ sequence: linked.sequence,
811
+ hash: linked.eventHash,
812
+ };
813
+ await lock.advanceEventHead(finalHead, linkedHead);
814
+ finalHead = linkedHead;
815
+ await input.faultInjector?.('spec-linked');
816
+ }
817
+ const stateBeforeOutcome = await store.state();
818
+ if (built.outcome &&
819
+ !stateBeforeOutcome.outcomes.some((outcome) => outcome.id === built.outcome?.id)) {
820
+ const registered = await store.append({
821
+ type: 'outcome.registered',
822
+ cycleId: input.route.cycleId,
823
+ entityId: built.outcome.id,
824
+ evidenceRefs: built.outcome.evidenceRefs,
825
+ payload: { record: built.outcome },
826
+ expectedHead: finalHead.hash,
827
+ });
828
+ const registeredHead = {
829
+ sequence: registered.sequence,
830
+ hash: registered.eventHash,
831
+ };
832
+ await lock.advanceEventHead(finalHead, registeredHead);
833
+ finalHead = registeredHead;
834
+ await input.faultInjector?.('outcome-registered');
835
+ }
836
+ const replayBeforeArtifact = await store.replay();
837
+ if (built.artifactSession &&
838
+ !replayBeforeArtifact.events.some((event) => event.type === 'artifact.created' && event.entityId === built.artifactSession?.id)) {
839
+ const artifactRecord = await store.putRecord('artifact-manifest', built.artifactSession, {
840
+ correlationId: input.route.id,
841
+ createdAt: input.route.createdAt,
842
+ });
843
+ const created = await store.append({
844
+ type: 'artifact.created',
845
+ cycleId: input.route.cycleId,
846
+ entityId: built.artifactSession.id,
847
+ evidenceRefs: built.artifactSession.evidenceRefs,
848
+ payload: { recordDigest: artifactRecord.digest },
849
+ expectedHead: finalHead.hash,
850
+ });
851
+ const createdHead = {
852
+ sequence: created.sequence,
853
+ hash: created.eventHash,
854
+ };
855
+ await lock.advanceEventHead(finalHead, createdHead);
856
+ finalHead = createdHead;
857
+ await input.faultInjector?.('artifact-created');
858
+ }
859
+ const applied = await store.append({
860
+ type: 'route.applied',
861
+ cycleId: input.route.cycleId,
862
+ entityId: input.route.id,
863
+ payload: {
864
+ routeDigest: input.route.routeDigest,
865
+ confirmationDigest: acceptedConfirmation,
866
+ transactionId: transaction.record.transactionId,
867
+ },
868
+ expectedHead: finalHead.hash,
869
+ });
870
+ const appliedHead = {
871
+ sequence: applied.sequence,
872
+ hash: applied.eventHash,
873
+ };
874
+ await lock.advanceEventHead(finalHead, appliedHead);
875
+ finalHead = appliedHead;
876
+ routeApplied = true;
877
+ const appliedState = await store.state();
878
+ await store.writeCheckpoint(appliedState);
879
+ await persistOperatingProjections({
880
+ projectRoot: input.projectRoot,
881
+ localRoot: input.localRoot,
882
+ state: appliedState,
883
+ revalidateEventHead: async () => (await store.replay()).eventHead,
884
+ });
885
+ return {
886
+ transactionId: transaction.record.transactionId,
887
+ eventHead: finalHead,
888
+ state: 'applied',
889
+ shipInvoked: false,
890
+ };
891
+ }
892
+ catch (error) {
893
+ if (committedTransaction && !bytesCommitted && !routeApplied) {
894
+ await rollbackJournalTransaction(input.projectRoot, committedTransaction).catch(() => undefined);
895
+ }
896
+ if (routeApplied || bytesCommitted || isAgentArtifact) {
897
+ const interruptedState = await store.state();
898
+ await store.writeCheckpoint(interruptedState).catch(() => undefined);
899
+ await persistOperatingProjections({
900
+ projectRoot: input.projectRoot,
901
+ localRoot: input.localRoot,
902
+ state: interruptedState,
903
+ revalidateEventHead: async () => (await store.replay()).eventHead,
904
+ }).catch(() => undefined);
905
+ throw error;
906
+ }
907
+ const current = (await store.replay()).eventHead;
908
+ const errorCode = error instanceof OperateError ? error.code : 'E_OPERATE_TRANSACTION_INVALID';
909
+ const failed = await store.append({
910
+ type: 'route.failed',
911
+ cycleId: input.route.cycleId,
912
+ entityId: input.route.id,
913
+ payload: {
914
+ routeDigest: input.route.routeDigest,
915
+ errorCode,
916
+ },
917
+ expectedHead: current.hash,
918
+ });
919
+ const failedHead = {
920
+ sequence: failed.sequence,
921
+ hash: failed.eventHash,
922
+ };
923
+ await lock.advanceEventHead(current, failedHead);
924
+ const failedState = await store.state();
925
+ await store.writeCheckpoint(failedState);
926
+ await persistOperatingProjections({
927
+ projectRoot: input.projectRoot,
928
+ localRoot: input.localRoot,
929
+ state: failedState,
930
+ revalidateEventHead: async () => (await store.replay()).eventHead,
931
+ });
932
+ throw error;
933
+ }
934
+ });
935
+ }
936
+ export async function rollbackOperatingRoute(input) {
937
+ const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
938
+ const initial = await store.replay();
939
+ return withOperatingLock(input.projectRoot, {
940
+ projectKey: operatingProjectKey(input.projectRoot),
941
+ expectedEventHead: initial.eventHead,
942
+ currentEventHead: initial.eventHead,
943
+ localRoot: input.localRoot,
944
+ }, async (lock) => {
945
+ const state = await store.state();
946
+ const projected = state.routes.find((route) => route.id === input.route.id);
947
+ if (!projected ||
948
+ !['applied', 'failed'].includes(projected.state) ||
949
+ projected.transactionId !== input.transactionId) {
950
+ throw new OperateError('E_OPERATE_ROUTE_DRIFT', 'The route projection no longer matches the rollback transaction.');
951
+ }
952
+ const paths = resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot });
953
+ const root = path.join(paths.transactions, input.transactionId);
954
+ const manifestPath = path.join(root, 'journal.json');
955
+ const record = await readJournal(manifestPath);
956
+ const revalidated = await store.replay();
957
+ if (revalidated.eventHead.sequence !== initial.eventHead.sequence ||
958
+ revalidated.eventHead.hash !== initial.eventHead.hash) {
959
+ throw new OperateError('E_OPERATE_HEAD_DIVERGED', 'Operating state changed before route rollback.');
960
+ }
961
+ await rollbackJournalTransaction(input.projectRoot, { root, manifestPath, record });
962
+ const event = await store.append({
963
+ type: 'route.rolled_back',
964
+ cycleId: input.route.cycleId,
965
+ entityId: input.route.id,
966
+ payload: {
967
+ routeDigest: input.route.routeDigest,
968
+ recoveryId: input.recoveryId,
969
+ },
970
+ expectedHead: initial.eventHead.hash,
971
+ });
972
+ const next = { sequence: event.sequence, hash: event.eventHash };
973
+ await lock.advanceEventHead(initial.eventHead, next);
974
+ const rolledBackState = await store.state();
975
+ await store.writeCheckpoint(rolledBackState);
976
+ await persistOperatingProjections({
977
+ projectRoot: input.projectRoot,
978
+ localRoot: input.localRoot,
979
+ state: rolledBackState,
980
+ revalidateEventHead: async () => (await store.replay()).eventHead,
981
+ });
982
+ return next;
983
+ });
984
+ }
985
+ export async function routeDestinationDigest(projectRoot, route) {
986
+ const destinations = await Promise.all(routeDestinationPaths(route).map(async (relativePath) => {
987
+ const target = await resolveContainedPath(projectRoot, relativePath);
988
+ const content = await readFile(target).catch(() => null);
989
+ return {
990
+ path: relativePath,
991
+ beforeDigest: content ? sha256Digest(content) : null,
992
+ };
993
+ }));
994
+ return canonicalDigest(destinations);
995
+ }
996
+ //# sourceMappingURL=routes.js.map