@6reduk/workspace-pipeline 0.1.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/docs/collaboration.md +59 -0
  4. package/docs/config-fields.md +65 -0
  5. package/docs/contracts.md +149 -0
  6. package/docs/doctor.md +165 -0
  7. package/docs/launch.md +50 -0
  8. package/docs/lifecycle-cli.md +141 -0
  9. package/docs/lifecycle.md +42 -0
  10. package/docs/migrations/unity.md +288 -0
  11. package/docs/native-provider-format.md +149 -0
  12. package/docs/provider-bundles.md +81 -0
  13. package/docs/provider-resources.md +37 -0
  14. package/docs/release.md +25 -0
  15. package/docs/remove.md +70 -0
  16. package/docs/repair.md +80 -0
  17. package/docs/repositories.md +209 -0
  18. package/docs/repository-manual-recovery.md +102 -0
  19. package/docs/repository-observations.md +29 -0
  20. package/docs/repository-recovery.md +204 -0
  21. package/docs/repository-retention.md +46 -0
  22. package/docs/repository-transport-budgets.md +26 -0
  23. package/docs/retention.md +237 -0
  24. package/docs/source.md +50 -0
  25. package/docs/switch.md +412 -0
  26. package/package.json +39 -0
  27. package/schemas/common.schema.json +251 -0
  28. package/schemas/inventory.schema.json +15 -0
  29. package/schemas/operation.schema.json +286 -0
  30. package/schemas/pipeline.schema.json +317 -0
  31. package/schemas/state.schema.json +302 -0
  32. package/schemas/workspace.schema.json +67 -0
  33. package/src/cli.js +7 -0
  34. package/src/commands/adopt.js +2 -0
  35. package/src/commands/bootstrap-recovery.js +90 -0
  36. package/src/commands/dispatch.js +394 -0
  37. package/src/commands/init.js +84 -0
  38. package/src/commands/launch.js +69 -0
  39. package/src/commands/migration-apply.js +53 -0
  40. package/src/commands/migration.js +50 -0
  41. package/src/commands/repositories.js +61 -0
  42. package/src/commands/repository-abandon.js +16 -0
  43. package/src/commands/repository-ancestors.js +21 -0
  44. package/src/commands/repository-locks.js +77 -0
  45. package/src/contracts/parse.js +57 -0
  46. package/src/contracts/semantic.js +240 -0
  47. package/src/contracts/validate.js +21 -0
  48. package/src/launch/grok.js +20 -0
  49. package/src/migrations/legacy-unity-begin.js +35 -0
  50. package/src/migrations/legacy-unity-compensate.js +82 -0
  51. package/src/migrations/legacy-unity-deactivate.js +60 -0
  52. package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
  53. package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
  54. package/src/migrations/legacy-unity-finalize.js +88 -0
  55. package/src/migrations/legacy-unity-install-recovery.js +76 -0
  56. package/src/migrations/legacy-unity-install-resume.js +60 -0
  57. package/src/migrations/legacy-unity-install.js +88 -0
  58. package/src/migrations/legacy-unity-lease.js +136 -0
  59. package/src/migrations/legacy-unity-preflight.js +67 -0
  60. package/src/migrations/legacy-unity-preview.js +91 -0
  61. package/src/migrations/legacy-unity-resume-apply.js +39 -0
  62. package/src/migrations/legacy-unity-resume.js +41 -0
  63. package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
  64. package/src/migrations/legacy-unity.js +99 -0
  65. package/src/operations/apply.js +576 -0
  66. package/src/operations/backup.js +94 -0
  67. package/src/operations/bootstrap-lock.js +87 -0
  68. package/src/operations/bootstrap-owner-retirement.js +121 -0
  69. package/src/operations/bundle-update.js +54 -0
  70. package/src/operations/config-fields.js +24 -0
  71. package/src/operations/continuation-lifecycle.js +77 -0
  72. package/src/operations/doctor.js +214 -0
  73. package/src/operations/history.js +170 -0
  74. package/src/operations/installer-identity.js +49 -0
  75. package/src/operations/journal.js +142 -0
  76. package/src/operations/lifecycle.js +133 -0
  77. package/src/operations/lineage-guard.js +20 -0
  78. package/src/operations/lock.js +109 -0
  79. package/src/operations/maintenance.js +62 -0
  80. package/src/operations/migration-pending.js +22 -0
  81. package/src/operations/ownership.js +122 -0
  82. package/src/operations/plan.js +278 -0
  83. package/src/operations/reconciliation.js +112 -0
  84. package/src/operations/recovery-lease.js +66 -0
  85. package/src/operations/remove.js +140 -0
  86. package/src/operations/repair.js +188 -0
  87. package/src/operations/repository-abandon.js +192 -0
  88. package/src/operations/repository-ancestors.js +158 -0
  89. package/src/operations/repository-apply.js +122 -0
  90. package/src/operations/repository-authorization.js +48 -0
  91. package/src/operations/repository-bootstrap-continuation.js +190 -0
  92. package/src/operations/repository-bootstrap-reconcile.js +106 -0
  93. package/src/operations/repository-bootstrap-recover.js +114 -0
  94. package/src/operations/repository-bootstrap.js +82 -0
  95. package/src/operations/repository-clone.js +63 -0
  96. package/src/operations/repository-history.js +108 -0
  97. package/src/operations/repository-inputs.js +41 -0
  98. package/src/operations/repository-journal.js +127 -0
  99. package/src/operations/repository-lock-reconcile.js +401 -0
  100. package/src/operations/repository-pending.js +29 -0
  101. package/src/operations/repository-reconcile.js +182 -0
  102. package/src/operations/repository-resumption-approvals.js +77 -0
  103. package/src/operations/repository-retention-apply.js +75 -0
  104. package/src/operations/repository-retention.js +137 -0
  105. package/src/operations/repository-workspace.js +78 -0
  106. package/src/operations/retention-apply.js +133 -0
  107. package/src/operations/retention-combined-scan.js +30 -0
  108. package/src/operations/retention-combined.js +41 -0
  109. package/src/operations/retention-policy.js +65 -0
  110. package/src/operations/retention-receipts.js +126 -0
  111. package/src/operations/retention-scan.js +86 -0
  112. package/src/operations/retention.js +56 -0
  113. package/src/operations/state.js +210 -0
  114. package/src/operations/switch-activate.js +64 -0
  115. package/src/operations/switch-backups.js +29 -0
  116. package/src/operations/switch-continuation-journal.js +94 -0
  117. package/src/operations/switch-continuation-pending.js +60 -0
  118. package/src/operations/switch-continuation-records.js +109 -0
  119. package/src/operations/switch-continuation-recovery.js +91 -0
  120. package/src/operations/switch-continuation-runtime.js +135 -0
  121. package/src/operations/switch-continuation-store.js +120 -0
  122. package/src/operations/switch-continuation.js +76 -0
  123. package/src/operations/switch-execute.js +68 -0
  124. package/src/operations/switch-inspect.js +45 -0
  125. package/src/operations/switch-journal-store.js +109 -0
  126. package/src/operations/switch-journal.js +71 -0
  127. package/src/operations/switch-lifecycle.js +72 -0
  128. package/src/operations/switch-pending.js +43 -0
  129. package/src/operations/switch-preflight.js +73 -0
  130. package/src/operations/switch-prepare.js +75 -0
  131. package/src/operations/switch-records.js +60 -0
  132. package/src/operations/switch-recovery-store.js +74 -0
  133. package/src/operations/switch.js +52 -0
  134. package/src/operations/toml-fields.js +133 -0
  135. package/src/providers/bundles.js +42 -0
  136. package/src/providers/common-entry.js +16 -0
  137. package/src/providers/grok.js +26 -0
  138. package/src/providers/interface.js +25 -0
  139. package/src/providers/kimi.js +26 -0
  140. package/src/providers/native.js +155 -0
  141. package/src/providers/registry.js +10 -0
  142. package/src/providers/shared.js +51 -0
  143. package/src/providers/source.js +30 -0
  144. package/src/source/git.js +303 -0
  145. package/src/source/inventory.js +87 -0
  146. package/src/source/repository-budget.js +18 -0
  147. package/src/source/snapshot.js +37 -0
  148. package/src/workspace/paths.js +54 -0
  149. package/src/workspace/profiles.js +8 -0
  150. package/src/workspace/repositories.js +57 -0
  151. package/src/workspace/repository-inventory.js +77 -0
  152. package/src/workspace/repository-observation.js +38 -0
  153. package/src/workspace/repository-preflight.js +129 -0
  154. package/src/workspace/repository-preview.js +196 -0
  155. package/src/workspace/repository-tree.js +57 -0
  156. package/src/workspace/reserved.js +11 -0
  157. package/src/workspace/resolve.js +53 -0
@@ -0,0 +1,240 @@
1
+ import { posix, win32 } from 'node:path';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { createHash } from 'node:crypto';
4
+ import { fail } from './parse.js';
5
+ import { validateStructure } from './validate.js';
6
+ import { requiredCapabilities, assertAdapter } from '../providers/interface.js';
7
+ import { resolveProviders, validateInstalledBundles } from '../providers/bundles.js';
8
+
9
+ const has = (o, k) => Object.hasOwn(o, k);
10
+ export function portablePath(value) {
11
+ if (typeof value !== 'string' || !value || value.length > 240 || !/^[A-Za-z0-9_. /-]+$/.test(value)) fail('path.invalid');
12
+ for (const part of value.split('/')) {
13
+ if (!part || part.length > 100 || part === '.' || part === '..' || /[. ]$/.test(part) ||
14
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part) || part.toLowerCase() === '.git') fail('path.invalid');
15
+ }
16
+ return value;
17
+ }
18
+ function machinePath(value) {
19
+ if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/.test(value) ||
20
+ !(posix.isAbsolute(value) || win32.isAbsolute(value))) fail('state.absolute-path');
21
+ }
22
+ export function validateLayoutReferences(layout) {
23
+ for (const ref of [layout.documentation, ...Object.values(layout.projectRoots ?? {})]) {
24
+ if (!has(layout.repositories, ref.repository)) fail('layout.repository');
25
+ if (ref.path !== '.') portablePath(ref.path);
26
+ }
27
+ for (const repo of Object.values(layout.repositories)) {
28
+ portablePath(repo.path);
29
+ if (repo.source && repo.source.subdirectory !== '.') fail('source.clone-subdirectory');
30
+ }
31
+ }
32
+ export function validateInventory(inventory, { inventoryPath, manifestPath }) {
33
+ validateStructure('inventory', inventory);
34
+ portablePath(inventoryPath); portablePath(manifestPath);
35
+ const seen = new Set();
36
+ for (const name of Object.keys(inventory)) {
37
+ portablePath(name);
38
+ const key = name.toLowerCase();
39
+ if (key === inventoryPath.toLowerCase()) fail('inventory.self');
40
+ if (seen.has(key)) fail('inventory.collision');
41
+ seen.add(key);
42
+ }
43
+ if (!has(inventory, manifestPath)) fail('inventory.manifest');
44
+ // Blob contents / missing and extra physical files / digest verification belong to S2.
45
+ return inventory;
46
+ }
47
+ export function validateBundle(pipeline, workspace, adapters) {
48
+ validateStructure('pipeline', pipeline); validateStructure('workspace', workspace);
49
+ if (workspace.pipeline.subdirectory !== '.') portablePath(workspace.pipeline.subdirectory);
50
+ portablePath(pipeline.resources); portablePath(pipeline.inventory);
51
+ for (const decl of Object.values(pipeline.providers)) {
52
+ requiredCapabilities(decl);
53
+ for (const key of ['skills', 'agents', 'mcp', 'entryInstructions'])
54
+ if (decl[key] !== null) portablePath(decl[key]);
55
+ }
56
+ for (const profile of Object.values(pipeline.workspaceProfiles ?? {})) validateLayoutReferences(profile);
57
+ let layout = workspace.layout;
58
+ if (workspace.profile !== undefined) {
59
+ if (!has(pipeline.workspaceProfiles ?? {}, workspace.profile)) fail('layout.profile');
60
+ layout = pipeline.workspaceProfiles[workspace.profile];
61
+ }
62
+ validateLayoutReferences(layout);
63
+ const selection = resolveProviders(pipeline, workspace);
64
+ for (const bundle of Object.values(pipeline.bundles ?? {})) portablePath(bundle.entry.source);
65
+ for (const id of selection.providers) {
66
+ if (!has(pipeline.providers, id)) fail('provider.absent');
67
+ if (adapters) assertAdapter(adapters[id], id, pipeline.providers[id]);
68
+ }
69
+ const entry = workspace.agentsDocument ?? pipeline.agentsDocument;
70
+ if (entry.mode === 'source') portablePath(entry.path);
71
+ return { layout, agentsDocument: entry, ...selection };
72
+ }
73
+ export function renderEntry(text, layout) {
74
+ if (typeof text !== 'string' || Buffer.byteLength(text) > 2 * 1024 * 1024) fail('entry.input');
75
+ validateLayoutReferences(layout);
76
+ const output = text.replace(/\{\{([^{}]*)\}\}/g, (matchText, token, offset) => {
77
+ if (text[offset - 1] === '{' || text[offset + matchText.length] === '}') fail('entry.token');
78
+ if (token === 'documentation') {
79
+ const doc = layout.documentation;
80
+ return posix.join(layout.repositories[doc.repository].path, doc.path);
81
+ }
82
+ const match = /^repository\.([a-z][a-z0-9-]{0,62})$/.exec(token);
83
+ if (!match || !has(layout.repositories, match[1])) fail('entry.token');
84
+ return layout.repositories[match[1]].path;
85
+ });
86
+ if (output.includes('{{') || output.includes('}}')) fail('entry.token');
87
+ return output;
88
+ }
89
+ export function resolveEntryChange(current, desired, baseHash, currentHash, decision) {
90
+ // Pure policy decision; caller S4/S8 must compute hashes from exact bytes.
91
+ if (current === null) return 'create';
92
+ if (current === desired) return 'preserve';
93
+ if (decision === 'preserve') return 'preserve';
94
+ if (baseHash !== null && baseHash === currentHash) return 'replace';
95
+ if (decision === 'replace') return 'replace';
96
+ // Merging requires a separately reviewed desired result, never an implicit merge.
97
+ fail('entry.conflict');
98
+ }
99
+ function pointer(value) {
100
+ if (typeof value !== 'string' || !/^(?:\/(?:[^~/]|~[01])*)+$/.test(value)) fail('ownership.pointer');
101
+ for (const part of value.split('/').slice(1).map(s => s.replace(/~1/g, '/').replace(/~0/g, '~')))
102
+ if (['__proto__', 'constructor', 'prototype'].includes(part)) fail('ownership.pointer');
103
+ }
104
+ function snapshot(s) {
105
+ if (s.source.subdirectory !== '.') portablePath(s.source.subdirectory);
106
+ portablePath(s.path); machinePath(s.origin.path); machinePath(s.origin.base);
107
+ if (!s.path.startsWith('.pipeline/snapshots/')) fail('state.snapshot-path');
108
+ if (s.source.transport === 'local') machinePath(s.origin.resolvedSource);
109
+ else if (s.origin.resolvedSource !== s.source.url) fail('source.identity');
110
+ }
111
+ function ownership(records, providers) {
112
+ const seen = [];
113
+ for (const record of records) {
114
+ portablePath(record.path);
115
+ if (record.backup !== null) {
116
+ portablePath(record.backup);
117
+ if (!record.backup.startsWith('.pipeline/backups/')) fail('ownership.backup');
118
+ }
119
+ if (record.beforeHash !== null && record.backup === null) fail('ownership.backup');
120
+ if (record.owner !== 'shared' && !providers.includes(record.owner)) fail('ownership.owner');
121
+ if (record.kind === 'file' && record.pointer !== null) fail('ownership.pointer');
122
+ if (record.kind === 'field') pointer(record.pointer);
123
+ for (const previous of seen) if (previous.path.toLowerCase() === record.path.toLowerCase()) {
124
+ if (previous.kind === 'file' || record.kind === 'file' || previous.pointer === record.pointer ||
125
+ previous.pointer.startsWith(record.pointer + '/') || record.pointer.startsWith(previous.pointer + '/')) fail('ownership.overlap');
126
+ }
127
+ seen.push(record);
128
+ }
129
+ }
130
+ function deployment(d) {
131
+ validateInstalledBundles(d);
132
+ snapshot(d.snapshot); validateLayoutReferences(d.layout);
133
+ if (Object.keys(d.adapterVersions).sort().join(',') !== [...d.providers].sort().join(',')) fail('state.adapters');
134
+ ownership(d.owned, d.providers);
135
+ }
136
+ export function validateState(state) {
137
+ validateStructure('state', state); machinePath(state.workspace);
138
+ if (state.active) deployment(state.active);
139
+ if (state.status === 'ready' && (!state.active || state.pending !== null)) fail('state.ready');
140
+ if (state.status === 'not-installed' && (state.active !== null || state.pending !== null)) fail('state.absent');
141
+ if (state.status === 'needs-reconciliation' && state.pending === null) fail('state.pending');
142
+ if (state.status === 'drift' && state.active === null) fail('state.drift');
143
+ return state;
144
+ }
145
+ export function validateOperation(op, previous) {
146
+ validateStructure('operation', op);
147
+ const ids = new Set();
148
+ if (op.kind === 'plan') {
149
+ machinePath(op.workspace);
150
+ // Existing-state plans require the exact previous state, including removed
151
+ // providers. The desired provider set alone cannot authorize cleanup.
152
+ if (op.beforeStateHash !== null) {
153
+ if (previous == null) fail('plan.previous-required');
154
+ validateState(previous);
155
+ if (previous.workspace !== op.workspace || contractDigest(previous) !== op.beforeStateHash) fail('plan.previous');
156
+ } else if (previous != null) fail('plan.previous');
157
+ const owners = new Set(['shared', ...(previous?.active?.providers ?? []), ...(op.desired?.providers ?? [])]);
158
+ if (op.source) snapshot(op.source);
159
+ if (op.desired) {
160
+ deployment(op.desired);
161
+ if (!op.source || !isDeepStrictEqual(op.source, op.desired.snapshot)) fail('plan.source');
162
+ }
163
+ if (['setup', 'update', 'repair', 'switch'].includes(op.command) && !op.desired) fail('plan.deployment');
164
+ const paths = new Set();
165
+ for (const t of op.targets) {
166
+ if (!owners.has(t.owner)) fail('operation.owner');
167
+ if (ids.has(t.id)) fail('operation.duplicate'); ids.add(t.id);
168
+ portablePath(t.path);
169
+ if (paths.has(t.path.toLowerCase())) fail('operation.path'); paths.add(t.path.toLowerCase());
170
+ if (t.action === 'create' && (t.beforeHash !== null || t.desiredHash === null)) fail('operation.hash');
171
+ if (t.action === 'delete' && (t.beforeHash === null || t.desiredHash !== null)) fail('operation.hash');
172
+ const retiredBundleFile = op.command==='update' && previous?.active?.owned.some(o=>
173
+ o.path===t.path && o.owner===t.owner && o.kind==='file') &&
174
+ Object.entries(previous.active.bundles??{}).some(([id,b])=>op.desired?.bundles?.[id] &&
175
+ b.providers.includes(t.owner) && !op.desired.providers.includes(t.owner));
176
+ if (t.action === 'verify-absent' && ((op.command !== 'remove' && !retiredBundleFile) || t.beforeHash !== null || t.desiredHash !== null)) fail('operation.hash');
177
+ if (['replace', 'edit-fields'].includes(t.action) && (t.beforeHash === null || t.desiredHash === null)) fail('operation.hash');
178
+ if ((t.action === 'edit-fields') !== (t.fields.length > 0)) fail('operation.fields');
179
+ const fieldPointers = [];
180
+ for (const f of t.fields) {
181
+ pointer(f.pointer);
182
+ if (fieldPointers.some(p => p === f.pointer || p.startsWith(f.pointer + '/') || f.pointer.startsWith(p + '/'))) fail('operation.fields');
183
+ fieldPointers.push(f.pointer);
184
+ }
185
+ }
186
+ } else {
187
+ let stopped = false;
188
+ for (const r of op.operations) {
189
+ if (ids.has(r.operationId)) fail('operation.duplicate'); ids.add(r.operationId);
190
+ if (stopped && r.status !== 'skipped') fail('receipt.order');
191
+ if (r.status !== 'completed') stopped = true;
192
+ if (r.status === 'completed' && r.observedHash !== r.desiredHash) fail('receipt.readback');
193
+ if (r.status === 'failed' && r.observedHash !== r.beforeHash) fail('receipt.before');
194
+ }
195
+ const statuses = op.operations.map(r => r.status);
196
+ const expected = statuses.every(s => s === 'completed') ? 'completed' :
197
+ statuses.includes('uncertain') ? 'uncertain' : 'failed';
198
+ if (op.status !== expected) fail('receipt.status');
199
+ }
200
+ return op;
201
+ }
202
+ // Hash domain data, not file bytes: sorted object keys, array order retained.
203
+ // Use only validated JSON-domain values. Source blob hashes remain exact byte hashes.
204
+ export function contractDigest(value) {
205
+ const canonical = v => {
206
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
207
+ if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
208
+ return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
209
+ };
210
+ return 'sha256:' + createHash('sha256').update(canonical(value), 'utf8').digest('hex');
211
+ }
212
+ export function validateReceiptForPlan(receipt, plan, previous) {
213
+ validateOperation(plan, previous); validateOperation(receipt);
214
+ if (plan.kind !== 'plan' || receipt.kind !== 'receipt' || receipt.planDigest !== contractDigest(plan)) fail('receipt.binding');
215
+ if (receipt.operations.length !== plan.targets.length) fail('receipt.coverage');
216
+ for (let i = 0; i < plan.targets.length; i++) {
217
+ const t = plan.targets[i], r = receipt.operations[i];
218
+ if (t.id !== r.operationId || t.beforeHash !== r.beforeHash || t.desiredHash !== r.desiredHash) fail('receipt.binding');
219
+ }
220
+ return receipt;
221
+ }
222
+
223
+ // Structural lifecycle guard only. S5 must independently prove writes/readback and
224
+ // bind the on-disk before-state; a fabricated receipt is not execution evidence.
225
+ export function validateTransition(previous, next, plan, receipt) {
226
+ if (previous !== null) validateState(previous);
227
+ validateState(next); validateReceiptForPlan(receipt, plan, previous);
228
+ if (next.workspace !== plan.workspace || (previous !== null && previous.workspace !== plan.workspace) ||
229
+ plan.beforeStateHash !== (previous === null ? null : contractDigest(previous))) fail('transition.before');
230
+ if (receipt.status === 'completed') {
231
+ if (!isDeepStrictEqual(next.active, plan.desired) || next.pending !== null ||
232
+ next.status !== (plan.desired === null ? 'not-installed' : 'ready')) fail('transition.activation');
233
+ } else {
234
+ if (next.status !== 'needs-reconciliation' || next.pending !== contractDigest(plan) ||
235
+ !isDeepStrictEqual(next.active, previous?.active ?? null)) fail('transition.partial');
236
+ if (contractDigest(next.activation??null)!==contractDigest(previous?.activation??null)) fail('transition.activation-history');
237
+ }
238
+ if (next.runtime !== 'not-run') fail('transition.runtime');
239
+ return next;
240
+ }
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import Ajv2020 from 'ajv/dist/2020.js';
3
+ import { fail } from './parse.js';
4
+
5
+ const names = ['common', 'pipeline', 'workspace', 'inventory', 'state', 'operation'];
6
+ // Reviewed schemas use required inside not/oneOf and inherit types from parents.
7
+ const ajv = new Ajv2020({ strict: true, strictTypes: false, strictRequired: false, allErrors: false,
8
+ ownProperties: true, coerceTypes: false, useDefaults: false, removeAdditional: false });
9
+ for (const name of names) {
10
+ const schema = JSON.parse(readFileSync(new URL('../../schemas/' + name + '.schema.json', import.meta.url), 'utf8'));
11
+ ajv.addSchema(schema, name + '.schema.json');
12
+ }
13
+ // common is a reference library, not a document schema with root constraints.
14
+ const validators = new Map(names.filter(name => name !== 'common')
15
+ .map(name => [name, ajv.getSchema(name + '.schema.json')]));
16
+ export function validateStructure(kind, value) {
17
+ const validator = validators.get(kind);
18
+ if (!validator) fail('schema.kind');
19
+ if (!validator(value)) fail('schema.invalid');
20
+ return value;
21
+ }
@@ -0,0 +1,20 @@
1
+ // Child-process-only overlay, not a launcher or readiness check by itself.
2
+ // Callers still validate workspace state and choose the executable.
3
+ // This neither grants trust nor disables native plugins.
4
+ export const GROK_CLAUDE_PIPELINE_KEYS = Object.freeze([
5
+ 'GROK_CLAUDE_SKILLS_ENABLED', 'GROK_CLAUDE_RULES_ENABLED',
6
+ 'GROK_CLAUDE_AGENTS_ENABLED', 'GROK_CLAUDE_MCPS_ENABLED',
7
+ 'GROK_CLAUDE_HOOKS_ENABLED',
8
+ ]);
9
+ export function grokEnvironment(parent, platform = process.platform) {
10
+ if (!parent || typeof parent !== 'object' || Array.isArray(parent)) throw new TypeError('launch.environment');
11
+ if (!['win32', 'linux', 'darwin'].includes(platform)) throw new TypeError('launch.platform');
12
+ const result = Object.create(null), owned = new Set(GROK_CLAUDE_PIPELINE_KEYS);
13
+ for (const [key, value] of Object.entries(parent)) {
14
+ // Windows spawn must not choose an inherited differently-cased spelling.
15
+ if (owned.has(platform === 'win32' ? key.toUpperCase() : key)) continue;
16
+ if (value !== undefined) result[key] = value;
17
+ }
18
+ for (const key of owned) result[key] = 'false';
19
+ return result;
20
+ }
@@ -0,0 +1,35 @@
1
+ import {createLegacyUnityLease,legacyUnityLeasePayload,sealLegacyUnityLease} from './legacy-unity-lease.js';
2
+ import {verifyLegacyUnityPreview} from './legacy-unity-preview.js';
3
+ import {acquireWorkspaceLock,assertLockHeld} from '../operations/lock.js';
4
+ import {writeCheckedFile} from '../operations/apply.js';
5
+ import {executeLegacyUnityDeactivation} from './legacy-unity-deactivate.js';
6
+ import {executeLegacyUnityInstall} from './legacy-unity-install.js';
7
+ import {finalizeLegacyUnityMigration} from './legacy-unity-finalize.js';
8
+
9
+ // Persist recovery before any provider writes. Marker is written FIRST so a
10
+ // crash saving recovery is still fail-closed for ordinary lifecycle commands.
11
+ // No automatic rollback/unlink on error. Caller owns the returned lock lifetime.
12
+ export async function beginLegacyUnityMigration(proposal,approval,workspace,{boundary=async()=>{}}={}){
13
+ const lease=await createLegacyUnityLease(proposal,approval,workspace);
14
+ let lock;
15
+ try{
16
+ lock=await acquireWorkspaceLock(workspace,{migrationOperation:lease});
17
+ await boundary('locked');
18
+ await verifyLegacyUnityPreview(proposal,approval,lock.workspace,lock);
19
+ const payload=legacyUnityLeasePayload(lease,lock.workspace);
20
+ await writeCheckedFile(lock,payload.markerPath,null,payload.markerBytes,undefined,
21
+ async phase=>boundary('marker:'+phase));
22
+ await writeCheckedFile(lock,payload.recoveryPath,null,payload.recoveryBytes,undefined,
23
+ async phase=>boundary('recovery:'+phase));
24
+ await sealLegacyUnityLease(lease,lock.workspace);
25
+ await assertLockHeld(lock);
26
+ return Object.freeze({lock,recoveryPath:payload.recoveryPath,recoveryHash:payload.recoveryHash,
27
+ status:'needs-reconciliation',providerWrites:0,runtime:'not-run',
28
+ deactivate:options=>executeLegacyUnityDeactivation(lock,payload,options),
29
+ install:options=>executeLegacyUnityInstall(lock,payload,options),
30
+ finalize:options=>finalizeLegacyUnityMigration(lock,payload,options)});
31
+ }catch(error){
32
+ if(lock){try{await lock.release({removeEmptyMetadata:true});}catch{error.lockRelease='failed';}}
33
+ throw error;
34
+ }
35
+ }
@@ -0,0 +1,82 @@
1
+ import {prepareLegacyUnityDeactivationResume} from './legacy-unity-deactivation-resume.js';
2
+ import {validateLegacyUnityDeactivation} from './legacy-unity.js';
3
+ import {readRecord,observeTargets} from '../operations/state.js';
4
+ import {resolveChild,inspectDirectory} from '../workspace/paths.js';
5
+ import {contractDigest} from '../contracts/semantic.js';
6
+ import {sha256} from '../source/inventory.js';
7
+ import {fail} from '../contracts/parse.js';
8
+ import {createLegacyUnityCompensationLease,legacyUnityLeasePayload} from './legacy-unity-lease.js';
9
+ import {acquireWorkspaceLock} from '../operations/lock.js';
10
+ import {writeCheckedFile,deleteCheckedFile} from '../operations/apply.js';
11
+ import {readdir} from 'node:fs/promises';
12
+
13
+ async function noInstallation(workspace){
14
+ for(const path of ['.pipeline/snapshots','.pipeline/backups','.pipeline/journals','.pipeline/transactions'])
15
+ if((await inspectDirectory(resolveChild(workspace,path))).exists)fail('migration.compensation-installation');
16
+ }
17
+ export async function prepareLegacyUnityCompensation(workspace,recoveryPath){
18
+ const base=await prepareLegacyUnityDeactivationResume(workspace,recoveryPath);
19
+ if(base.status!=='needs-approval')fail('migration.compensation-conflict');
20
+ await noInstallation(base.workspace);
21
+ // A resumed attempt needs its own recovery/compensation selection, never ignore it.
22
+ if((await inspectDirectory(resolveChild(base.workspace,recoveryPath.replace(/recovery\.json$/,'deactivation-resumes')))).exists)fail('migration.compensation-resumed');
23
+ const stored=await readRecord(resolveChild(base.workspace,recoveryPath));
24
+ if(stored.digest!==base.recoveryHash)fail('migration.resume-drift');
25
+ const phase=validateLegacyUnityDeactivation(stored.value.preview.preflight.deactivation);
26
+ const operations=phase.targets.map(t=>{
27
+ const observed=base.observations.find(o=>o.path===t.path).hash;
28
+ return {path:t.path,action:observed===t.beforeHash?'confirm-original':'restore-original',beforeHash:observed,
29
+ afterHash:t.beforeHash,bytes:t.before};
30
+ });
31
+ const body={schemaVersion:1,kind:'legacy-unity-compensation-preview',workspace:base.workspace,recoveryPath,
32
+ recoveryHash:base.recoveryHash,markerHash:base.markerHash,base,operations,
33
+ removeMarker:{path:'.pipeline/migration-operation.json',beforeHash:base.markerHash},status:'needs-approval',executable:false,runtime:'not-run'};
34
+ return {...body,digest:contractDigest(body)};
35
+ }
36
+
37
+ export async function applyLegacyUnityCompensation(proposal,approval,workspace,{boundary=async()=>{}}={}){
38
+ const {lease,preview}=await createLegacyUnityCompensationLease(proposal,approval,workspace);
39
+ const lock=await acquireWorkspaceLock(workspace,{migrationOperation:lease});
40
+ try{
41
+ if(contractDigest(await prepareLegacyUnityCompensation(lock.workspace,preview.recoveryPath))!==contractDigest(preview))fail('migration.resume-drift');
42
+ const payload=legacyUnityLeasePayload(lease,lock.workspace),directory=preview.recoveryPath.replace(/recovery\.json$/,'compensation');
43
+ const written=[];let previous=null;
44
+ const append=async(kind,detail)=>{
45
+ for(const item of written)if((await readRecord(resolveChild(lock.workspace,item.path))).digest!==item.hash)fail('migration.journal-drift');
46
+ const path=directory+'/'+String(written.length).padStart(6,'0')+'.json';
47
+ const bytes=Buffer.from(JSON.stringify({schemaVersion:1,seq:written.length,previous,kind,detail})+'\n');
48
+ await writeCheckedFile(lock,path,null,bytes);previous=sha256(bytes);written.push({path,hash:previous});
49
+ };
50
+ await append('authorization',{preview,approval:{decision:'approve',previewDigest:preview.digest}});
51
+ const expected=new Map(preview.base.observations.map(o=>[o.path,o.hash]));
52
+ const check=async()=>{
53
+ await noInstallation(lock.workspace);
54
+ const originalDirectory=preview.recoveryPath.replace(/recovery\.json$/,'deactivation');
55
+ let names;try{names=(await readdir(resolveChild(lock.workspace,originalDirectory))).sort();}catch(e){if(e.code!=='ENOENT')throw e;names=[];}
56
+ if(names.join(',')!==preview.base.events.map(e=>e.path.split('/').at(-1)).join(','))fail('migration.journal-drift');
57
+ if((await inspectDirectory(resolveChild(lock.workspace,preview.recoveryPath.replace(/recovery\.json$/,'deactivation-resumes')))).exists)fail('migration.compensation-resumed');
58
+ for(const path of ['.pipeline/state.json',preview.recoveryPath.replace(/recovery\.json$/,'installation.json')])
59
+ if((await observeTargets(lock.workspace,[path]))[0].bytes!==null)fail('migration.compensation-installation');
60
+ for(const e of preview.base.events)if((await readRecord(resolveChild(lock.workspace,e.path))).digest!==e.hash)fail('migration.journal-drift');
61
+ for(const row of await observeTargets(lock.workspace,[...expected.keys()]))
62
+ if((row.bytes===null?null:sha256(row.bytes))!==expected.get(row.path))fail('migration.observation-drift');
63
+ };
64
+ for(const op of preview.operations){
65
+ await check();await append('intent',op);
66
+ try{
67
+ if(op.action==='restore-original')await writeCheckedFile(lock,op.path,op.beforeHash,Buffer.from(op.bytes,'base64'),undefined,boundary);
68
+ const [row]=await observeTargets(lock.workspace,[op.path]);
69
+ if(row.bytes===null||sha256(row.bytes)!==op.afterHash)fail('migration.compensation-drift');
70
+ }catch{
71
+ await append('outcome',{path:op.path,status:'uncertain'});
72
+ return {status:'needs-reconciliation',journal:directory,runtime:'not-run'};
73
+ }
74
+ expected.set(op.path,op.afterHash);await append('outcome',{path:op.path,status:'original-observed',hash:op.afterHash});
75
+ }
76
+ await check();await append('compensated',{recoveryHash:preview.recoveryHash});
77
+ await boundary('compensation-recorded');await check();
78
+ for(const e of written)if((await readRecord(resolveChild(lock.workspace,e.path))).digest!==e.hash)fail('migration.journal-drift');
79
+ await deleteCheckedFile(lock,payload.markerPath,payload.markerHash,async()=>{});
80
+ return {status:'legacy-restored',journal:directory,runtime:'not-run'};
81
+ }finally{await lock.release();}
82
+ }
@@ -0,0 +1,60 @@
1
+ import {readRecord,observeTargets} from '../operations/state.js';
2
+ import {writeCheckedFile} from '../operations/apply.js';
3
+ import {assertMigrationLockHeld} from '../operations/lock.js';
4
+ import {resolveChild} from '../workspace/paths.js';
5
+ import {validateLegacyUnityDeactivation} from './legacy-unity.js';
6
+ import {sha256} from '../source/inventory.js';
7
+ import {parse,fail,ContractError} from '../contracts/parse.js';
8
+
9
+ // Internal first-phase executor. New activation is deliberately not performed.
10
+ // Every attempt has an exclusive journal; replay requires a future recovery API.
11
+ export async function executeLegacyUnityDeactivation(lock,payload,{boundary=async()=>{}}={}){
12
+ const guard=()=>assertMigrationLockHeld(lock,payload.recoveryPath,payload.recoveryHash);
13
+ await guard();
14
+ const stored=await readRecord(resolveChild(lock.workspace,payload.recoveryPath));
15
+ if(stored.digest!==payload.recoveryHash)fail('migration.recovery-drift');
16
+ const record=stored.value,preview=record.preview;
17
+ if(record.workspace!==lock.workspace||record.approval?.previewDigest!==preview.digest||record.approval?.decision!=='approve')fail('migration.recovery-binding');
18
+ const phase=validateLegacyUnityDeactivation(preview.preflight.deactivation);
19
+ const before=new Map(preview.initialObservations.map(x=>[x.path,x.hash]));
20
+ for(const x of preview.preflight.observations){
21
+ if(before.has(x.path)&&before.get(x.path)!==x.sha256)fail('migration.observation-binding');
22
+ before.set(x.path,x.sha256);
23
+ }
24
+ for(const row of await observeTargets(lock.workspace,[...before.keys()]))
25
+ if((row.bytes===null?null:sha256(row.bytes))!==before.get(row.path))fail('migration.observation-drift');
26
+ const directory=payload.recoveryPath.replace(/recovery\.json$/,'deactivation');
27
+ let seq=0,head=null,lastPath;const written=[];
28
+ async function append(kind,detail){
29
+ await guard();
30
+ if(lastPath&&(await readRecord(resolveChild(lock.workspace,lastPath))).digest!==head)fail('migration.journal-drift');
31
+ const relative=directory+'/'+String(seq).padStart(6,'0')+'.json';
32
+ const event={schemaVersion:1,seq,previous:head,recoveryHash:payload.recoveryHash,kind,detail};
33
+ const bytes=Buffer.from(JSON.stringify(parse(JSON.stringify(event),'json'))+'\n');
34
+ await writeCheckedFile(lock,relative,null,bytes);
35
+ lastPath=relative;head=sha256(bytes);written.push({path:relative,hash:head});seq++;
36
+ }
37
+ await append('start',null);
38
+ for(const target of phase.targets){
39
+ await append('intent',{path:target.path,beforeHash:target.beforeHash,afterHash:target.afterHash});
40
+ try{
41
+ await writeCheckedFile(lock,target.path,target.beforeHash,Buffer.from(target.after,'base64'),undefined,
42
+ async step=>boundary(step,{path:target.path}));
43
+ }catch(error){
44
+ // Error after a write is not silently promoted to successful completion.
45
+ let observedHash=null;
46
+ try{const [row]=await observeTargets(lock.workspace,[target.path]);observedHash=row.bytes===null?null:sha256(row.bytes);}catch{}
47
+ const status=observedHash===target.beforeHash?'failed':'uncertain';
48
+ await append('outcome',{path:target.path,status,observedHash});
49
+ return {status:'needs-reconciliation',phase:'deactivate-legacy',outcome:status,
50
+ journal:directory,error:error instanceof ContractError?error.code:'migration.target-io',runtime:'not-run'};
51
+ }
52
+ await append('outcome',{path:target.path,status:'completed',observedHash:target.afterHash});
53
+ }
54
+ for(const row of await observeTargets(lock.workspace,phase.targets.map(t=>t.path)))
55
+ if(row.bytes===null||sha256(row.bytes)!==phase.targets.find(t=>t.path===row.path).afterHash)fail('migration.phase-drift');
56
+ await append('phase-checked',{phase:'deactivate-legacy'});
57
+ for(const item of written)if((await readRecord(resolveChild(lock.workspace,item.path))).digest!==item.hash)fail('migration.journal-drift');
58
+ return {status:'needs-reconciliation',phase:'deactivate-legacy',outcome:'completed',journal:directory,
59
+ next:'install-local-delivery',runtime:'not-run'};
60
+ }
@@ -0,0 +1,62 @@
1
+ import {createLegacyUnityDeactivationResumeLease} from './legacy-unity-lease.js';
2
+ import {prepareLegacyUnityDeactivationResume} from './legacy-unity-deactivation-resume.js';
3
+ import {validateLegacyUnityDeactivation} from './legacy-unity.js';
4
+ import {acquireWorkspaceLock} from '../operations/lock.js';
5
+ import {readRecord,observeTargets} from '../operations/state.js';
6
+ import {writeCheckedFile} from '../operations/apply.js';
7
+ import {resolveChild} from '../workspace/paths.js';
8
+ import {contractDigest} from '../contracts/semantic.js';
9
+ import {sha256} from '../source/inventory.js';
10
+ import {fail} from '../contracts/parse.js';
11
+ import {readdir} from 'node:fs/promises';
12
+
13
+ // Internal execution only; original uncertain/failed receipts are never rewritten.
14
+ export async function applyLegacyUnityDeactivationResume(proposal,approval,workspace,{boundary=async()=>{}}={}){
15
+ const {lease,preview}=await createLegacyUnityDeactivationResumeLease(proposal,approval,workspace);
16
+ const lock=await acquireWorkspaceLock(workspace,{migrationOperation:lease});
17
+ try{
18
+ if(contractDigest(await prepareLegacyUnityDeactivationResume(lock.workspace,preview.recoveryPath))!==contractDigest(preview))fail('migration.resume-drift');
19
+ const record=await readRecord(resolveChild(lock.workspace,preview.recoveryPath));
20
+ const phase=validateLegacyUnityDeactivation(record.value.preview.preflight.deactivation);
21
+ const directory=preview.recoveryPath.replace(/recovery\.json$/,`deactivation-resumes/${preview.digest.slice(7)}`);
22
+ const written=[];let previous=null;
23
+ const append=async(kind,detail)=>{
24
+ for(const item of written)if((await readRecord(resolveChild(lock.workspace,item.path))).digest!==item.hash)fail('migration.journal-drift');
25
+ const path=directory+'/'+String(written.length).padStart(6,'0')+'.json';
26
+ const bytes=Buffer.from(JSON.stringify({schemaVersion:1,seq:written.length,previous,kind,detail})+'\n');
27
+ await writeCheckedFile(lock,path,null,bytes);previous=sha256(bytes);written.push({path,hash:previous});
28
+ };
29
+ await append('authorization',{preview,approval:{decision:'approve',previewDigest:preview.digest}});
30
+ const expected=new Map(preview.observations.map(o=>[o.path,o.hash]));
31
+ const check=async()=>{
32
+ const originalDirectory=preview.recoveryPath.replace(/recovery\.json$/,'deactivation');
33
+ let names;try{names=(await readdir(resolveChild(lock.workspace,originalDirectory))).sort();}catch(error){if(error.code!=='ENOENT')throw error;names=[];}
34
+ if(names.join(',')!==preview.events.map(e=>e.path.split('/').at(-1)).join(','))fail('migration.journal-drift');
35
+ for(const e of preview.events)if((await readRecord(resolveChild(lock.workspace,e.path))).digest!==e.hash)fail('migration.journal-drift');
36
+ for(const path of [preview.recoveryPath.replace(/recovery\.json$/,'installation.json'),'.pipeline/state.json'])
37
+ if((await observeTargets(lock.workspace,[path]))[0].bytes!==null)fail('migration.install-started');
38
+ for(const row of await observeTargets(lock.workspace,[...expected.keys()]))
39
+ if((row.bytes===null?null:sha256(row.bytes))!==expected.get(row.path))fail('migration.observation-drift');
40
+ };
41
+ for(const operation of preview.operations){
42
+ await check();await append('intent',operation);
43
+ try{
44
+ if(operation.action==='write-disabled'){
45
+ const target=phase.targets.find(t=>t.path===operation.path);
46
+ await writeCheckedFile(lock,operation.path,operation.beforeHash,Buffer.from(target.after,'base64'),undefined,boundary);
47
+ }
48
+ const [row]=await observeTargets(lock.workspace,[operation.path]);
49
+ if(row.bytes===null||sha256(row.bytes)!==operation.afterHash)fail('migration.phase-drift');
50
+ }catch{
51
+ const [row]=await observeTargets(lock.workspace,[operation.path]);
52
+ await append('outcome',{path:operation.path,status:'uncertain',observedHash:row.bytes===null?null:sha256(row.bytes)});
53
+ return {status:'needs-reconciliation',outcome:'uncertain',journal:directory,runtime:'not-run'};
54
+ }
55
+ expected.set(operation.path,operation.afterHash);
56
+ await append('outcome',{path:operation.path,status:operation.action==='write-disabled'?'written':'confirmed-observed',observedHash:operation.afterHash});
57
+ }
58
+ await check();await append('phase-observed',{phase:'deactivate-legacy',recoveryHash:preview.recoveryHash});
59
+ for(const e of written)if((await readRecord(resolveChild(lock.workspace,e.path))).digest!==e.hash)fail('migration.journal-drift');
60
+ return {status:'needs-reconciliation',outcome:'phase-observed',journal:directory,runtime:'not-run'};
61
+ }finally{await lock.release();}
62
+ }
@@ -0,0 +1,81 @@
1
+ import {readdir} from 'node:fs/promises';
2
+ import {absoluteRoot,resolveChild,inspectDirectory} from '../workspace/paths.js';
3
+ import {readRecord,observeTargets} from '../operations/state.js';
4
+ import {contractDigest} from '../contracts/semantic.js';
5
+ import {sha256} from '../source/inventory.js';
6
+ import {fail} from '../contracts/parse.js';
7
+ import {validateLegacyUnityDeactivation,inspectLegacyUnityDeactivation} from './legacy-unity.js';
8
+
9
+ // Read-only first-phase reconciliation. A current after hash is NOT a receipt.
10
+ export async function prepareLegacyUnityDeactivationResume(workspace,recoveryPath){
11
+ workspace=absoluteRoot(workspace);
12
+ if(typeof recoveryPath!=='string'||!/^\.pipeline\/migrations\/[a-f0-9-]{36}\/recovery\.json$/.test(recoveryPath))fail('migration.recovery-path');
13
+ const recovery=await readRecord(resolveChild(workspace,recoveryPath)),r=recovery.value,p=r.preview;
14
+ if(r.schemaVersion!==1||r.kind!=='legacy-unity-recovery'||r.workspace!==workspace||p?.workspace!==workspace||
15
+ recoveryPath!==`.pipeline/migrations/${r.id}/recovery.json`)fail('migration.recovery-binding');
16
+ const {digest,...body}=p;
17
+ if(contractDigest(body)!==digest||contractDigest(r.approval)!==contractDigest({decision:'approve',previewDigest:digest}))fail('migration.preview-binding');
18
+ const markerPath='.pipeline/migration-operation.json',marker=await readRecord(resolveChild(workspace,markerPath));
19
+ if(contractDigest(marker.value)!==contractDigest({schemaVersion:1,kind:'legacy-unity-pending',workspace,recoveryPath,
20
+ recoveryHash:recovery.digest,previewDigest:digest}))fail('migration.marker-binding');
21
+ // Once native installation begins this narrow route must not restore its inputs.
22
+ for(const path of [recoveryPath.replace(/recovery\.json$/,'installation.json'),'.pipeline/state.json']){
23
+ if((await observeTargets(workspace,[path]))[0].bytes!==null)fail('migration.install-started');
24
+ }
25
+ const phase=validateLegacyUnityDeactivation(p.preflight.deactivation);
26
+ const directory=recoveryPath.replace(/recovery\.json$/,'deactivation');
27
+ const exists=(await inspectDirectory(resolveChild(workspace,directory))).exists;
28
+ const names=exists?(await readdir(resolveChild(workspace,directory))).sort():[];
29
+ if(names.length>6||names.some((name,i)=>name!==String(i).padStart(6,'0')+'.json'))fail('migration.phase-evidence');
30
+ const events=[],intents=new Set(),completed=new Set();let previous=null,stopped=false;
31
+ for(let seq=0;seq<names.length;seq++){
32
+ if(stopped)fail('migration.phase-evidence');
33
+ const record=await readRecord(resolveChild(workspace,directory+'/'+names[seq]));
34
+ const event=record.value;let expected;
35
+ if(seq===0)expected={kind:'start',detail:null};
36
+ else if(seq===5)expected={kind:'phase-checked',detail:{phase:'deactivate-legacy'}};
37
+ else{
38
+ const t=phase.targets[Math.floor((seq-1)/2)];
39
+ if(seq%2){expected={kind:'intent',detail:{path:t.path,beforeHash:t.beforeHash,afterHash:t.afterHash}};intents.add(t.path);}
40
+ else{
41
+ const d=event.detail;
42
+ if(!d||!['completed','failed','uncertain'].includes(d.status)||
43
+ !(d.observedHash===null||/^sha256:[a-f0-9]{64}$/.test(d.observedHash)))fail('migration.phase-evidence');
44
+ if(d.status==='completed'&&d.observedHash!==t.afterHash||d.status==='failed'&&d.observedHash!==t.beforeHash)fail('migration.phase-evidence');
45
+ stopped=d.status!=='completed';if(!stopped)completed.add(t.path);
46
+ expected={kind:'outcome',detail:{path:t.path,status:d.status,observedHash:d.observedHash}};
47
+ }
48
+ }
49
+ if(contractDigest(event)!==contractDigest({schemaVersion:1,seq,previous,recoveryHash:recovery.digest,...expected}))fail('migration.phase-evidence');
50
+ previous=record.digest;events.push({path:directory+'/'+names[seq],hash:record.digest});
51
+ }
52
+ const expected=new Map(p.initialObservations.map(o=>[o.path,o.hash]));
53
+ for(const o of p.preflight.observations){
54
+ if(expected.has(o.path)&&expected.get(o.path)!==o.sha256)fail('migration.observation-binding');
55
+ expected.set(o.path,o.sha256);
56
+ }
57
+ const rows=await observeTargets(workspace,[...expected.keys()]);
58
+ const classifications=inspectLegacyUnityDeactivation(phase,phase.targets.map(t=>({provider:t.provider,bytes:rows.find(r=>r.path===t.path).bytes})));
59
+ for(const row of rows){
60
+ if(phase.targets.some(t=>t.path===row.path))continue;
61
+ if((row.bytes===null?null:sha256(row.bytes))!==expected.get(row.path))fail('migration.observation-drift');
62
+ }
63
+ const conflicts=classifications.filter(t=>t.state==='conflict'||t.state==='after'&&!intents.has(t.path)||t.state==='before'&&completed.has(t.path));
64
+ const operations=conflicts.length?[]:classifications.map(t=>({path:t.path,
65
+ action:t.state==='before'?'write-disabled':'confirm-observed',state:t.state,
66
+ beforeHash:sha256(rows.find(r=>r.path===t.path).bytes),afterHash:phase.targets.find(x=>x.path===t.path).afterHash}));
67
+ // Recheck evidence and files, without claiming a global atomic snapshot.
68
+ for(const e of [{path:markerPath,hash:marker.digest},{path:recoveryPath,hash:recovery.digest},...events])
69
+ if((await readRecord(resolveChild(workspace,e.path))).digest!==e.hash)fail('migration.resume-drift');
70
+ if(exists&&(await readdir(resolveChild(workspace,directory))).sort().join(',')!==names.join(','))fail('migration.resume-drift');
71
+ if(!exists&&(await inspectDirectory(resolveChild(workspace,directory))).exists)fail('migration.resume-drift');
72
+ for(const path of [recoveryPath.replace(/recovery\.json$/,'installation.json'),'.pipeline/state.json'])
73
+ if((await observeTargets(workspace,[path]))[0].bytes!==null)fail('migration.resume-drift');
74
+ const after=await observeTargets(workspace,[...expected.keys()]);
75
+ const fingerprints=items=>items.map(o=>({path:o.path,hash:o.bytes===null?null:sha256(o.bytes)}));
76
+ if(contractDigest(fingerprints(after))!==contractDigest(fingerprints(rows)))fail('migration.resume-drift');
77
+ const result={schemaVersion:1,kind:'legacy-unity-deactivation-resume-preview',workspace,recoveryPath,
78
+ recoveryHash:recovery.digest,markerHash:marker.digest,events,observations:fingerprints(rows),classifications,conflicts,operations,
79
+ status:conflicts.length?'blocked':'needs-approval',executable:false,runtime:'not-run'};
80
+ return {...result,digest:contractDigest(result)};
81
+ }