@skrr-ai/cli 0.1.94 → 0.1.95

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 (120) hide show
  1. package/README.md +153 -165
  2. package/bin/run.js +32 -0
  3. package/bin/update-npm-runner.cjs +33 -0
  4. package/bin/update-session.cjs +194 -0
  5. package/bin/update-worker.cjs +208 -0
  6. package/dist/base-command.js +0 -15
  7. package/dist/commands/code/doctor.d.ts +1 -0
  8. package/dist/commands/code/doctor.js +6 -0
  9. package/dist/commands/code/index.js +4 -0
  10. package/dist/commands/code/install.d.ts +5 -62
  11. package/dist/commands/code/install.js +86 -279
  12. package/dist/commands/code/rollback.d.ts +8 -0
  13. package/dist/commands/code/rollback.js +20 -0
  14. package/dist/commands/code/update.d.ts +8 -37
  15. package/dist/commands/code/update.js +134 -213
  16. package/dist/commands/daemon/install.js +4 -0
  17. package/dist/commands/generate-recovery-code.d.ts +13 -1
  18. package/dist/commands/generate-recovery-code.js +146 -13
  19. package/dist/commands/instructions/guide.d.ts +20 -0
  20. package/dist/commands/instructions/guide.js +84 -0
  21. package/dist/commands/instructions/install.js +57 -33
  22. package/dist/commands/instructions/setup.d.ts +14 -0
  23. package/dist/commands/instructions/setup.js +72 -0
  24. package/dist/commands/integrations/execute.js +1 -1
  25. package/dist/commands/login.d.ts +5 -6
  26. package/dist/commands/login.js +27 -11
  27. package/dist/commands/logout.js +7 -7
  28. package/dist/commands/security/overview.d.ts +11 -0
  29. package/dist/commands/security/overview.js +158 -0
  30. package/dist/commands/spaces/claim.d.ts +12 -0
  31. package/dist/commands/spaces/claim.js +42 -0
  32. package/dist/commands/spaces/ownership.d.ts +12 -0
  33. package/dist/commands/spaces/ownership.js +46 -0
  34. package/dist/commands/tasks/cancel-cascade.js +1 -1
  35. package/dist/commands/tasks/create-tree.d.ts +5 -1
  36. package/dist/commands/tasks/create-tree.js +35 -7
  37. package/dist/commands/tasks/interrupt.js +2 -2
  38. package/dist/commands/tasks/pause-schedule.js +1 -1
  39. package/dist/commands/token/list.d.ts +3 -5
  40. package/dist/commands/token/list.js +99 -57
  41. package/dist/commands/update.d.ts +14 -0
  42. package/dist/commands/update.js +61 -0
  43. package/dist/generated/managed-instruction-guides.json +9 -0
  44. package/dist/hooks/init.d.ts +5 -0
  45. package/dist/hooks/init.js +27 -0
  46. package/dist/lib/commitments.d.ts +0 -8
  47. package/dist/lib/commitments.js +0 -52
  48. package/dist/lib/daemon-installer.d.ts +2 -0
  49. package/dist/lib/daemon-installer.js +100 -24
  50. package/dist/lib/first-party-harness-doctor.js +31 -1
  51. package/dist/lib/first-party-harness-remote-install.d.ts +71 -0
  52. package/dist/lib/first-party-harness-remote-install.js +310 -0
  53. package/dist/lib/first-party-harness-remote-update.d.ts +46 -0
  54. package/dist/lib/first-party-harness-remote-update.js +242 -0
  55. package/dist/lib/first-party-harness-startup-update.d.ts +26 -0
  56. package/dist/lib/first-party-harness-startup-update.js +166 -0
  57. package/dist/lib/first-party-harness-update.d.ts +46 -0
  58. package/dist/lib/first-party-harness-update.js +289 -0
  59. package/dist/lib/first-party-harness.js +7 -4
  60. package/dist/lib/managed-instruction-local.d.ts +12 -0
  61. package/dist/lib/managed-instruction-local.js +58 -0
  62. package/dist/lib/managed-instruction-setup.d.ts +35 -0
  63. package/dist/lib/managed-instruction-setup.js +306 -0
  64. package/dist/lib/memory-sync.d.ts +1 -0
  65. package/dist/lib/memory-sync.js +279 -68
  66. package/dist/lib/platform-update-local.d.ts +2 -0
  67. package/dist/lib/platform-update-local.js +113 -0
  68. package/dist/lib/platform-update.d.ts +39 -0
  69. package/dist/lib/platform-update.js +84 -0
  70. package/dist/lib/profile-machine-identity-guard.d.ts +2 -0
  71. package/dist/lib/profile-machine-identity-guard.js +13 -0
  72. package/dist/lib/prompt.d.ts +1 -0
  73. package/dist/lib/prompt.js +4 -3
  74. package/dist/lib/task-batch.d.ts +9 -0
  75. package/dist/lib/task-batch.js +94 -17
  76. package/dist/lib/task-instruction-offer.d.ts +1 -4
  77. package/dist/lib/task-instruction-offer.js +3 -129
  78. package/dist/lib/update-check.d.ts +61 -53
  79. package/dist/lib/update-check.js +453 -314
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessChannels.d.ts +3 -4
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessChannels.js +3 -4
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessReleaseKeys.d.ts +82 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessReleaseKeys.js +105 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessUpdater.d.ts +110 -0
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessUpdater.js +945 -0
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/harnessPermissionControls.d.ts +32 -0
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/harnessPermissionControls.js +62 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +4 -1
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +35 -5
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releasePolicy.d.ts +63 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releasePolicy.js +169 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseVersions.d.ts +2 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseVersions.js +44 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessChannels.d.ts +3 -4
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessChannels.js +3 -4
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessReleaseKeys.d.ts +82 -0
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessReleaseKeys.js +102 -0
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessUpdater.d.ts +110 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessUpdater.js +923 -0
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/harnessPermissionControls.d.ts +32 -0
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/harnessPermissionControls.js +59 -0
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -0
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -0
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +35 -5
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releasePolicy.d.ts +63 -0
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releasePolicy.js +163 -0
  108. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseVersions.d.ts +2 -0
  109. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseVersions.js +41 -0
  110. package/dist/node_modules/@skrr-ai/auth-core/package.json +31 -1
  111. package/dist/node_modules/@skrr-ai/data-provider/index.js +6584 -6567
  112. package/docs/v1/README.md +28 -0
  113. package/docs/v1/api.md +55 -0
  114. package/docs/v1/changelog.md +16 -0
  115. package/docs/v1/check-examples.mjs +178 -0
  116. package/docs/v1/examples/api-readonly.mjs +144 -0
  117. package/docs/v1/operations.md +87 -0
  118. package/docs/v1/quickstart.md +71 -0
  119. package/oclif.manifest.json +46007 -45462
  120. package/package.json +13 -11
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.MEMORY_SYNC_TOMBSTONES = exports.MEMORY_SYNC_CONFLICTS = exports.MEMORY_SYNC_BASIS = exports.MEMORY_SYNC_MANIFEST = void 0;
6
+ exports.MEMORY_SYNC_TOMBSTONES = exports.MEMORY_SYNC_QUARANTINE = exports.MEMORY_SYNC_CONFLICTS = exports.MEMORY_SYNC_BASIS = exports.MEMORY_SYNC_MANIFEST = void 0;
7
7
  exports.fileForMemoryPath = fileForMemoryPath;
8
8
  exports.readMaterializationBinding = readMaterializationBinding;
9
9
  exports.assertMaterializationSpaceContext = assertMaterializationSpaceContext;
@@ -22,6 +22,7 @@ const node_path_1 = __importDefault(require("node:path"));
22
22
  exports.MEMORY_SYNC_MANIFEST = '.memory-sync';
23
23
  exports.MEMORY_SYNC_BASIS = '.memory-sync-basis';
24
24
  exports.MEMORY_SYNC_CONFLICTS = '.memory-conflicts';
25
+ exports.MEMORY_SYNC_QUARANTINE = '.memory-quarantine';
25
26
  exports.MEMORY_SYNC_TOMBSTONES = '.memory-tombstones';
26
27
  function codedError(code, message) {
27
28
  const exitCode = code.includes('NOT_FOUND')
@@ -127,10 +128,12 @@ async function readJson(root, target) {
127
128
  catch (error) {
128
129
  if (error?.code === 'ENOENT')
129
130
  return null;
131
+ if (['MEMORY_SYNC_SYMLINK_DENIED', 'MEMORY_SYNC_PATH_ESCAPE'].includes(error?.code))
132
+ throw error;
130
133
  throw codedError('MEMORY_SYNC_METADATA_INVALID', `Invalid sync metadata: ${target}`);
131
134
  }
132
135
  }
133
- async function atomicWrite(root, target, content) {
136
+ async function atomicWrite(root, target, content, { replace = true } = {}) {
134
137
  await assertSafeTarget(root, target, { createParents: true });
135
138
  const tmp = `${target}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
136
139
  await assertSafeTarget(root, tmp);
@@ -139,7 +142,23 @@ async function atomicWrite(root, target, content) {
139
142
  // Recheck immediately before the atomic replace. Another process may have
140
143
  // swapped a parent after the first validation.
141
144
  await assertSafeTarget(root, target);
142
- await promises_1.default.rename(tmp, target);
145
+ if (replace) {
146
+ await promises_1.default.rename(tmp, target);
147
+ }
148
+ else {
149
+ try {
150
+ // Publishing an untracked destination must never replace a file that
151
+ // appeared after preflight. link is atomic and refuses an existing name.
152
+ await promises_1.default.link(tmp, target);
153
+ }
154
+ catch (error) {
155
+ if (error?.code !== 'EEXIST')
156
+ throw error;
157
+ if ((await localHash(root, target)) !== hash(content)) {
158
+ throw codedError('MEMORY_SYNC_LOCAL_FILE_CONFLICT', `A local file now occupies ${node_path_1.default.relative(root, target)}; preserve it before pulling`);
159
+ }
160
+ }
161
+ }
143
162
  }
144
163
  finally {
145
164
  await promises_1.default.unlink(tmp).catch((error) => {
@@ -207,7 +226,104 @@ async function localHash(root, target) {
207
226
  async function writeConflict(root, conflict, dryRun) {
208
227
  if (dryRun)
209
228
  return;
210
- await atomicJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS, `${conflict.id}.json`), conflict);
229
+ const target = node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS, `${conflict.id}.json`);
230
+ const existing = await readJson(root, target);
231
+ if (typeof existing?.quarantineFile === 'string' &&
232
+ existing.quarantineFile !== conflict.quarantineFile &&
233
+ (await localHash(root, node_path_1.default.join(root, existing.quarantineFile))) !== null) {
234
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', `Resolve the preserved edit for ${conflict.id} before recording another conflict`);
235
+ }
236
+ await atomicJson(root, target, conflict);
237
+ }
238
+ async function quarantineRemovedTopic(root, prior, dryRun, localFileHash, movedHead) {
239
+ const conflictPath = node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS, `${prior.memoryId}.json`);
240
+ const existing = await readJson(root, conflictPath);
241
+ const type = movedHead ? 'remote_move_local_edit' : 'remote_delete_local_edit';
242
+ const matchingIntent = existing?.type === type &&
243
+ existing.file === prior.file &&
244
+ existing.baseVersionId === prior.versionId &&
245
+ typeof existing.quarantineFile === 'string' &&
246
+ (!movedHead ||
247
+ (existing.recoveryFile === fileForMemoryPath(movedHead.path) &&
248
+ existing.remoteVersionId === movedHead.versionId &&
249
+ existing.remoteFileHash === hash(movedHead.document)));
250
+ if (!matchingIntent &&
251
+ typeof existing?.quarantineFile === 'string' &&
252
+ (await localHash(root, node_path_1.default.join(root, existing.quarantineFile))) !== null) {
253
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', `Resolve the preserved edit for ${prior.memoryId} before applying another retirement or move`);
254
+ }
255
+ const relative = matchingIntent
256
+ ? existing.quarantineFile
257
+ : node_path_1.default.join(exports.MEMORY_SYNC_QUARANTINE, `${safeSegment(prior.memoryId)}-${(0, node_crypto_1.randomUUID)()}.md`);
258
+ if (matchingIntent && (await localHash(root, node_path_1.default.join(root, relative))) !== null) {
259
+ // A previous attempt already moved this exact edit. Its former active
260
+ // path may now hold another topic; preserve both and the original receipt.
261
+ return relative;
262
+ }
263
+ if (!dryRun) {
264
+ const source = node_path_1.default.join(root, prior.file);
265
+ const target = node_path_1.default.join(root, relative);
266
+ await assertSafeTarget(root, source);
267
+ await assertSafeTarget(root, target, { createParents: true });
268
+ // Persist the recovery intent BEFORE moving the edit. If the process
269
+ // crashes after rename, the preserved file still has an indexed receipt.
270
+ await writeConflict(root, {
271
+ id: prior.memoryId,
272
+ type,
273
+ memoryId: prior.memoryId,
274
+ file: prior.file,
275
+ baseVersionId: prior.versionId,
276
+ quarantineFile: relative,
277
+ localFileHash,
278
+ ...(movedHead
279
+ ? {
280
+ recoveryFile: fileForMemoryPath(movedHead.path),
281
+ remoteVersionId: movedHead.versionId,
282
+ remoteFileHash: hash(movedHead.document),
283
+ }
284
+ : {}),
285
+ detectedAt: new Date().toISOString(),
286
+ }, false);
287
+ await promises_1.default.rename(source, target);
288
+ }
289
+ return relative;
290
+ }
291
+ async function assertRemoteTarget(root, remote, entries, retiringIds = new Set()) {
292
+ const relative = fileForMemoryPath(remote.path);
293
+ const owner = Object.values(entries).find((entry) => entry.file === relative);
294
+ if (owner && owner.memoryId !== remote.memoryId) {
295
+ if (retiringIds.has(owner.memoryId))
296
+ return;
297
+ throw codedError('MEMORY_SYNC_PATH_CONFLICT', `Remote topic ${remote.memoryId} targets a path owned by ${owner.memoryId}; reconcile these paths before pulling`);
298
+ }
299
+ if (entries[remote.memoryId]?.file === relative)
300
+ return;
301
+ const currentHash = await localHash(root, node_path_1.default.join(root, relative));
302
+ if (currentHash !== null && currentHash !== hash(remote.document)) {
303
+ throw codedError('MEMORY_SYNC_LOCAL_FILE_CONFLICT', `Remote topic ${remote.memoryId} would overwrite an untracked file at ${relative}; preserve or move that file before pulling`);
304
+ }
305
+ }
306
+ async function reconcileRemoteMove(root, prior, remote, dryRun) {
307
+ const existing = await readJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS, `${prior.memoryId}.json`));
308
+ const alreadyQuarantined = existing?.type === 'remote_move_local_edit' &&
309
+ existing.file === prior.file &&
310
+ existing.baseVersionId === prior.versionId &&
311
+ typeof existing.quarantineFile === 'string' &&
312
+ (await localHash(root, node_path_1.default.join(root, existing.quarantineFile))) !== null;
313
+ const source = node_path_1.default.join(root, prior.file);
314
+ const currentHash = await localHash(root, source);
315
+ if (alreadyQuarantined || (currentHash !== null && currentHash !== prior.fileHash)) {
316
+ await quarantineRemovedTopic(root, prior, dryRun, currentHash || existing?.localFileHash || prior.fileHash, remote);
317
+ return true;
318
+ }
319
+ if (!dryRun) {
320
+ await assertSafeTarget(root, source);
321
+ await promises_1.default.unlink(source).catch((error) => {
322
+ if (error?.code !== 'ENOENT')
323
+ throw error;
324
+ });
325
+ }
326
+ return false;
211
327
  }
212
328
  async function pullSnapshot(rootInput, snapshot, { dryRun = false } = {}) {
213
329
  const root = await assertSafeRoot(rootInput, true);
@@ -215,15 +331,56 @@ async function pullSnapshot(rootInput, snapshot, { dryRun = false } = {}) {
215
331
  validateBinding(snapshot, manifest, basis);
216
332
  const nextEntries = { ...(basis?.entries || {}) };
217
333
  const remoteIds = new Set(snapshot.entries.map((entry) => entry.memoryId));
334
+ const retiringIds = new Set(Object.keys(nextEntries).filter((id) => !remoteIds.has(id)));
218
335
  let changed = 0;
219
336
  let conflicts = 0;
337
+ // Check all destinations before retiring any old path. A colliding local
338
+ // file must leave both the old materialization and the cursor intact.
339
+ for (const remote of snapshot.entries) {
340
+ await assertRemoteTarget(root, remote, nextEntries, retiringIds);
341
+ }
342
+ // Retire old identities before materializing replacements at the same path.
343
+ // This also reconciles a crash after quarantine rename but before basis save.
344
+ for (const [memoryId, prior] of Object.entries(nextEntries)) {
345
+ if (remoteIds.has(memoryId))
346
+ continue;
347
+ const target = node_path_1.default.join(root, prior.file);
348
+ const existing = await readJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS, `${memoryId}.json`));
349
+ const alreadyQuarantined = existing?.type === 'remote_delete_local_edit' &&
350
+ existing.file === prior.file &&
351
+ existing.baseVersionId === prior.versionId &&
352
+ typeof existing.quarantineFile === 'string' &&
353
+ (await localHash(root, node_path_1.default.join(root, existing.quarantineFile))) !== null;
354
+ const currentHash = await localHash(root, target);
355
+ if (alreadyQuarantined || (currentHash !== null && currentHash !== prior.fileHash)) {
356
+ conflicts += 1;
357
+ await quarantineRemovedTopic(root, prior, dryRun, currentHash || existing?.localFileHash || prior.fileHash);
358
+ }
359
+ else if (!dryRun) {
360
+ await assertSafeTarget(root, target);
361
+ await promises_1.default.unlink(target).catch((error) => {
362
+ if (error?.code !== 'ENOENT')
363
+ throw error;
364
+ });
365
+ }
366
+ changed += 1;
367
+ delete nextEntries[memoryId];
368
+ }
220
369
  for (const remote of snapshot.entries) {
221
370
  const relative = fileForMemoryPath(remote.path);
222
371
  const target = node_path_1.default.join(root, relative);
223
372
  const prior = nextEntries[remote.memoryId];
373
+ const virtuallyRetired = dryRun &&
374
+ Object.values(basis?.entries || {}).some((entry) => entry.file === relative && retiringIds.has(entry.memoryId));
375
+ if (!virtuallyRetired)
376
+ await assertRemoteTarget(root, remote, nextEntries);
377
+ const moved = Boolean(prior && prior.file !== relative);
378
+ if (prior && moved && (await reconcileRemoteMove(root, prior, remote, dryRun))) {
379
+ conflicts += 1;
380
+ }
224
381
  const currentHash = await localHash(root, target);
225
- const localChanged = Boolean(prior && currentHash !== prior.fileHash);
226
- const remoteChanged = !prior || prior.versionId !== remote.versionId;
382
+ const localChanged = Boolean(prior && !moved && currentHash !== prior.fileHash);
383
+ const remoteChanged = !prior || moved || prior.versionId !== remote.versionId;
227
384
  if (prior && localChanged && remoteChanged) {
228
385
  conflicts += 1;
229
386
  await writeConflict(root, {
@@ -241,7 +398,9 @@ async function pullSnapshot(rootInput, snapshot, { dryRun = false } = {}) {
241
398
  if (remoteChanged || currentHash === null) {
242
399
  changed += 1;
243
400
  if (!dryRun)
244
- await atomicWrite(root, target, remote.document);
401
+ await atomicWrite(root, target, remote.document, {
402
+ replace: Boolean(prior && !moved && currentHash !== null),
403
+ });
245
404
  }
246
405
  const nextHash = hash(remote.document);
247
406
  nextEntries[remote.memoryId] = {
@@ -253,33 +412,6 @@ async function pullSnapshot(rootInput, snapshot, { dryRun = false } = {}) {
253
412
  contentHash: remote.contentHash,
254
413
  };
255
414
  }
256
- for (const [memoryId, prior] of Object.entries(nextEntries)) {
257
- if (remoteIds.has(memoryId))
258
- continue;
259
- const target = node_path_1.default.join(root, prior.file);
260
- const currentHash = await localHash(root, target);
261
- if (currentHash !== null && currentHash !== prior.fileHash) {
262
- conflicts += 1;
263
- await writeConflict(root, {
264
- id: memoryId,
265
- type: 'remote_delete_local_edit',
266
- memoryId,
267
- file: prior.file,
268
- baseVersionId: prior.versionId,
269
- detectedAt: new Date().toISOString(),
270
- }, dryRun);
271
- continue;
272
- }
273
- changed += 1;
274
- if (!dryRun) {
275
- await assertSafeTarget(root, target);
276
- await promises_1.default.unlink(target).catch((error) => {
277
- if (error?.code !== 'ENOENT')
278
- throw error;
279
- });
280
- }
281
- delete nextEntries[memoryId];
282
- }
283
415
  if (!dryRun) {
284
416
  await atomicWrite(root, node_path_1.default.join(root, 'MEMORY.md'), snapshot.index);
285
417
  await atomicJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_MANIFEST), {
@@ -333,8 +465,13 @@ async function pullChanges(rootInput, delta, { dryRun = false } = {}) {
333
465
  }
334
466
  const relative = fileForMemoryPath(item.head.path);
335
467
  const target = node_path_1.default.join(root, relative);
468
+ await assertRemoteTarget(root, item.head, nextEntries);
469
+ const moved = Boolean(prior && prior.file !== relative);
470
+ if (prior && moved && (await reconcileRemoteMove(root, prior, item.head, dryRun))) {
471
+ conflicts += 1;
472
+ }
336
473
  const currentHash = await localHash(root, target);
337
- if (prior && currentHash !== prior.fileHash) {
474
+ if (prior && !moved && currentHash !== prior.fileHash) {
338
475
  conflicts += 1;
339
476
  await writeConflict(root, {
340
477
  id: memoryId,
@@ -350,7 +487,9 @@ async function pullChanges(rootInput, delta, { dryRun = false } = {}) {
350
487
  }
351
488
  changed += 1;
352
489
  if (!dryRun)
353
- await atomicWrite(root, target, item.head.document);
490
+ await atomicWrite(root, target, item.head.document, {
491
+ replace: Boolean(prior && !moved && currentHash !== null),
492
+ });
354
493
  nextEntries[memoryId] = {
355
494
  memoryId,
356
495
  path: item.head.path,
@@ -371,17 +510,18 @@ async function pullChanges(rootInput, delta, { dryRun = false } = {}) {
371
510
  if (!prior)
372
511
  continue;
373
512
  const target = node_path_1.default.join(root, prior.file);
513
+ const existing = await readJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS, `${memoryId}.json`));
514
+ const alreadyQuarantined = existing?.type === 'remote_delete_local_edit' &&
515
+ existing.file === prior.file &&
516
+ existing.baseVersionId === prior.versionId &&
517
+ typeof existing.quarantineFile === 'string' &&
518
+ (await localHash(root, node_path_1.default.join(root, existing.quarantineFile))) !== null;
374
519
  const currentHash = await localHash(root, target);
375
- if (currentHash !== null && currentHash !== prior.fileHash) {
520
+ if (alreadyQuarantined || (currentHash !== null && currentHash !== prior.fileHash)) {
376
521
  conflicts += 1;
377
- await writeConflict(root, {
378
- id: memoryId,
379
- type: 'remote_delete_local_edit',
380
- memoryId,
381
- file: prior.file,
382
- baseVersionId: prior.versionId,
383
- detectedAt: new Date().toISOString(),
384
- }, dryRun);
522
+ await quarantineRemovedTopic(root, prior, dryRun, currentHash || existing?.localFileHash || prior.fileHash);
523
+ changed += 1;
524
+ delete nextEntries[memoryId];
385
525
  continue;
386
526
  }
387
527
  changed += 1;
@@ -609,14 +749,80 @@ async function resolveConflict(rootInput, conflictId, strategy, manualContent) {
609
749
  if (!conflict)
610
750
  throw codedError('MEMORY_SYNC_CONFLICT_NOT_FOUND', `Conflict not found: ${conflictId}`);
611
751
  const target = node_path_1.default.join(root, conflict.file);
752
+ const quarantined = conflict.quarantineFile ? node_path_1.default.join(root, conflict.quarantineFile) : null;
753
+ if (quarantined)
754
+ await assertSafeTarget(root, quarantined);
755
+ const quarantineExists = quarantined ? (await localHash(root, quarantined)) !== null : false;
756
+ const basisEntry = binding.basis.entries[conflict.memoryId];
757
+ const exactBasis = basisEntry?.file === conflict.file && basisEntry?.versionId === conflict.baseVersionId;
758
+ if (conflict.type === 'remote_move_local_edit') {
759
+ if (!['keep-local', 'keep-remote', 'manual'].includes(strategy)) {
760
+ throw codedError('MEMORY_SYNC_STRATEGY_INVALID', `Unsupported conflict strategy: ${strategy}`);
761
+ }
762
+ const currentRemoteBasis = typeof conflict.recoveryFile === 'string' &&
763
+ basisEntry?.file === conflict.recoveryFile &&
764
+ basisEntry?.versionId === conflict.remoteVersionId &&
765
+ basisEntry?.fileHash === conflict.remoteFileHash;
766
+ if (!quarantineExists || !quarantined) {
767
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', 'The move has not completed its quarantine; retry the pull before resolving it');
768
+ }
769
+ if (strategy !== 'keep-remote') {
770
+ if (!currentRemoteBasis) {
771
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', 'The moved Memory head changed; keep the remote head or preserve the quarantined edit separately');
772
+ }
773
+ const recoveryTarget = node_path_1.default.join(root, conflict.recoveryFile);
774
+ if ((await localHash(root, recoveryTarget)) !== conflict.remoteFileHash) {
775
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', 'The moved topic has newer local edits; preserve them before recovering the quarantined edit');
776
+ }
777
+ if (strategy === 'manual' && !manualContent?.trim()) {
778
+ throw codedError('MEMORY_SYNC_MANUAL_CONTENT_REQUIRED', 'Manual content is required');
779
+ }
780
+ await atomicWrite(root, recoveryTarget, strategy === 'manual' ? manualContent : await promises_1.default.readFile(quarantined, 'utf8'));
781
+ // Keep the current remote version/hash as the CAS basis. The recovered
782
+ // body is an explicit local edit of that head, never a new old-path topic.
783
+ }
784
+ await promises_1.default.unlink(quarantined);
785
+ await assertSafeTarget(root, conflictFile);
786
+ await promises_1.default.unlink(conflictFile);
787
+ return { resolved: true, strategy };
788
+ }
789
+ if (quarantined && strategy !== 'keep-remote' && basisEntry && !exactBasis) {
790
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', 'Memory changed after this recovery was recorded');
791
+ }
792
+ if (quarantined &&
793
+ strategy !== 'keep-remote' &&
794
+ Object.values(binding.basis.entries).some((entry) => entry.file === conflict.file && entry.memoryId !== conflict.memoryId)) {
795
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', 'The old topic path now belongs to another Memory');
796
+ }
797
+ if (strategy === 'keep-local' && quarantineExists && quarantined) {
798
+ await assertSafeTarget(root, target, { createParents: true });
799
+ // Recovery is an explicit user action. Do not overwrite a new local file
800
+ // created after quarantine, and never send a remote write from this step.
801
+ await promises_1.default.writeFile(target, await promises_1.default.readFile(quarantined), { mode: 0o600, flag: 'wx' });
802
+ }
803
+ if (strategy === 'keep-local' && quarantined && !quarantineExists) {
804
+ if (!exactBasis || (await localHash(root, target)) !== conflict.localFileHash) {
805
+ throw codedError('MEMORY_SYNC_RECOVERY_CONFLICT', 'Pending quarantine no longer matches its local edit');
806
+ }
807
+ }
612
808
  if (strategy === 'keep-remote') {
613
809
  if (conflict.type === 'remote_delete_local_edit') {
614
- await assertSafeTarget(root, target);
615
- await promises_1.default.unlink(target).catch((error) => {
616
- if (error?.code !== 'ENOENT')
617
- throw error;
618
- });
619
- delete binding.basis.entries[conflict.memoryId];
810
+ // A completed quarantine owns only its recovery file. The active path
811
+ // may now hold a different remote topic; never delete by the old path.
812
+ if (!quarantined ||
813
+ (!quarantineExists &&
814
+ exactBasis &&
815
+ (await localHash(root, target)) === conflict.localFileHash)) {
816
+ if (exactBasis) {
817
+ await assertSafeTarget(root, target);
818
+ await promises_1.default.unlink(target).catch((error) => {
819
+ if (error?.code !== 'ENOENT')
820
+ throw error;
821
+ });
822
+ }
823
+ }
824
+ if (exactBasis)
825
+ delete binding.basis.entries[conflict.memoryId];
620
826
  }
621
827
  else {
622
828
  if (!conflict.remoteDocument)
@@ -640,7 +846,8 @@ async function resolveConflict(rootInput, conflictId, strategy, manualContent) {
640
846
  if (strategy !== 'keep-remote') {
641
847
  const basisEntry = binding.basis.entries[conflict.memoryId];
642
848
  if (conflict.type === 'remote_delete_local_edit') {
643
- delete binding.basis.entries[conflict.memoryId];
849
+ if (exactBasis)
850
+ delete binding.basis.entries[conflict.memoryId];
644
851
  }
645
852
  else if (basisEntry && conflict.remoteVersionId) {
646
853
  // The local body stays dirty, but its CAS basis advances to the remote
@@ -649,6 +856,8 @@ async function resolveConflict(rootInput, conflictId, strategy, manualContent) {
649
856
  }
650
857
  }
651
858
  await atomicJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_BASIS), binding.basis);
859
+ if (quarantineExists && quarantined)
860
+ await promises_1.default.unlink(quarantined);
652
861
  await assertSafeTarget(root, conflictFile);
653
862
  await promises_1.default.unlink(conflictFile);
654
863
  return { resolved: true, strategy };
@@ -681,24 +890,26 @@ async function revokeMaterialization(rootInput, generation) {
681
890
  if (error?.code !== 'ENOENT')
682
891
  throw error;
683
892
  });
684
- const conflictsDirectory = node_path_1.default.join(root, exports.MEMORY_SYNC_CONFLICTS);
685
- const conflictEntries = await promises_1.default.readdir(conflictsDirectory).catch((error) => {
686
- if (error?.code === 'ENOENT')
687
- return [];
688
- throw error;
689
- });
690
- for (const entry of conflictEntries) {
691
- const conflictFile = node_path_1.default.join(conflictsDirectory, entry);
692
- await assertSafeTarget(root, conflictFile);
693
- const stat = await promises_1.default.lstat(conflictFile);
694
- if (!stat.isFile()) {
695
- throw codedError('MEMORY_SYNC_METADATA_INVALID', `Unexpected conflict entry during revocation: ${entry}`);
893
+ for (const directory of [exports.MEMORY_SYNC_CONFLICTS, exports.MEMORY_SYNC_QUARANTINE]) {
894
+ const protectedDirectory = node_path_1.default.join(root, directory);
895
+ const protectedEntries = await promises_1.default.readdir(protectedDirectory).catch((error) => {
896
+ if (error?.code === 'ENOENT')
897
+ return [];
898
+ throw error;
899
+ });
900
+ for (const entry of protectedEntries) {
901
+ const protectedFile = node_path_1.default.join(protectedDirectory, entry);
902
+ await assertSafeTarget(root, protectedFile);
903
+ const stat = await promises_1.default.lstat(protectedFile);
904
+ if (!stat.isFile()) {
905
+ throw codedError('MEMORY_SYNC_METADATA_INVALID', `Unexpected protected entry during revocation: ${entry}`);
906
+ }
907
+ await promises_1.default.unlink(protectedFile);
908
+ removed += 1;
696
909
  }
697
- await promises_1.default.unlink(conflictFile);
698
- removed += 1;
910
+ if (protectedEntries.length)
911
+ await promises_1.default.rmdir(protectedDirectory);
699
912
  }
700
- if (conflictEntries.length)
701
- await promises_1.default.rmdir(conflictsDirectory);
702
913
  if (manifest) {
703
914
  await atomicJson(root, node_path_1.default.join(root, exports.MEMORY_SYNC_MANIFEST), {
704
915
  ...manifest,
@@ -0,0 +1,2 @@
1
+ import type { PlatformUpdateDependencies } from './platform-update';
2
+ export declare const localPlatformUpdateDependencies: PlatformUpdateDependencies;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localPlatformUpdateDependencies = void 0;
4
+ const update_check_1 = require("./update-check");
5
+ const first_party_harness_update_1 = require("./first-party-harness-update");
6
+ const exec_runtime_binary_1 = require("./exec-runtime-binary");
7
+ function parseDaemonStatus(stdout) {
8
+ let parsed;
9
+ try {
10
+ parsed = JSON.parse(stdout.trim());
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ if (!parsed ||
16
+ typeof parsed !== 'object' ||
17
+ Array.isArray(parsed) ||
18
+ typeof parsed.state !== 'string') {
19
+ return null;
20
+ }
21
+ return parsed;
22
+ }
23
+ async function daemonStatus(check) {
24
+ const result = await (0, exec_runtime_binary_1.execRuntimeBinaryCapture)(['update', check ? '--check' : '--status', '--json'], { timeoutMs: 8000 });
25
+ const status = parseDaemonStatus(result.stdout);
26
+ if (!status) {
27
+ // Older daemons already own a signed updater, but do not know this JSON
28
+ // schema. Keep that recovery path reachable without guessing from logs.
29
+ const legacy = check
30
+ ? await (0, exec_runtime_binary_1.execRuntimeBinaryCapture)(['update', '--check'], { timeoutMs: 8000 })
31
+ : null;
32
+ const version = await (0, exec_runtime_binary_1.execRuntimeBinaryCapture)(['--version'], { timeoutMs: 2500 });
33
+ const installedVersion = version.stdout
34
+ .trim()
35
+ .match(/^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/)?.[1] ?? null;
36
+ return {
37
+ state: legacy && legacy.code !== 0 ? 'check-failed' : 'legacy-runtime',
38
+ owner: 'legacy-runtime',
39
+ installedVersion,
40
+ runningVersion: null,
41
+ offeredVersion: null,
42
+ commands: ['skrr daemon update'],
43
+ detail: legacy
44
+ ? legacy.stdout.trim() || legacy.stderr.trim()
45
+ : 'This daemon predates unified update status. Its existing updater remains available; detailed status appears after upgrading.',
46
+ };
47
+ }
48
+ return result.code === 0
49
+ ? status
50
+ : { ...status, state: 'check-failed', detail: status.detail ?? result.stderr.trim() };
51
+ }
52
+ async function updateDaemon(mode, options) {
53
+ if (!(0, exec_runtime_binary_1.findRuntimeBinary)())
54
+ return {
55
+ state: 'not-installed',
56
+ owner: 'none',
57
+ commands: ['skrr daemon install'],
58
+ detail: 'The daemon is not installed on this computer.',
59
+ };
60
+ if (mode !== 'apply')
61
+ return daemonStatus(mode === 'check');
62
+ const before = await daemonStatus(false);
63
+ // Source, desktop, image and retired npm installs have a different owner.
64
+ // Their next command is already present in the daemon's own status response.
65
+ if (!['self', 'homebrew', 'direct', 'brew', 'legacy-runtime'].includes(String(before.owner ?? before.installMethod)))
66
+ return before;
67
+ const args = ['update', ...(options.restart ? ['--restart'] : [])];
68
+ const result = await (0, exec_runtime_binary_1.execRuntimeBinaryCapture)(args, { timeoutMs: 15 * 60 * 1000 });
69
+ let after = before;
70
+ try {
71
+ after = await daemonStatus(false);
72
+ }
73
+ catch {
74
+ /* An apply failure must retain its original result if a restart prevents probing. */
75
+ }
76
+ return {
77
+ ...after,
78
+ ...(result.code !== 0 ? { state: 'failed' } : {}),
79
+ detail: result.code !== 0
80
+ ? result.stderr.trim() || result.stdout.trim() || `Daemon updater exited ${result.code}.`
81
+ : after.detail,
82
+ apply: {
83
+ exitCode: result.code,
84
+ output: result.stdout.trim(),
85
+ diagnostics: result.stderr.trim(),
86
+ },
87
+ };
88
+ }
89
+ exports.localPlatformUpdateDependencies = {
90
+ async cli(mode, options) {
91
+ const args = {
92
+ currentVersion: options.currentVersion,
93
+ baseURL: options.baseURL,
94
+ env: options.env,
95
+ };
96
+ if (mode === 'status')
97
+ return (0, update_check_1.readCliUpdateStatus)(args);
98
+ if (mode === 'check')
99
+ return (0, update_check_1.checkCliUpdate)({ ...args, force: true });
100
+ return (0, update_check_1.performCliUpdate)(args);
101
+ },
102
+ async code(mode, options) {
103
+ if (mode === 'status')
104
+ return (0, first_party_harness_update_1.getLocalCodeUpdateStatus)(options.env);
105
+ if (mode === 'check')
106
+ return (0, first_party_harness_update_1.checkLocalCodeUpdate)({ env: options.env, force: true });
107
+ const current = (0, first_party_harness_update_1.getLocalCodeUpdateStatus)(options.env);
108
+ if (current.owner !== 'managed' || !current.activePath)
109
+ return current;
110
+ return (0, first_party_harness_update_1.updateLocalCode)({ env: options.env });
111
+ },
112
+ daemon: updateDaemon,
113
+ };
@@ -0,0 +1,39 @@
1
+ /** One local update surface; each installation owner still applies its own bytes. */
2
+ export declare const UPDATE_COMPONENTS: readonly ["cli", "code", "daemon"];
3
+ export type UpdateComponent = (typeof UPDATE_COMPONENTS)[number];
4
+ export type UpdateMode = 'check' | 'status' | 'apply';
5
+ export interface ComponentUpdateStatus {
6
+ component: UpdateComponent;
7
+ state: string;
8
+ runningVersion: string | null;
9
+ installedVersion: string | null;
10
+ offeredVersion: string | null;
11
+ owner: string;
12
+ commands: string[];
13
+ autoUpdateEnabled: boolean | null;
14
+ lastAttempt: unknown | null;
15
+ detail: string | null;
16
+ /** The component's full status is retained for machine-readable diagnostics. */
17
+ status: Record<string, unknown>;
18
+ }
19
+ export interface PlatformUpdateOptions {
20
+ mode: UpdateMode;
21
+ component?: UpdateComponent | 'all';
22
+ currentVersion: string;
23
+ baseURL: string;
24
+ restart?: boolean;
25
+ env?: NodeJS.ProcessEnv;
26
+ }
27
+ export interface PlatformUpdateDependencies {
28
+ cli(mode: UpdateMode, options: PlatformUpdateOptions): Promise<unknown>;
29
+ code(mode: UpdateMode, options: PlatformUpdateOptions): Promise<unknown>;
30
+ daemon(mode: UpdateMode, options: PlatformUpdateOptions): Promise<unknown>;
31
+ }
32
+ export declare function normalizeComponentUpdateStatus(component: UpdateComponent, value: unknown): ComponentUpdateStatus;
33
+ export declare function runPlatformUpdate(options: PlatformUpdateOptions, dependencies: PlatformUpdateDependencies): Promise<{
34
+ schemaVersion: 1;
35
+ mode: UpdateMode;
36
+ components: ComponentUpdateStatus[];
37
+ ok: boolean;
38
+ }>;
39
+ export declare function describeComponentUpdate(status: ComponentUpdateStatus): string;