@open-agent-toolkit/cli 0.2.51 → 0.2.53

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 (218) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/bootstrap.md +6 -2
  3. package/assets/docs/cli-utilities/tool-packs.md +87 -33
  4. package/assets/docs/provider-sync/commands.md +30 -2
  5. package/assets/docs/provider-sync/config.md +19 -0
  6. package/assets/docs/provider-sync/manifest-and-drift.md +37 -0
  7. package/assets/docs/provider-sync/providers.md +69 -2
  8. package/assets/docs/provider-sync/scope-and-surface.md +44 -0
  9. package/assets/docs/reference/cli-reference.md +1 -0
  10. package/assets/docs/reference/oat-directory-structure.md +11 -4
  11. package/assets/docs/workflows/projects/evidence-layers.md +110 -1
  12. package/assets/docs/workflows/projects/implementation-execution.md +13 -0
  13. package/assets/docs/workflows/projects/orchestration-model.md +18 -0
  14. package/assets/public-package-versions.json +4 -4
  15. package/assets/skills/oat-dispatch-subagents/SKILL.md +57 -1
  16. package/assets/skills/oat-dispatch-subagents/references/record-schema.md +77 -1
  17. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  18. package/assets/skills/oat-project-dispatch-subagents/SKILL.md +24 -1
  19. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  20. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +11 -0
  21. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  22. package/assets/skills/oat-project-plan-writing/SKILL.md +12 -1
  23. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  24. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  25. package/assets/skills/oat-project-review-provide/SKILL.md +12 -1
  26. package/assets/skills/oat-project-review-provide-remote/SKILL.md +12 -1
  27. package/dist/agents/canonical/index.d.ts +2 -0
  28. package/dist/agents/canonical/index.d.ts.map +1 -1
  29. package/dist/agents/canonical/index.js +1 -0
  30. package/dist/agents/canonical/resolve.d.ts +45 -0
  31. package/dist/agents/canonical/resolve.d.ts.map +1 -0
  32. package/dist/agents/canonical/resolve.js +175 -0
  33. package/dist/commands/decision/agents-guidance.d.ts +2 -2
  34. package/dist/commands/decision/agents-guidance.d.ts.map +1 -1
  35. package/dist/commands/decision/agents-guidance.js +1 -4
  36. package/dist/commands/decision/index.d.ts.map +1 -1
  37. package/dist/commands/decision/index.js +25 -6
  38. package/dist/commands/docs/init/index.d.ts +7 -1
  39. package/dist/commands/docs/init/index.d.ts.map +1 -1
  40. package/dist/commands/docs/init/index.js +53 -20
  41. package/dist/commands/doctor/index.d.ts +8 -2
  42. package/dist/commands/doctor/index.d.ts.map +1 -1
  43. package/dist/commands/doctor/index.js +157 -46
  44. package/dist/commands/init/index.d.ts +8 -3
  45. package/dist/commands/init/index.d.ts.map +1 -1
  46. package/dist/commands/init/index.js +66 -28
  47. package/dist/commands/init/tools/index.d.ts +12 -12
  48. package/dist/commands/init/tools/index.d.ts.map +1 -1
  49. package/dist/commands/init/tools/index.js +430 -99
  50. package/dist/commands/init/tools/install-state.d.ts +2 -0
  51. package/dist/commands/init/tools/install-state.d.ts.map +1 -1
  52. package/dist/commands/init/tools/install-state.js +11 -0
  53. package/dist/commands/init/tools/project-guidance.d.ts +49 -0
  54. package/dist/commands/init/tools/project-guidance.d.ts.map +1 -0
  55. package/dist/commands/init/tools/project-guidance.js +144 -0
  56. package/dist/commands/init/tools/workflows/index.d.ts +7 -0
  57. package/dist/commands/init/tools/workflows/index.d.ts.map +1 -1
  58. package/dist/commands/init/tools/workflows/index.js +160 -7
  59. package/dist/commands/pjm/index.d.ts.map +1 -1
  60. package/dist/commands/pjm/index.js +28 -3
  61. package/dist/commands/pjm/init.d.ts +5 -0
  62. package/dist/commands/pjm/init.d.ts.map +1 -1
  63. package/dist/commands/pjm/init.js +19 -3
  64. package/dist/commands/project/dispatch/index.d.ts +10 -0
  65. package/dist/commands/project/dispatch/index.d.ts.map +1 -0
  66. package/dist/commands/project/dispatch/index.js +137 -0
  67. package/dist/commands/project/dispatch/record.d.ts +91 -0
  68. package/dist/commands/project/dispatch/record.d.ts.map +1 -0
  69. package/dist/commands/project/dispatch/record.js +402 -0
  70. package/dist/commands/project/index.d.ts.map +1 -1
  71. package/dist/commands/project/index.js +2 -0
  72. package/dist/commands/providers/inspect/inspect.d.ts +8 -1
  73. package/dist/commands/providers/inspect/inspect.d.ts.map +1 -1
  74. package/dist/commands/providers/inspect/inspect.js +146 -26
  75. package/dist/commands/providers/list/list.d.ts +8 -1
  76. package/dist/commands/providers/list/list.d.ts.map +1 -1
  77. package/dist/commands/providers/list/list.js +139 -23
  78. package/dist/commands/providers/providers.types.d.ts +25 -1
  79. package/dist/commands/providers/providers.types.d.ts.map +1 -1
  80. package/dist/commands/providers/set/index.d.ts +9 -1
  81. package/dist/commands/providers/set/index.d.ts.map +1 -1
  82. package/dist/commands/providers/set/index.js +43 -33
  83. package/dist/commands/remove/skill/remove-skill.d.ts.map +1 -1
  84. package/dist/commands/remove/skill/remove-skill.js +11 -0
  85. package/dist/commands/shared/agents-md.d.ts +42 -14
  86. package/dist/commands/shared/agents-md.d.ts.map +1 -1
  87. package/dist/commands/shared/agents-md.js +364 -70
  88. package/dist/commands/status/index.d.ts +8 -3
  89. package/dist/commands/status/index.d.ts.map +1 -1
  90. package/dist/commands/status/index.js +149 -37
  91. package/dist/commands/sync/apply.d.ts.map +1 -1
  92. package/dist/commands/sync/apply.js +220 -29
  93. package/dist/commands/sync/dry-run.d.ts.map +1 -1
  94. package/dist/commands/sync/dry-run.js +30 -6
  95. package/dist/commands/sync/index.d.ts +17 -2
  96. package/dist/commands/sync/index.d.ts.map +1 -1
  97. package/dist/commands/sync/index.js +108 -43
  98. package/dist/commands/sync/sync.types.d.ts +39 -2
  99. package/dist/commands/sync/sync.types.d.ts.map +1 -1
  100. package/dist/commands/sync/sync.utils.d.ts +5 -1
  101. package/dist/commands/sync/sync.utils.d.ts.map +1 -1
  102. package/dist/commands/sync/sync.utils.js +73 -0
  103. package/dist/commands/tools/info/info-tool.d.ts +2 -0
  104. package/dist/commands/tools/info/info-tool.d.ts.map +1 -1
  105. package/dist/commands/tools/info/info-tool.js +34 -12
  106. package/dist/commands/tools/install/index.d.ts.map +1 -1
  107. package/dist/commands/tools/install/index.js +19 -3
  108. package/dist/commands/tools/list/list-tools.d.ts +2 -0
  109. package/dist/commands/tools/list/list-tools.d.ts.map +1 -1
  110. package/dist/commands/tools/list/list-tools.js +36 -7
  111. package/dist/commands/tools/remove/index.d.ts.map +1 -1
  112. package/dist/commands/tools/remove/index.js +150 -21
  113. package/dist/commands/tools/remove/remove-tools.d.ts +20 -0
  114. package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
  115. package/dist/commands/tools/remove/remove-tools.js +122 -1
  116. package/dist/commands/tools/shared/format-pack-inventory.d.ts +19 -0
  117. package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
  118. package/dist/commands/tools/shared/format-pack-inventory.js +109 -0
  119. package/dist/commands/tools/shared/pack-evidence.d.ts +66 -0
  120. package/dist/commands/tools/shared/pack-evidence.d.ts.map +1 -0
  121. package/dist/commands/tools/shared/pack-evidence.js +132 -0
  122. package/dist/commands/tools/shared/pack-inventory.d.ts +1 -0
  123. package/dist/commands/tools/shared/pack-inventory.d.ts.map +1 -1
  124. package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts +44 -0
  125. package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts.map +1 -0
  126. package/dist/commands/tools/shared/pack-lifecycle-outcome.js +91 -0
  127. package/dist/commands/tools/update/index.d.ts.map +1 -1
  128. package/dist/commands/tools/update/index.js +74 -4
  129. package/dist/commands/tools/update/update-tools.d.ts +5 -0
  130. package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
  131. package/dist/commands/tools/update/update-tools.js +64 -2
  132. package/dist/config/oat-config.d.ts +5 -0
  133. package/dist/config/oat-config.d.ts.map +1 -1
  134. package/dist/config/oat-config.js +17 -5
  135. package/dist/config/user-sync-config.d.ts +1 -0
  136. package/dist/config/user-sync-config.d.ts.map +1 -1
  137. package/dist/config/user-sync-config.js +8 -0
  138. package/dist/drift/detector.d.ts +2 -2
  139. package/dist/drift/detector.d.ts.map +1 -1
  140. package/dist/drift/detector.js +18 -0
  141. package/dist/drift/strays.d.ts.map +1 -1
  142. package/dist/drift/strays.js +5 -0
  143. package/dist/engine/collection-sync.d.ts +14 -0
  144. package/dist/engine/collection-sync.d.ts.map +1 -0
  145. package/dist/engine/collection-sync.js +282 -0
  146. package/dist/engine/compute-plan.d.ts +8 -1
  147. package/dist/engine/compute-plan.d.ts.map +1 -1
  148. package/dist/engine/compute-plan.js +303 -12
  149. package/dist/engine/engine.types.d.ts +61 -0
  150. package/dist/engine/engine.types.d.ts.map +1 -1
  151. package/dist/engine/execute-plan.d.ts +22 -2
  152. package/dist/engine/execute-plan.d.ts.map +1 -1
  153. package/dist/engine/execute-plan.js +459 -27
  154. package/dist/engine/index.d.ts +1 -1
  155. package/dist/engine/index.d.ts.map +1 -1
  156. package/dist/engine/index.js +1 -1
  157. package/dist/engine/provider-path-safety.d.ts +1 -0
  158. package/dist/engine/provider-path-safety.d.ts.map +1 -1
  159. package/dist/engine/provider-path-safety.js +15 -0
  160. package/dist/engine/scanner.d.ts +9 -2
  161. package/dist/engine/scanner.d.ts.map +1 -1
  162. package/dist/engine/scanner.js +58 -4
  163. package/dist/fs/io.d.ts +67 -0
  164. package/dist/fs/io.d.ts.map +1 -1
  165. package/dist/fs/io.js +536 -3
  166. package/dist/manifest/index.d.ts +2 -2
  167. package/dist/manifest/index.d.ts.map +1 -1
  168. package/dist/manifest/index.js +1 -1
  169. package/dist/manifest/manager.d.ts +2 -2
  170. package/dist/manifest/manager.d.ts.map +1 -1
  171. package/dist/manifest/manager.js +20 -3
  172. package/dist/manifest/manifest.types.d.ts +704 -13
  173. package/dist/manifest/manifest.types.d.ts.map +1 -1
  174. package/dist/manifest/manifest.types.js +131 -15
  175. package/dist/providers/codex/codec/sync-extension.d.ts +1 -1
  176. package/dist/providers/codex/codec/sync-extension.d.ts.map +1 -1
  177. package/dist/providers/codex/codec/sync-extension.js +48 -17
  178. package/dist/providers/cursor/codec/sync-extension.d.ts +1 -1
  179. package/dist/providers/cursor/codec/sync-extension.d.ts.map +1 -1
  180. package/dist/providers/cursor/codec/sync-extension.js +52 -23
  181. package/dist/providers/identity/absolute-paths.d.ts +68 -0
  182. package/dist/providers/identity/absolute-paths.d.ts.map +1 -0
  183. package/dist/providers/identity/absolute-paths.js +144 -0
  184. package/dist/providers/identity/claude-runtime-observation.d.ts +105 -0
  185. package/dist/providers/identity/claude-runtime-observation.d.ts.map +1 -0
  186. package/dist/providers/identity/claude-runtime-observation.fixtures.d.ts +40 -0
  187. package/dist/providers/identity/claude-runtime-observation.fixtures.d.ts.map +1 -0
  188. package/dist/providers/identity/claude-runtime-observation.fixtures.js +146 -0
  189. package/dist/providers/identity/claude-runtime-observation.js +229 -0
  190. package/dist/providers/identity/codex-runtime-observation.d.ts +75 -0
  191. package/dist/providers/identity/codex-runtime-observation.d.ts.map +1 -0
  192. package/dist/providers/identity/codex-runtime-observation.fixtures.d.ts +37 -0
  193. package/dist/providers/identity/codex-runtime-observation.fixtures.d.ts.map +1 -0
  194. package/dist/providers/identity/codex-runtime-observation.fixtures.js +539 -0
  195. package/dist/providers/identity/codex-runtime-observation.js +255 -0
  196. package/dist/providers/identity/generic-dispatch-record.d.ts +313 -0
  197. package/dist/providers/identity/generic-dispatch-record.d.ts.map +1 -0
  198. package/dist/providers/identity/generic-dispatch-record.js +539 -0
  199. package/dist/providers/identity/oat-dispatch-record.d.ts +1964 -0
  200. package/dist/providers/identity/oat-dispatch-record.d.ts.map +1 -0
  201. package/dist/providers/identity/oat-dispatch-record.js +691 -0
  202. package/dist/providers/identity/runtime-observation.d.ts +100 -0
  203. package/dist/providers/identity/runtime-observation.d.ts.map +1 -0
  204. package/dist/providers/identity/runtime-observation.js +169 -0
  205. package/dist/providers/shared/index.d.ts +2 -0
  206. package/dist/providers/shared/index.d.ts.map +1 -1
  207. package/dist/providers/shared/index.js +1 -0
  208. package/dist/providers/shared/materialization-extension.d.ts +12 -0
  209. package/dist/providers/shared/materialization-extension.d.ts.map +1 -1
  210. package/dist/providers/shared/registry.d.ts +69 -0
  211. package/dist/providers/shared/registry.d.ts.map +1 -0
  212. package/dist/providers/shared/registry.js +252 -0
  213. package/dist/providers/shared/restart-adviser.d.ts +26 -0
  214. package/dist/providers/shared/restart-adviser.d.ts.map +1 -0
  215. package/dist/providers/shared/restart-adviser.js +96 -0
  216. package/dist/shared/types.d.ts +4 -0
  217. package/dist/shared/types.d.ts.map +1 -1
  218. package/package.json +2 -2
package/dist/fs/io.js CHANGED
@@ -1,5 +1,7 @@
1
- import { chmod, mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile, } from 'node:fs/promises';
2
- import { dirname, isAbsolute, join, relative } from 'node:path';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, lstat, link, mkdir, open, readdir, readFile, readlink, realpath, rename, rm, stat, symlink, writeFile, } from 'node:fs/promises';
3
+ import { hostname } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
3
5
  export async function fileExists(path) {
4
6
  try {
5
7
  const s = await stat(path);
@@ -18,6 +20,7 @@ export async function dirExists(path) {
18
20
  return false;
19
21
  }
20
22
  }
23
+ const COLLECTION_LINK_UNSAFE_CODE = 'E_COLLECTION_LINK_UNSAFE';
21
24
  export async function ensureDir(dirPath) {
22
25
  await mkdir(dirPath, { recursive: true });
23
26
  }
@@ -49,6 +52,15 @@ export async function copySingleFile(src, dest) {
49
52
  const content = await readFile(src);
50
53
  await writeFile(dest, content);
51
54
  }
55
+ export async function copySingleFileNoClobber(src, dest) {
56
+ await ensureDir(dirname(dest));
57
+ const content = await readFile(src);
58
+ await writeFile(dest, content, { flag: 'wx' });
59
+ }
60
+ export async function writeFileNoClobber(dest, content) {
61
+ await ensureDir(dirname(dest));
62
+ await writeFile(dest, content, { encoding: 'utf8', flag: 'wx' });
63
+ }
52
64
  export async function createSymlink(target, linkPath, onFallback, isFile) {
53
65
  await ensureDir(dirname(linkPath));
54
66
  // Use relative symlink targets so links stay valid when the source tree
@@ -73,9 +85,530 @@ export async function createSymlink(target, linkPath, onFallback, isFile) {
73
85
  return 'copy';
74
86
  }
75
87
  }
88
+ export async function createSymlinkNoClobber(target, linkPath, isFile) {
89
+ await ensureDir(dirname(linkPath));
90
+ const symlinkTarget = isAbsolute(target)
91
+ ? relative(dirname(linkPath), target)
92
+ : target;
93
+ await symlink(symlinkTarget, linkPath, isFile ? 'file' : 'dir');
94
+ return 'symlink';
95
+ }
96
+ export async function createCollectionSymlinkNoClobber(_target, linkPath, guard) {
97
+ const scopeRoot = resolve(guard.scopeRoot);
98
+ const parent = resolve(dirname(linkPath));
99
+ const relativeParent = relative(scopeRoot, parent);
100
+ if (relativeParent === '..' ||
101
+ relativeParent.startsWith(`..${sep}`) ||
102
+ isAbsolute(relativeParent)) {
103
+ throw new Error('Collection destination parent is outside the sync scope.');
104
+ }
105
+ const parentSegments = relativeParent === '' ? [] : relativeParent.split(sep);
106
+ const ancestors = [scopeRoot];
107
+ let current = scopeRoot;
108
+ for (const segment of parentSegments) {
109
+ current = resolve(current, segment);
110
+ ancestors.push(current);
111
+ }
112
+ let nearestExisting;
113
+ for (const ancestor of [...ancestors].reverse()) {
114
+ try {
115
+ nearestExisting = await lstat(ancestor);
116
+ break;
117
+ }
118
+ catch (error) {
119
+ if (typeof error !== 'object' ||
120
+ error === null ||
121
+ !('code' in error) ||
122
+ error.code !== 'ENOENT') {
123
+ throw error;
124
+ }
125
+ }
126
+ }
127
+ if (nearestExisting === undefined ||
128
+ nearestExisting.isSymbolicLink() ||
129
+ !nearestExisting.isDirectory() ||
130
+ String(nearestExisting.dev) !== guard.expectedParent.device ||
131
+ String(nearestExisting.ino) !== guard.expectedParent.inode) {
132
+ throw new Error('Collection destination ancestry changed before creation.');
133
+ }
134
+ for (const ancestor of ancestors) {
135
+ let ancestorStat;
136
+ try {
137
+ ancestorStat = await lstat(ancestor);
138
+ }
139
+ catch (error) {
140
+ if (typeof error !== 'object' ||
141
+ error === null ||
142
+ !('code' in error) ||
143
+ error.code !== 'ENOENT') {
144
+ throw error;
145
+ }
146
+ continue;
147
+ }
148
+ if (ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory()) {
149
+ throw new Error('Collection destination ancestry must contain only real directories.');
150
+ }
151
+ }
152
+ try {
153
+ await lstat(linkPath);
154
+ throw Object.assign(new Error('Collection destination already exists.'), {
155
+ code: 'EEXIST',
156
+ });
157
+ }
158
+ catch (error) {
159
+ if (typeof error !== 'object' ||
160
+ error === null ||
161
+ !('code' in error) ||
162
+ error.code !== 'ENOENT') {
163
+ throw error;
164
+ }
165
+ }
166
+ throw Object.assign(new Error('Collection link creation is disabled because this runtime has no securely guarded parent-relative symlink primitive.'), { code: COLLECTION_LINK_UNSAFE_CODE });
167
+ }
168
+ export async function removeCollectionSymlinkIfUnchanged(linkPath, created) {
169
+ try {
170
+ const current = await lstat(linkPath);
171
+ if (!current.isSymbolicLink() ||
172
+ String(current.dev) !== created.device ||
173
+ String(current.ino) !== created.inode) {
174
+ return false;
175
+ }
176
+ if ((await readlink(linkPath)) !== created.linkText) {
177
+ return false;
178
+ }
179
+ // Node's path-based unlink cannot bind the final removal to the identity
180
+ // proven above. Preserve the alias until a guarded parent-relative
181
+ // no-follow removal primitive is available.
182
+ return false;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ /**
189
+ * The one serialization of a JSON document OAT writes to disk.
190
+ *
191
+ * Every size bound that claims to constrain a published file must measure this
192
+ * exact string. Validating compact `JSON.stringify` while writing pretty-printed
193
+ * output with a trailing newline let a record validated at 65,536 bytes land on
194
+ * disk at 65,874.
195
+ */
196
+ export function serializeJsonDocument(data) {
197
+ return `${JSON.stringify(data, null, 2)}\n`;
198
+ }
76
199
  export async function atomicWriteJson(filePath, data) {
77
200
  const tempPath = `${filePath}.tmp`;
78
201
  await ensureDir(dirname(filePath));
79
- await writeFile(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
202
+ await writeFile(tempPath, serializeJsonDocument(data), 'utf8');
80
203
  await rename(tempPath, filePath);
81
204
  }
205
+ const WRITER_LOCK_RETRY_MS = 10;
206
+ const WRITER_LOCK_TIMEOUT_MS = 5000;
207
+ /** A lock younger than this is never reclaimed, so a writer that has not yet
208
+ * written its holder file is not mistaken for a crash. */
209
+ const WRITER_LOCK_MIN_RECLAIM_MS = 1000;
210
+ /**
211
+ * Hard cap. A lock older than this is reclaimed unconditionally — including one
212
+ * whose holder is provably alive — because a dispatch write is a sub-second
213
+ * operation and a holder past this bound is hung rather than working. This is
214
+ * safe because the lock is not what protects the journal: the create-only
215
+ * revision compare-and-swap is, and it holds independently of any lock, so a
216
+ * wrongly reclaimed lock costs mutual exclusion but never correctness.
217
+ */
218
+ const WRITER_LOCK_HARD_STALE_MS = 15 * 60 * 1000;
219
+ const HOLDER_FILE = 'holder.json';
220
+ /**
221
+ * Non-identifying, stable-per-host token. Liveness of a recorded pid is only
222
+ * meaningful on the host that recorded it, and the raw hostname is never
223
+ * written into a project artifact or an error message.
224
+ */
225
+ function hostId() {
226
+ return createHash('sha256')
227
+ .update(hostname(), 'utf8')
228
+ .digest('hex')
229
+ .slice(0, 16);
230
+ }
231
+ function processStartedAt() {
232
+ return Math.round(Date.now() - process.uptime() * 1000);
233
+ }
234
+ function holderIsRunning(holder) {
235
+ if (holder.hostId !== hostId()) {
236
+ // Another host's pid says nothing about this one; fall back to age alone.
237
+ return true;
238
+ }
239
+ try {
240
+ process.kill(holder.pid, 0);
241
+ return true;
242
+ }
243
+ catch (error) {
244
+ // EPERM means the process exists but is owned by somebody else.
245
+ return errorCode(error) !== 'ESRCH';
246
+ }
247
+ }
248
+ async function readWriterLockHolder(lockPath) {
249
+ try {
250
+ const parsed = JSON.parse(await readFile(join(lockPath, HOLDER_FILE), 'utf8'));
251
+ if (typeof parsed !== 'object' ||
252
+ parsed === null ||
253
+ typeof parsed.pid !== 'number' ||
254
+ typeof parsed.hostId !== 'string') {
255
+ return null;
256
+ }
257
+ return parsed;
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ }
263
+ /**
264
+ * Bounded staleness policy. A lock is reclaimable when it is old enough that a
265
+ * live writer would have finished, and either its recorded holder is provably
266
+ * gone on this host or its age exceeds the hard cap. Returns the reason so the
267
+ * caller can report what it did.
268
+ */
269
+ async function writerLockStaleReason(lockPath, minReclaimMs, hardStaleMs) {
270
+ let age;
271
+ try {
272
+ age = Date.now() - (await lstat(lockPath)).mtimeMs;
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ if (age < minReclaimMs)
278
+ return null;
279
+ const holder = await readWriterLockHolder(lockPath);
280
+ if (holder !== null && !holderIsRunning(holder)) {
281
+ return 'its recorded holder process is no longer running';
282
+ }
283
+ return age >= hardStaleMs ? 'it exceeded the staleness cap' : null;
284
+ }
285
+ /**
286
+ * Exclusive contained writer lock. `mkdir` without `recursive` is the atomic
287
+ * primitive: exactly one caller can create the directory, and every other
288
+ * caller observes `EEXIST`. The lock is validated as a real directory so a
289
+ * planted symlink fails closed instead of redirecting the guarded section, it
290
+ * records holder evidence so a crashed writer can be reclaimed rather than
291
+ * wedging the project forever, and no message it raises contains an absolute
292
+ * path.
293
+ */
294
+ export async function withContainedWriterLock(lockPath, scopeRoot, run, options = {}) {
295
+ const resolvedScope = resolve(scopeRoot);
296
+ const resolvedLock = resolve(lockPath);
297
+ if (dirname(resolvedLock) !== resolvedScope) {
298
+ throw new Error('Writer lock must live directly inside its scope root.');
299
+ }
300
+ const scopeStat = await lstat(resolvedScope);
301
+ if (scopeStat.isSymbolicLink() || !scopeStat.isDirectory()) {
302
+ throw new Error('Writer lock scope must be a real directory.');
303
+ }
304
+ const reportedLock = scopeRelative(resolvedScope, resolvedLock);
305
+ const timeoutMs = options.timeoutMs ?? WRITER_LOCK_TIMEOUT_MS;
306
+ const deadline = Date.now() + timeoutMs;
307
+ let reclaimed = false;
308
+ for (;;) {
309
+ try {
310
+ await mkdir(resolvedLock);
311
+ break;
312
+ }
313
+ catch (error) {
314
+ if (errorCode(error) !== 'EEXIST') {
315
+ throw error;
316
+ }
317
+ if (!reclaimed) {
318
+ const stale = await writerLockStaleReason(resolvedLock, options.minReclaimMs ?? WRITER_LOCK_MIN_RECLAIM_MS, options.hardStaleMs ?? WRITER_LOCK_HARD_STALE_MS);
319
+ if (stale !== null) {
320
+ const held = await lstat(resolvedLock);
321
+ if (held.isSymbolicLink() || !held.isDirectory()) {
322
+ throw new Error(`Writer lock ${reportedLock} is not a real directory.`, { cause: error });
323
+ }
324
+ await rm(resolvedLock, { recursive: true, force: true });
325
+ reclaimed = true;
326
+ continue;
327
+ }
328
+ }
329
+ if (Date.now() >= deadline) {
330
+ throw new Error(`Another writer holds ${reportedLock}. If no writer is running, remove ${reportedLock} inside the project and retry.`, { cause: error });
331
+ }
332
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, WRITER_LOCK_RETRY_MS));
333
+ }
334
+ }
335
+ const holder = {
336
+ hostId: hostId(),
337
+ pid: process.pid,
338
+ processStartedAt: processStartedAt(),
339
+ acquiredAt: new Date().toISOString(),
340
+ };
341
+ const held = await lstat(resolvedLock);
342
+ if (held.isSymbolicLink() || !held.isDirectory()) {
343
+ throw new Error(`Writer lock ${reportedLock} is not a real directory.`);
344
+ }
345
+ const lockIdentity = identityOf(held);
346
+ try {
347
+ await writeFile(join(resolvedLock, HOLDER_FILE), `${JSON.stringify(holder, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
348
+ return await run();
349
+ }
350
+ finally {
351
+ await releaseWriterLock(resolvedLock, lockIdentity, holder);
352
+ }
353
+ }
354
+ /**
355
+ * Release only a lock this call still provably holds. Both the directory
356
+ * identity captured at acquisition and the holder record must still match. A
357
+ * missing holder file counts as "not mine" — it is exactly the window in which
358
+ * a reclaiming writer has re-created the directory but not yet written its own
359
+ * record — so the replacement is left to the staleness policy rather than
360
+ * removed from under its new owner.
361
+ */
362
+ async function releaseWriterLock(lockPath, identity, holder) {
363
+ try {
364
+ const current = await lstat(lockPath);
365
+ if (current.isSymbolicLink() || !current.isDirectory())
366
+ return;
367
+ if (!sameIdentity(identityOf(current), identity))
368
+ return;
369
+ const currentHolder = await readWriterLockHolder(lockPath);
370
+ if (currentHolder === null ||
371
+ currentHolder.hostId !== holder.hostId ||
372
+ currentHolder.pid !== holder.pid ||
373
+ currentHolder.acquiredAt !== holder.acquiredAt) {
374
+ return;
375
+ }
376
+ await rm(lockPath, { recursive: true, force: true });
377
+ }
378
+ catch {
379
+ // Nothing releasable remains.
380
+ }
381
+ }
382
+ function identityOf(entry) {
383
+ return { device: String(entry.dev), inode: String(entry.ino) };
384
+ }
385
+ function sameIdentity(left, right) {
386
+ return left.device === right.device && left.inode === right.inode;
387
+ }
388
+ async function assertUnchangedIdentity(path, expected, message) {
389
+ if (!sameIdentity(identityOf(await lstat(path)), expected)) {
390
+ throw new Error(message);
391
+ }
392
+ }
393
+ /**
394
+ * Scope-relative rendering for any path this module reports. NFR1 requires that
395
+ * home paths and user-specific absolute paths stay out of durable output, so no
396
+ * error raised below may carry an absolute path.
397
+ */
398
+ function scopeRelative(resolvedScope, target) {
399
+ const rendered = relative(resolvedScope, resolve(target));
400
+ return rendered === '' ? '.' : rendered.split(sep).join('/');
401
+ }
402
+ export function journalErrorCode(error) {
403
+ return errorCode(error);
404
+ }
405
+ function errorCode(error) {
406
+ return typeof error === 'object' && error !== null && 'code' in error
407
+ ? String(error.code)
408
+ : null;
409
+ }
410
+ /**
411
+ * Re-raise a filesystem error without Node's absolute-path message text while
412
+ * preserving its `code` so callers can still branch on `EEXIST`/`ENOENT`.
413
+ * Exported so every journal producer redacts identically instead of each one
414
+ * re-deriving the rule.
415
+ */
416
+ export function redactedFsError(error, operation, relativeTarget) {
417
+ const code = errorCode(error);
418
+ const raised = new Error(`Journal ${operation} failed for ${relativeTarget}${code === null ? '' : ` (${code})`}.`, { cause: error });
419
+ return code === null ? raised : Object.assign(raised, { code });
420
+ }
421
+ /**
422
+ * Node exposes no `openat`/`renameat`/`linkat`, so a path-based final operation
423
+ * cannot be bound to a directory file descriptor. Publication is therefore
424
+ * append-only and create-only: the destination must not already exist, the only
425
+ * publishing syscall is `link`, and this module never issues `rename`, `rm`, or
426
+ * `unlink` against a publication destination. A pathname swapped after the last
427
+ * check can therefore cause at most an unreferenced create, never a replacement
428
+ * or a deletion of content this call did not create.
429
+ */
430
+ async function publicationIsMisplaced(resolvedFile, tempIdentity, parentIdentity, realScope) {
431
+ let published;
432
+ try {
433
+ published = await lstat(resolvedFile);
434
+ }
435
+ catch {
436
+ return 'the published name disappeared';
437
+ }
438
+ if (published.isSymbolicLink() || !published.isFile()) {
439
+ return 'the published name is not a regular file';
440
+ }
441
+ if (!sameIdentity(identityOf(published), tempIdentity)) {
442
+ return 'the published name is not the staged file';
443
+ }
444
+ try {
445
+ if (!sameIdentity(identityOf(await lstat(dirname(resolvedFile))), parentIdentity)) {
446
+ return 'the parent directory identity changed';
447
+ }
448
+ const currentRealParent = await realpath(dirname(resolvedFile));
449
+ const currentRelative = relative(realScope, currentRealParent);
450
+ if (currentRelative === '..' ||
451
+ currentRelative.startsWith(`..${sep}`) ||
452
+ isAbsolute(currentRelative)) {
453
+ return 'the parent directory resolves outside the allowed scope';
454
+ }
455
+ }
456
+ catch {
457
+ return 'the parent directory could not be re-validated';
458
+ }
459
+ return null;
460
+ }
461
+ /**
462
+ * Remove the staging file only when the name still resolves to the exact inode
463
+ * this call created. A swapped directory pathname therefore strands the staged
464
+ * file rather than deleting an unrelated one. Returns whether the staged data
465
+ * was provably removed, so callers can report the outcome accurately.
466
+ *
467
+ * Precisely: this removes only the *inode* this call created, not only the
468
+ * *names* it created. A privileged process that hard-links the staged file into
469
+ * a directory it controls and then swaps the journal pathname at it can have
470
+ * that alias unlinked here. No data is destroyed — the inode stays linked from
471
+ * the real directory, and no pre-existing foreign file can ever match, since a
472
+ * distinct file has a distinct dev/ino pair.
473
+ */
474
+ async function removeStagedFile(tempPath, tempIdentity) {
475
+ try {
476
+ if (tempIdentity === null) {
477
+ await rm(tempPath, { force: true });
478
+ return 'removed';
479
+ }
480
+ const current = await lstat(tempPath);
481
+ if (!sameIdentity(identityOf(current), tempIdentity)) {
482
+ return 'stranded';
483
+ }
484
+ await rm(tempPath, { force: true });
485
+ return 'removed';
486
+ }
487
+ catch {
488
+ return 'stranded';
489
+ }
490
+ }
491
+ /**
492
+ * Publish one immutable JSON journal revision.
493
+ *
494
+ * Guarantees, stated as what the code does:
495
+ * - The destination is created with `link` only. An occupied destination raises
496
+ * `EEXIST`; the destination is never renamed onto, removed, or truncated.
497
+ * - After publication the destination is proven to be the exact staged inode,
498
+ * inside the same directory inode validated before staging, still resolving
499
+ * inside `scopeRoot`. Failure raises and removes nothing.
500
+ * - Staging cleanup removes only the inode this call created.
501
+ * - No raised message contains an absolute path.
502
+ *
503
+ * Residual, unchanged by this design: a privileged concurrent process that
504
+ * swaps the validated directory pathname can cause the `link` to create an
505
+ * unreferenced file under a directory it controls, and can strand the staged
506
+ * file. Neither outcome replaces or deletes pre-existing content.
507
+ */
508
+ export async function publishContainedJsonRevision(filePath, data, scopeRoot) {
509
+ const resolvedScope = resolve(scopeRoot);
510
+ const resolvedFile = resolve(filePath);
511
+ const relativeFile = relative(resolvedScope, resolvedFile);
512
+ if (relativeFile === '..' ||
513
+ relativeFile.startsWith(`..${sep}`) ||
514
+ isAbsolute(relativeFile)) {
515
+ throw new Error('JSON journal destination is outside its allowed scope.');
516
+ }
517
+ const reportedFile = scopeRelative(resolvedScope, resolvedFile);
518
+ const scopeStat = await lstat(resolvedScope);
519
+ if (scopeStat.isSymbolicLink() || !scopeStat.isDirectory()) {
520
+ throw new Error('JSON journal scope must be a real directory.');
521
+ }
522
+ const realScope = await realpath(resolvedScope);
523
+ const parentRelative = relative(resolvedScope, dirname(resolvedFile));
524
+ const segments = parentRelative === '' ? [] : parentRelative.split(sep);
525
+ let current = resolvedScope;
526
+ for (const segment of segments) {
527
+ current = join(current, segment);
528
+ try {
529
+ const currentStat = await lstat(current);
530
+ if (currentStat.isSymbolicLink() || !currentStat.isDirectory()) {
531
+ throw new Error('JSON journal ancestry must not contain symlinks and must contain only real directories.');
532
+ }
533
+ }
534
+ catch (error) {
535
+ if (errorCode(error) !== 'ENOENT') {
536
+ throw error;
537
+ }
538
+ await mkdir(current);
539
+ }
540
+ }
541
+ const realParent = await realpath(dirname(resolvedFile));
542
+ const relativeParent = relative(realScope, realParent);
543
+ if (relativeParent === '..' ||
544
+ relativeParent.startsWith(`..${sep}`) ||
545
+ isAbsolute(relativeParent)) {
546
+ throw new Error('JSON journal ancestry resolves outside its allowed scope.');
547
+ }
548
+ // Publication is create-only, so an already-published revision is a lost
549
+ // compare-and-swap rather than something to overwrite.
550
+ try {
551
+ await lstat(resolvedFile);
552
+ throw Object.assign(new Error(`Journal revision ${reportedFile} already exists.`), { code: 'EEXIST' });
553
+ }
554
+ catch (error) {
555
+ if (errorCode(error) !== 'ENOENT') {
556
+ throw error;
557
+ }
558
+ }
559
+ const parentIdentity = identityOf(await lstat(dirname(resolvedFile)));
560
+ const content = serializeJsonDocument(data);
561
+ const tempPath = join(dirname(resolvedFile), `.${basename(resolvedFile)}.${randomUUID()}.tmp`);
562
+ const reportedTemp = scopeRelative(resolvedScope, tempPath);
563
+ let tempCreated = false;
564
+ let stagedIdentity = null;
565
+ try {
566
+ let handle;
567
+ try {
568
+ handle = await open(tempPath, 'wx');
569
+ }
570
+ catch (error) {
571
+ throw redactedFsError(error, 'staging', reportedTemp);
572
+ }
573
+ let tempIdentity;
574
+ try {
575
+ tempCreated = true;
576
+ await handle.writeFile(content, 'utf8');
577
+ tempIdentity = identityOf(await handle.stat());
578
+ stagedIdentity = tempIdentity;
579
+ }
580
+ finally {
581
+ await handle.close();
582
+ }
583
+ // Last ancestry check, then the identity binding that narrows the window
584
+ // between that check and the path-based publication below.
585
+ if ((await realpath(dirname(resolvedFile))) !== realParent) {
586
+ throw new Error('JSON journal ancestry changed before publication.');
587
+ }
588
+ await assertUnchangedIdentity(dirname(resolvedFile), parentIdentity, 'JSON journal directory identity changed before publication.');
589
+ await assertUnchangedIdentity(tempPath, tempIdentity, 'JSON journal staging file identity changed before publication.');
590
+ try {
591
+ await link(tempPath, resolvedFile);
592
+ }
593
+ catch (error) {
594
+ throw redactedFsError(error, 'publication', reportedFile);
595
+ }
596
+ const misplaced = await publicationIsMisplaced(resolvedFile, tempIdentity, parentIdentity, realScope);
597
+ if (misplaced) {
598
+ // Never remove the destination: under a swapped pathname it may name
599
+ // content this call did not create.
600
+ const staged = await removeStagedFile(tempPath, tempIdentity);
601
+ tempCreated = false;
602
+ throw new Error(`Journal revision ${reportedFile} could not be verified inside the validated directory (${misplaced}). It was not published to the project journal, and this call removed and replaced nothing. ${staged === 'removed'
603
+ ? 'Staged data was removed.'
604
+ : 'A staged copy may remain outside the validated directory.'}`);
605
+ }
606
+ await removeStagedFile(tempPath, tempIdentity);
607
+ tempCreated = false;
608
+ }
609
+ finally {
610
+ if (tempCreated) {
611
+ await removeStagedFile(tempPath, stagedIdentity);
612
+ }
613
+ }
614
+ }
@@ -1,5 +1,5 @@
1
1
  export { computeDirectoryHash } from './hash.js';
2
2
  export { addEntry, createEmptyManifest, findEntry, loadManifest, removeEntry, saveManifest, } from './manager.js';
3
- export type { Manifest, ManifestEntry } from './manifest.types.js';
4
- export { ManifestEntrySchema, ManifestSchema } from './manifest.types.js';
3
+ export type { Manifest, ManifestCollectionEntry, ManifestEntry, ManifestEntryV2, ManifestV1, ManifestV2, } from './manifest.types.js';
4
+ export { ManifestCollectionEntrySchema, ManifestEntrySchema, ManifestSchema, ManifestV1Schema, ManifestV2Schema, } from './manifest.types.js';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/manifest/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,SAAS,EACT,YAAY,EACZ,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/manifest/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,SAAS,EACT,YAAY,EACZ,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,QAAQ,EACR,uBAAuB,EACvB,aAAa,EACb,eAAe,EACf,UAAU,EACV,UAAU,GACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,6BAA6B,EAC7B,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,kBAAkB,CAAC"}
@@ -1,3 +1,3 @@
1
1
  export { computeDirectoryHash } from './hash.js';
2
2
  export { addEntry, createEmptyManifest, findEntry, loadManifest, removeEntry, saveManifest, } from './manager.js';
3
- export { ManifestEntrySchema, ManifestSchema } from './manifest.types.js';
3
+ export { ManifestCollectionEntrySchema, ManifestEntrySchema, ManifestSchema, ManifestV1Schema, ManifestV2Schema, } from './manifest.types.js';
@@ -1,7 +1,7 @@
1
- import { type Manifest, type ManifestEntry } from './manifest.types.js';
1
+ import { type Manifest, type ManifestEntry, type ManifestV2 } from './manifest.types.js';
2
2
  export declare function createEmptyManifest(): Manifest;
3
3
  export declare function loadManifest(manifestPath: string): Promise<Manifest>;
4
- export declare function saveManifest(manifestPath: string, manifest: Manifest): Promise<void>;
4
+ export declare function saveManifest(manifestPath: string, manifest: ManifestV2): Promise<void>;
5
5
  export declare function findEntry(manifest: Manifest, canonicalPath: string, provider: string): ManifestEntry | undefined;
6
6
  export declare function addEntry(manifest: Manifest, entry: ManifestEntry): Manifest;
7
7
  export declare function removeEntry(manifest: Manifest, canonicalPath: string, provider: string): Manifest;
@@ -1 +1 @@
1
- {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/manifest/manager.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,aAAa,EAEnB,MAAM,kBAAkB,CAAC;AAa1B,wBAAgB,mBAAmB,IAAI,QAAQ,CAO9C;AAED,wBAAsB,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CA6C1E;AAED,wBAAsB,YAAY,CAChC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,QAAQ,GACjB,OAAO,CAAC,IAAI,CAAC,CAWf;AAED,wBAAgB,SAAS,CACvB,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GACf,aAAa,GAAG,SAAS,CAK3B;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,GAAG,QAAQ,CAc3E;AAED,wBAAgB,WAAW,CACzB,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GACf,QAAQ,CAeV"}
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/manifest/manager.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,UAAU,EAGhB,MAAM,kBAAkB,CAAC;AAa1B,wBAAgB,mBAAmB,IAAI,QAAQ,CAQ9C;AAED,wBAAsB,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAmE1E;AAED,wBAAsB,YAAY,CAChC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,UAAU,GACnB,OAAO,CAAC,IAAI,CAAC,CAWf;AAED,wBAAgB,SAAS,CACvB,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GACf,aAAa,GAAG,SAAS,CAK3B;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,GAAG,QAAQ,CAc3E;AAED,wBAAgB,WAAW,CACzB,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GACf,QAAQ,CAeV"}
@@ -3,7 +3,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { CliError } from '../errors/index.js';
5
5
  import { OAT_VERSION } from '../shared/oat-version.js';
6
- import { ManifestSchema, } from './manifest.types.js';
6
+ import { ManifestSchema, ManifestV2Schema, } from './manifest.types.js';
7
7
  function formatIssuePath(path) {
8
8
  if (path.length === 0) {
9
9
  return '(root)';
@@ -15,9 +15,10 @@ function nowIso() {
15
15
  }
16
16
  export function createEmptyManifest() {
17
17
  return {
18
- version: 1,
18
+ version: 2,
19
19
  oatVersion: OAT_VERSION,
20
20
  entries: [],
21
+ collections: [],
21
22
  lastUpdated: nowIso(),
22
23
  };
23
24
  }
@@ -31,6 +32,12 @@ export async function loadManifest(manifestPath) {
31
32
  catch {
32
33
  throw new CliError(`Manifest at ${manifestPath} is not valid JSON. Delete or repair the file and re-run oat sync.`);
33
34
  }
35
+ if (typeof parsed !== 'object' ||
36
+ parsed === null ||
37
+ !('version' in parsed) ||
38
+ (parsed.version !== 1 && parsed.version !== 2)) {
39
+ throw new CliError(`Manifest at ${manifestPath} failed validation: version must be 1 or 2. Delete or repair the file and re-run oat sync.`);
40
+ }
34
41
  const result = ManifestSchema.safeParse(parsed);
35
42
  if (!result.success) {
36
43
  const issueDetails = result.error.issues
@@ -38,6 +45,16 @@ export async function loadManifest(manifestPath) {
38
45
  .join('; ');
39
46
  throw new CliError(`Manifest at ${manifestPath} failed validation: ${issueDetails}. Delete or repair the file and re-run oat sync.`);
40
47
  }
48
+ if (result.data.version === 1) {
49
+ return {
50
+ ...result.data,
51
+ version: 2,
52
+ entries: result.data.entries.map((entry) => ({ ...entry })),
53
+ collections: [],
54
+ };
55
+ }
56
+ // Collection entries are consumed by the collection planner before the
57
+ // existing per-entry engine receives this compatibility view.
41
58
  return result.data;
42
59
  }
43
60
  catch (error) {
@@ -54,7 +71,7 @@ export async function loadManifest(manifestPath) {
54
71
  }
55
72
  }
56
73
  export async function saveManifest(manifestPath, manifest) {
57
- const validated = ManifestSchema.parse({
74
+ const validated = ManifestV2Schema.parse({
58
75
  ...manifest,
59
76
  oatVersion: OAT_VERSION,
60
77
  });