evolcore 0.0.28 → 0.0.29

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 (183) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/ec-safe-output.js +270 -54
  3. package/dist/agents/baseagent-availability.js +37 -1
  4. package/dist/agents/baseagent.js +19 -9
  5. package/dist/agents/claude-runner.js +171 -13
  6. package/dist/agents/codex/app-server-client.js +6 -2
  7. package/dist/agents/codex/diagnostics.js +4 -3
  8. package/dist/agents/codex-runner.js +396 -95
  9. package/dist/agents/gemini-runner.js +35 -17
  10. package/dist/aun/group/history.js +130 -90
  11. package/dist/aun/group/operations.js +1 -3
  12. package/dist/aun/menu/transport.js +5 -3
  13. package/dist/aun/msg/managed-operation.js +70 -0
  14. package/dist/aun/msg/p2p.js +1 -3
  15. package/dist/aun/msg/upload.js +2 -2
  16. package/dist/aun/outbox.js +1 -37
  17. package/dist/channels/aun.js +249 -89
  18. package/dist/channels/daemon.js +18 -1
  19. package/dist/channels/feishu.js +1 -2
  20. package/dist/cli/agent-command.js +23 -3
  21. package/dist/cli/agent-create.js +68 -2
  22. package/dist/cli/agent.js +12 -2
  23. package/dist/cli/aun-commands.js +87 -13
  24. package/dist/cli/cli-argv.js +26 -0
  25. package/dist/cli/daemon-commands.js +40 -10
  26. package/dist/cli/data-command.js +4 -4
  27. package/dist/cli/handoff-command.js +2 -0
  28. package/dist/cli/index.js +18 -1
  29. package/dist/cli/managed-command-guard.js +10 -0
  30. package/dist/cli/memory-command.js +441 -0
  31. package/dist/cli/restart-monitor.js +62 -6
  32. package/dist/cli/skill-command.js +41 -0
  33. package/dist/cli/task-context.js +2 -23
  34. package/dist/cli/trigger-command.js +9 -1
  35. package/dist/cli/watch-msg-diag.js +209 -0
  36. package/dist/config/config-field-policy.js +52 -3
  37. package/dist/config/config-manager.js +17 -4
  38. package/dist/config/contact-book-store.js +2 -2
  39. package/dist/config/mention-mode.js +7 -2
  40. package/dist/config/merge.js +28 -9
  41. package/dist/config/resolved-config-op.js +1 -1
  42. package/dist/config/runtime-env.js +137 -0
  43. package/dist/config/schema-registry.js +11 -1
  44. package/dist/config-store.js +10 -10
  45. package/dist/core/audit/event-key.js +3 -1
  46. package/dist/core/audit/log-integrity.js +4 -0
  47. package/dist/core/auth/agent-delegation.js +2 -1
  48. package/dist/core/auth/auth-gateway.js +13 -6
  49. package/dist/core/auth/authorization-audit.js +54 -19
  50. package/dist/core/auth/operation-authorizer.js +17 -0
  51. package/dist/core/auth/operation-catalog.js +9 -0
  52. package/dist/core/causation/context.js +5 -1
  53. package/dist/core/command/agent-control.js +1 -2
  54. package/dist/core/command/command-handler.js +51 -23
  55. package/dist/core/command/memory-slash.js +270 -0
  56. package/dist/core/command/menu-catalog.js +1 -0
  57. package/dist/core/command/menu-handler.js +315 -60
  58. package/dist/core/command/menu-protocol.js +42 -4
  59. package/dist/core/command/slash-gate.js +10 -10
  60. package/dist/core/command/slash-handler.js +206 -64
  61. package/dist/core/data-migration.js +282 -505
  62. package/dist/core/event-catalog.js +26 -0
  63. package/dist/core/evolagent-registry.js +6 -0
  64. package/dist/core/evolagent.js +0 -10
  65. package/dist/core/handoff/store.js +1 -20
  66. package/dist/core/inference/text-inference.js +57 -1
  67. package/dist/core/memory/consolidation-context.js +505 -0
  68. package/dist/core/memory/consolidation-ops-records.js +124 -0
  69. package/dist/core/memory/consolidation-trigger.js +394 -0
  70. package/dist/core/memory/index-manager.js +308 -0
  71. package/dist/core/memory/launch-consolidation.js +395 -0
  72. package/dist/core/memory/memory-admin.js +234 -0
  73. package/dist/core/memory/memory-document.js +100 -0
  74. package/dist/core/memory/memory-session-policy.js +41 -0
  75. package/dist/core/memory/memory-signature.js +257 -0
  76. package/dist/core/memory/memory-writer.js +432 -0
  77. package/dist/core/memory/staleness.js +117 -0
  78. package/dist/core/message/auto-mention-gate.js +454 -0
  79. package/dist/core/message/im-renderer.js +69 -16
  80. package/dist/core/message/message-bridge.js +287 -21
  81. package/dist/core/message/message-log.js +1 -0
  82. package/dist/core/message/message-queue.js +114 -27
  83. package/dist/core/message/pending-hints.js +329 -161
  84. package/dist/core/message/response-engine.js +567 -80
  85. package/dist/core/permission/ec-command-parser.js +1029 -80
  86. package/dist/core/permission/protected-paths.js +376 -126
  87. package/dist/core/permission/sandbox-runtime.js +40 -12
  88. package/dist/core/permission/tool-error-code.js +96 -13
  89. package/dist/core/permission/tool-policy.js +276 -23
  90. package/dist/core/role/runtime-policy.js +3 -7
  91. package/dist/core/session/{runner-task-history.js → runner-event-summary.js} +1 -1
  92. package/dist/core/session/session-fs-store.js +0 -76
  93. package/dist/core/session/session-idle-monitor.js +355 -0
  94. package/dist/core/session/session-manager.js +478 -17
  95. package/dist/core/session/session-mapper.js +33 -16
  96. package/dist/core/session/session-renew.js +217 -155
  97. package/dist/core/session/{session-turns.js → session-transcript.js} +35 -6
  98. package/dist/core/skill/global-watcher.js +282 -0
  99. package/dist/core/skill/index-generator.js +84 -0
  100. package/dist/core/skill/startup.js +73 -0
  101. package/dist/core/skill/switch-resolver.js +115 -0
  102. package/dist/core/task/task-activity-store.js +219 -0
  103. package/dist/eck/kit-renderer.js +30 -3
  104. package/dist/eck/manifest-engine.js +26 -4
  105. package/dist/index.js +708 -18
  106. package/dist/ipc.js +564 -2
  107. package/dist/paths.js +30 -9
  108. package/dist/response-system/engines/v1/proactive-flow.js +116 -15
  109. package/dist/response-system/modes/single-session/index.js +3 -0
  110. package/dist/trigger/feedback.js +1 -0
  111. package/dist/trigger/manager.js +0 -10
  112. package/dist/trigger/scheduler.js +11 -0
  113. package/dist/trigger/validation.js +22 -0
  114. package/dist/utils/avatar-upload.js +59 -18
  115. package/dist/utils/error-dict.json +6 -3
  116. package/dist/utils/error-utils.js +27 -0
  117. package/dist/utils/logger.js +32 -10
  118. package/dist/utils/media-cache.js +84 -0
  119. package/dist/utils/msg-diag.js +97 -0
  120. package/dist/utils/welcome.js +37 -8
  121. package/kits/docs/AUTHORING.md +314 -0
  122. package/kits/docs/GUIDE.md +2 -0
  123. package/kits/docs/INDEX.md +2 -0
  124. package/kits/docs/aun/CHEATSHEET.md +4 -0
  125. package/kits/docs/aun/SDK.md +60 -0
  126. package/kits/docs/context-assembly.md +10 -6
  127. package/kits/docs/evolcore/INDEX.md +17 -3
  128. package/kits/docs/evolcore/agent-create.md +24 -0
  129. package/kits/docs/evolcore/agent.md +5 -2
  130. package/kits/docs/evolcore/config.md +1 -1
  131. package/kits/docs/evolcore/ec-capabilities.md +364 -0
  132. package/kits/docs/evolcore/group.md +34 -18
  133. package/kits/docs/evolcore/memory-architecture.md +78 -0
  134. package/kits/docs/evolcore/memory-consolidation-batches.md +133 -0
  135. package/kits/docs/evolcore/memory-consolidation.md +263 -0
  136. package/kits/docs/evolcore/memory-tags.md +63 -0
  137. package/kits/docs/evolcore/memory.md +110 -0
  138. package/kits/docs/evolcore/menu-client.md +27 -11
  139. package/kits/{schemas → docs/evolcore}/menu-exec-schema-commands.md +12 -6
  140. package/kits/docs/evolcore/menu-protocol.md +213 -0
  141. package/kits/docs/evolcore/msg.md +2 -0
  142. package/kits/docs/evolcore/trigger.md +7 -5
  143. package/kits/docs/path-registry.md +6 -8
  144. package/kits/docs/prompt-loading-architecture.md +5 -3
  145. package/kits/docs/venues/group.md +5 -4
  146. package/kits/eck_manifest.json +294 -88
  147. package/kits/eck_manifest.memory.json +257 -0
  148. package/kits/rules/03-identity.md +8 -8
  149. package/kits/rules/05-venue.md +4 -2
  150. package/kits/rules/06-channel.md +1 -1
  151. package/kits/schemas/_meta.json +276 -47
  152. package/kits/schemas/agent-config.schema.12.json +38 -2
  153. package/kits/schemas/agent-config.schema.13.json +591 -0
  154. package/kits/schemas/daemon.schema.6.json +9 -9
  155. package/kits/schemas/defaults.schema.5.json +29 -0
  156. package/kits/schemas/relation-config.schema.8.json +14 -1
  157. package/kits/schemas/relation-config.schema.9.json +129 -0
  158. package/kits/schemas/role-config.schema.1.json +3 -3
  159. package/kits/skills/.switches.json +3 -0
  160. package/kits/skills/ec-skill/SKILL.md +217 -0
  161. package/kits/skills/ec-skill/scripts/cli.mjs +128 -0
  162. package/kits/skills/ec-skill/scripts/deps.mjs +107 -0
  163. package/kits/skills/ec-skill/scripts/frontmatter.mjs +104 -0
  164. package/kits/skills/ec-skill/scripts/skill.mjs +629 -0
  165. package/kits/skills/ec-skill/scripts/template/SKILL.md.tmpl +30 -0
  166. package/kits/templates/decision/auto-mention.md +25 -0
  167. package/kits/templates/decision/session-renew.md +22 -0
  168. package/kits/templates/memory-consolidation/forget.md +104 -0
  169. package/kits/templates/memory-consolidation/generalize.md +76 -0
  170. package/kits/templates/memory-consolidation/notes.md +133 -0
  171. package/kits/templates/memory-consolidation/profile.md +58 -0
  172. package/kits/templates/memory-consolidation/promote.md +72 -0
  173. package/kits/templates/memory-consolidation/workflow.md +75 -0
  174. package/kits/templates/system-fragments/bootstrap.md +14 -5
  175. package/kits/templates/system-fragments/commands.md +1 -0
  176. package/kits/templates/system-fragments/group-history.md +6 -0
  177. package/kits/templates/system-fragments/identity.md +0 -3
  178. package/kits/templates/system-fragments/memory.md +79 -0
  179. package/kits/templates/system-fragments/reply.md +1 -0
  180. package/kits/templates/system-fragments/venue.md +1 -1
  181. package/package.json +4 -6
  182. package/dist/trigger/legacy-session-history.js +0 -19
  183. package/kits/templates/system-fragments/group-unread-history.md +0 -7
@@ -1,12 +1,12 @@
1
1
  import crypto from 'crypto';
2
2
  import { execFileSync } from 'child_process';
3
3
  import fs from 'fs';
4
+ import os from 'os';
4
5
  import path from 'path';
5
- import { formatChannelSessionKey, scanLegacyChatDirs, scanMetaFiles, scanNestedAgentChatDirs, scanNonCanonicalAgentChatDirs, } from './session/session-fs-store.js';
6
- import { rewriteLegacyTriggerSessionPaths } from '../trigger/legacy-session-history.js';
6
+ import { formatChannelSessionKey, scanMetaFiles, scanNestedAgentChatDirs, scanNonCanonicalAgentChatDirs, } from './session/session-fs-store.js';
7
7
  const SESSION_SEEN_FILE = 'seen-messages.jsonl';
8
8
  const CHANNEL_TYPES = new Set(['aun', 'feishu', 'wechat', 'dingtalk', 'qqbot', 'wecom']);
9
- const DATA_MIGRATION_PLAN_VERSION = 3;
9
+ const DATA_MIGRATION_PLAN_VERSION = 5;
10
10
  function encodeSegment(value) {
11
11
  return value.replace(/[<>:"/\\|?*\x00-\x1F%]/g, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
12
12
  }
@@ -62,32 +62,6 @@ function operationProofTopologyKey(operation) {
62
62
  source: operation.source,
63
63
  });
64
64
  }
65
- /**
66
- * Manifests written before allowExistingDestinationMerge was introduced still
67
- * need safe recovery. Re-derive that narrow permission from their immutable
68
- * source topology and the current owner-evidenced control identity; no other
69
- * destination conflict is relaxed.
70
- */
71
- function restoreLegacyControlMergeEligibility(manifest) {
72
- const control = controlIdentity(manifest.root);
73
- if (control.aids.size !== 1 || control.legacyAids.size === 0)
74
- return;
75
- const currentAid = control.aids.values().next().value;
76
- const legacySessionsRoot = path.join(manifest.root, 'data', 'sessions', 'aun');
77
- const expectedChannelKey = `aun#${currentAid}#main`;
78
- const destinationRoot = path.join(manifest.root, 'agents', currentAid, 'sessions');
79
- for (const operation of manifest.operations) {
80
- if (operation.allowExistingDestinationMerge || operation.transform !== 'normalize-session-channel-key')
81
- continue;
82
- const relative = path.relative(legacySessionsRoot, operation.source);
83
- const [legacyAid] = relative.split(path.sep);
84
- if (!legacyAid || relative.startsWith(`..${path.sep}`) || !control.legacyAids.has(legacyAid))
85
- continue;
86
- if (operation.sessionChannelKey !== expectedChannelKey || !operation.destination || !isWithin(destinationRoot, operation.destination))
87
- continue;
88
- operation.allowExistingDestinationMerge = true;
89
- }
90
- }
91
65
  function sessionDestination(root, aid, channelKey, channelId) {
92
66
  const channelType = channelKey === 'daemon' ? 'daemon' : channelKey.split('#', 1)[0]?.trim();
93
67
  if (!channelType)
@@ -97,8 +71,20 @@ function sessionDestination(root, aid, channelKey, channelId) {
97
71
  function channelStateDestination(root, channelKey, ...parts) {
98
72
  return path.join(root, 'data', 'channel-state', encodeURIComponent(channelKey), ...parts);
99
73
  }
100
- function triggerDestination(root, aid, ...parts) {
101
- return path.join(root, 'agents', aid, 'triggers', ...parts);
74
+ function agentDataDestination(root, aid, ...parts) {
75
+ return path.join(root, 'agents', aid, 'data', ...parts);
76
+ }
77
+ function contactOperationsDestination(root, aid) {
78
+ return agentDataDestination(root, aid, 'contact', 'operations.jsonl');
79
+ }
80
+ function contactAuditDestination(root, aid) {
81
+ return agentDataDestination(root, aid, 'contact', 'audit.jsonl');
82
+ }
83
+ function codexTokensDestination(root, aid) {
84
+ return agentDataDestination(root, aid, 'codex-tokens');
85
+ }
86
+ function outboxDestination(root, aid, queue) {
87
+ return agentDataDestination(root, aid, 'outbox', `${queue}.jsonl`);
102
88
  }
103
89
  function readJson(filePath) {
104
90
  try {
@@ -126,15 +112,16 @@ function acknowledgeCompletedCopies(root, operations) {
126
112
  .sort((left, right) => right.mtime - left.mtime);
127
113
  for (const candidate of manifests) {
128
114
  const manifest = readJson(candidate.path);
129
- if (manifest?.state !== 'completed')
115
+ if (manifest?.state !== 'completed' && manifest?.state !== 'completed_with_errors')
130
116
  continue;
131
- // An unchanged plan is a normal re-run and must retain its conflict
132
- // checks. Acknowledgement is only for extending a prior plan.
133
- if (manifest.planSignature === currentPlanSignature)
117
+ // A fully completed unchanged plan is a normal re-run and must retain
118
+ // its conflict checks. A partial manifest may contain committed items
119
+ // that need acknowledgement even when its topology is unchanged.
120
+ if (manifest.state === 'completed' && manifest.planSignature === currentPlanSignature)
134
121
  return;
135
122
  for (const operation of manifest.operations ?? []) {
136
- if ((operation.kind !== 'copy' && operation.kind !== 'contact-audit-partition')
137
- || (operation.status !== 'committed' && operation.status !== 'skipped')
123
+ if ((operation.kind !== 'copy' && operation.kind !== 'merge-copy' && operation.kind !== 'contact-audit-partition')
124
+ || !['committed', 'skipped', 'acknowledged'].includes(operation.status)
138
125
  || !operation.sourceHash)
139
126
  continue;
140
127
  const key = operationProofTopologyKey(operation);
@@ -142,6 +129,7 @@ function acknowledgeCompletedCopies(root, operations) {
142
129
  proofs.set(key, {
143
130
  migrationId: manifest.id,
144
131
  sourceHash: operation.sourceHash,
132
+ sourceHashes: operation.sourceHashes,
145
133
  destinations: operation.kind === 'contact-audit-partition'
146
134
  ? [...(operation.destinations ?? [])]
147
135
  : operation.destination ? [operation.destination] : [],
@@ -152,21 +140,32 @@ function acknowledgeCompletedCopies(root, operations) {
152
140
  }
153
141
  catch { }
154
142
  for (const operation of operations) {
155
- if (operation.kind !== 'copy' && operation.kind !== 'contact-audit-partition')
143
+ if (operation.kind !== 'copy' && operation.kind !== 'merge-copy' && operation.kind !== 'contact-audit-partition')
156
144
  continue;
157
145
  const proof = proofs.get(operationProofTopologyKey(operation));
158
146
  if (!proof)
159
147
  continue;
160
- if (operation.kind === 'copy' && (!operation.destination || !fs.existsSync(operation.destination)))
148
+ if ((operation.kind === 'copy' || operation.kind === 'merge-copy')
149
+ && (!operation.destination || !fs.existsSync(operation.destination)))
161
150
  continue;
162
151
  if (operation.kind === 'contact-audit-partition' && proof.destinations.some(destination => !fs.existsSync(destination)))
163
152
  continue;
164
153
  try {
165
- if (treeHash(operation.source, operation.exclude) !== proof.sourceHash)
154
+ if (operation.kind === 'merge-copy') {
155
+ const sourceHashes = proof.sourceHashes ?? {};
156
+ if (operationSources(operation).some(source => {
157
+ const expected = sourceHashes[source] ?? (source === operation.source ? proof.sourceHash : undefined);
158
+ return !expected || treeHash(source, operation.exclude) !== expected;
159
+ }))
160
+ continue;
161
+ }
162
+ else if (treeHash(operation.source, operation.exclude) !== proof.sourceHash)
166
163
  continue;
167
164
  operation.status = 'acknowledged';
168
165
  operation.acknowledgedBy = proof.migrationId;
169
166
  operation.sourceHash = proof.sourceHash;
167
+ if (proof.sourceHashes)
168
+ operation.sourceHashes = proof.sourceHashes;
170
169
  }
171
170
  catch { }
172
171
  }
@@ -246,154 +245,6 @@ function configuredAids(root) {
246
245
  }
247
246
  })()));
248
247
  }
249
- function controlIdentity(root) {
250
- const config = readJson(path.join(root, 'daemon.json'));
251
- const aid = typeof config?.aid === 'string' && config.aid.trim() && config.aid !== '_unknown'
252
- ? config.aid.trim()
253
- : undefined;
254
- const owners = new Set();
255
- if (Array.isArray(config?.owners)) {
256
- for (const owner of config.owners) {
257
- if (typeof owner === 'string' && owner.trim())
258
- owners.add(owner);
259
- }
260
- }
261
- // A previous `ecNNNNN.<domain>` control identity has no Agent config, so
262
- // its legacy AUN session root cannot be attributed through configuredAids.
263
- // It is safe to recognize only when that root contains at least one current
264
- // daemon owner: the whole root then belongs to the same process control
265
- // plane, including sessions with its non-owner Agents.
266
- const legacyAids = new Set();
267
- if (aid && owners.size > 0) {
268
- try {
269
- for (const entry of fs.readdirSync(path.join(root, 'data', 'sessions', 'aun'), { withFileTypes: true })) {
270
- if (!entry.isDirectory() || entry.name === aid || !/^ec\d{5}\.[^.]+(?:\..+)+$/i.test(entry.name))
271
- continue;
272
- const sessionRoot = path.join(root, 'data', 'sessions', 'aun', entry.name);
273
- const hasCurrentOwner = fs.readdirSync(sessionRoot, { withFileTypes: true })
274
- .some(child => child.isDirectory() && owners.has(child.name));
275
- if (hasCurrentOwner)
276
- legacyAids.add(entry.name);
277
- }
278
- }
279
- catch { }
280
- }
281
- return { aids: new Set(aid ? [aid] : []), owners, legacyAids };
282
- }
283
- function inferUnknownAunOwner(chat, control) {
284
- if (chat.channelType !== 'aun' || chat.selfAID !== '_unknown' || control.aids.size !== 1)
285
- return undefined;
286
- // Old message-only AUN directories use `_unknown` for the local identity.
287
- // The control identity is recoverable only when the peer is one of its
288
- // configured owners; arbitrary unknown directories remain untouched.
289
- if (!control.owners.has(chat.channelId))
290
- return undefined;
291
- return control.aids.values().next().value;
292
- }
293
- function remapLegacyControlAunOwner(aid, control) {
294
- if (control.aids.size !== 1 || !control.legacyAids.has(aid))
295
- return undefined;
296
- return control.aids.values().next().value;
297
- }
298
- function isOwnedTriggerDefinition(definition, aid, triggerId) {
299
- return !!definition
300
- && typeof definition === 'object'
301
- && definition.id === triggerId
302
- && definition.agentAid === aid;
303
- }
304
- function listTriggerStorageRoots(root, layout) {
305
- const roots = [];
306
- if (layout === 'legacy') {
307
- const legacyRoot = path.join(root, 'data', 'triggers');
308
- try {
309
- for (const entry of fs.readdirSync(legacyRoot, { withFileTypes: true })) {
310
- if (entry.isDirectory())
311
- roots.push({ aid: entry.name, directory: path.join(legacyRoot, entry.name) });
312
- }
313
- }
314
- catch { }
315
- return roots;
316
- }
317
- const agentsRoot = path.join(root, 'agents');
318
- try {
319
- for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
320
- if (!entry.isDirectory())
321
- continue;
322
- const directory = path.join(agentsRoot, entry.name, 'triggers');
323
- if (fs.existsSync(directory))
324
- roots.push({ aid: entry.name, directory });
325
- }
326
- }
327
- catch { }
328
- return roots;
329
- }
330
- /**
331
- * Trigger definitions are the authority for daemon-session ownership. Search
332
- * both layouts while a completed migration retains the immutable legacy copy.
333
- */
334
- function listTriggerOwners(root) {
335
- const ownerAids = new Map();
336
- for (const layout of ['legacy', 'agent']) {
337
- for (const { aid, directory } of listTriggerStorageRoots(root, layout)) {
338
- let triggerEntries;
339
- try {
340
- triggerEntries = fs.readdirSync(directory, { withFileTypes: true });
341
- }
342
- catch {
343
- continue;
344
- }
345
- for (const triggerEntry of triggerEntries) {
346
- if (!triggerEntry.isDirectory())
347
- continue;
348
- const definition = readJson(path.join(directory, triggerEntry.name, 'trigger.json'));
349
- if (!isOwnedTriggerDefinition(definition, aid, triggerEntry.name))
350
- continue;
351
- const owners = ownerAids.get(triggerEntry.name) ?? new Set();
352
- owners.add(aid);
353
- ownerAids.set(triggerEntry.name, owners);
354
- }
355
- }
356
- }
357
- return new Map([...ownerAids].map(([triggerId, aids]) => [
358
- triggerId,
359
- [...aids].sort().map(aid => ({ aid })),
360
- ]));
361
- }
362
- function resolveDaemonTriggerOwner(chat, record, triggerOwners) {
363
- if (chat.channelType !== 'daemon') {
364
- return { warning: `Non-Trigger daemon session retained: ${chat.channelId}` };
365
- }
366
- const triggerIds = new Set();
367
- if (chat.channelId.startsWith('trigger:'))
368
- triggerIds.add(chat.channelId.slice('trigger:'.length));
369
- for (const value of [record?.metadata?.peerId, record?.metadata?.triggerId, record?.triggerId]) {
370
- if (typeof value === 'string' && value.startsWith('trigger:'))
371
- triggerIds.add(value.slice('trigger:'.length));
372
- }
373
- triggerIds.delete('');
374
- if (triggerIds.size === 0)
375
- return { warning: `Non-Trigger daemon session retained: ${chat.channelId}` };
376
- if (triggerIds.size > 1) {
377
- return { warning: `Daemon session references multiple Trigger IDs and was retained: ${chat.channelId}` };
378
- }
379
- const triggerId = [...triggerIds][0];
380
- const candidates = triggerOwners.get(triggerId) ?? [];
381
- const declaredOwner = typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
382
- ? record.selfAID
383
- : undefined;
384
- const ownedCandidates = declaredOwner
385
- ? candidates.filter(candidate => candidate.aid === declaredOwner)
386
- : candidates;
387
- if (ownedCandidates.length === 1)
388
- return { aid: ownedCandidates[0].aid };
389
- if (declaredOwner && candidates.length > 0) {
390
- return { warning: `Daemon Trigger session owner conflicts with its definition and was retained: ${chat.channelId}` };
391
- }
392
- if (candidates.length === 0) {
393
- return { warning: `Daemon Trigger session has no matching definition and was retained: ${chat.channelId}` };
394
- }
395
- return { warning: `Daemon Trigger session has ambiguous ownership and was retained: ${chat.channelId}` };
396
- }
397
248
  function matchingChannel(instances, type, legacyName) {
398
249
  const matches = instances.filter(instance => instance.type === type && (instance.key === legacyName || instance.rawName === legacyName));
399
250
  return matches.length === 1 ? matches[0].key : undefined;
@@ -464,6 +315,11 @@ function addCopy(operations, id, source, destination, staging, exclude, transfor
464
315
  return;
465
316
  operations.push({ id, kind: 'copy', source, destination, staging, exclude, transform, sessionChannelKey, status: 'planned' });
466
317
  }
318
+ function addDiscard(operations, id, source, reason) {
319
+ if (!fs.existsSync(source) || operations.some(operation => operation.kind === 'discard' && operation.source === source))
320
+ return;
321
+ operations.push({ id, kind: 'discard', source, status: 'planned', discardReason: reason });
322
+ }
467
323
  /**
468
324
  * Historic AUN data can contain an early `_unknown` transcript and a later
469
325
  * identified transcript for the same peer. They are one logical session, so
@@ -498,14 +354,10 @@ function addMergeCopy(operations, id, source, destination, staging, exclude, tra
498
354
  export function planDataMigration(root) {
499
355
  const operations = [];
500
356
  const warnings = [];
501
- const supersededSourceHashes = {};
502
357
  const id = `data-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
503
358
  const stagingDir = migrationStagingDir(root, id);
504
359
  const instances = listChannelInstances(root);
505
360
  const aids = configuredAids(root);
506
- const control = controlIdentity(root);
507
- const triggerOwners = listTriggerOwners(root);
508
- const legacySessions = path.join(root, 'data', 'sessions');
509
361
  const legacyFeishuChats = new Map();
510
362
  let sequence = 0;
511
363
  const nextId = (prefix) => `${prefix}-${String(++sequence).padStart(5, '0')}`;
@@ -517,12 +369,6 @@ export function planDataMigration(root) {
517
369
  chats.add(channelId);
518
370
  legacyFeishuChats.set(channelKey, chats);
519
371
  };
520
- const rememberSupersededSource = (source) => {
521
- try {
522
- supersededSourceHashes[source] = treeHash(source);
523
- }
524
- catch { }
525
- };
526
372
  const addFeishuSeen = (source, channelKey, channelId, prefix) => {
527
373
  if (!fs.existsSync(source))
528
374
  return;
@@ -535,42 +381,7 @@ export function planDataMigration(root) {
535
381
  addCopy(operations, operationId, source, destination, stageFor(operationId));
536
382
  }
537
383
  };
538
- // Trigger data is Agent-owned. Migrate each valid Trigger independently so
539
- // one malformed legacy definition does not block unrelated Agent data.
540
- for (const { aid, directory } of listTriggerStorageRoots(root, 'legacy')) {
541
- let entries;
542
- try {
543
- entries = fs.readdirSync(directory, { withFileTypes: true });
544
- }
545
- catch {
546
- continue;
547
- }
548
- for (const entry of entries) {
549
- const source = path.join(directory, entry.name);
550
- if (entry.isDirectory()) {
551
- if (entry.name.startsWith('.')) {
552
- const operationId = nextId('trigger-metadata');
553
- addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId));
554
- continue;
555
- }
556
- const definition = readJson(path.join(source, 'trigger.json'));
557
- if (!isOwnedTriggerDefinition(definition, aid, entry.name)) {
558
- warnings.push(`Invalid Trigger definition retained: ${source}`);
559
- continue;
560
- }
561
- const operationId = nextId('trigger');
562
- addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId), undefined, 'rewrite-trigger-session-paths');
563
- continue;
564
- }
565
- if (entry.isFile() && (entry.name === 'history.jsonl' || /^triggers\.legacy\.migrated\.\d+\.json$/.test(entry.name))) {
566
- const operationId = nextId(entry.name === 'history.jsonl' ? 'trigger-history' : 'trigger-legacy-stats');
567
- addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId));
568
- continue;
569
- }
570
- warnings.push(`Unrecognized Trigger data retained: ${source}`);
571
- }
572
- }
573
- // Canonicalize single-level keys written by an earlier v3 build. These are
384
+ // Canonicalize single-level keys written by an earlier build. These are
574
385
  // live Agent-owned copies and therefore take precedence over retained source
575
386
  // layouts when both resolve to the same destination.
576
387
  for (const chat of scanNonCanonicalAgentChatDirs(path.join(root, 'agents'))) {
@@ -596,10 +407,9 @@ export function planDataMigration(root) {
596
407
  addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'canonical-feishu-seen');
597
408
  }
598
409
  }
599
- // Version 3 flattens the previous Agent-owned two-level session layout.
600
- // These directories are the most recent runtime copies, so when they and a
601
- // retained data/sessions source resolve to the same destination, this copy
602
- // takes precedence and avoids replaying an older transcript.
410
+ // Flatten the previous Agent-owned two-level session layout. These
411
+ // directories are the most recent runtime copies and take precedence over
412
+ // any already-created canonical destination.
603
413
  const nestedChats = scanNestedAgentChatDirs(path.join(root, 'agents'))
604
414
  .map(chat => {
605
415
  const record = readLatestSession(chat.dirPath);
@@ -636,83 +446,6 @@ export function planDataMigration(root) {
636
446
  addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'flatten-feishu-seen');
637
447
  }
638
448
  }
639
- for (const chat of scanLegacyChatDirs(legacySessions)) {
640
- const record = readLatestSession(chat.dirPath);
641
- if (chat.channelType === 'daemon') {
642
- const triggerOwner = resolveDaemonTriggerOwner(chat, record, triggerOwners);
643
- if (!triggerOwner.aid) {
644
- warnings.push(`${triggerOwner.warning ?? 'Unresolved daemon session retained'}: ${chat.dirPath}`);
645
- continue;
646
- }
647
- let destination;
648
- try {
649
- destination = sessionDestination(root, triggerOwner.aid, 'daemon', chat.channelId);
650
- }
651
- catch (error) {
652
- warnings.push(`Unmigratable daemon session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
653
- continue;
654
- }
655
- if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
656
- const sessionId = nextId('trigger-session');
657
- addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId));
658
- }
659
- else {
660
- rememberSupersededSource(chat.dirPath);
661
- }
662
- continue;
663
- }
664
- const fromChannel = parseChannelKey(record?.channel ?? record?.metadata?.channelKey);
665
- const detectedAid = chat.selfAID && chat.selfAID !== '_unknown'
666
- ? chat.selfAID
667
- : (typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
668
- ? record.selfAID
669
- : fromChannel?.aid ?? inferUnknownAunOwner(chat, control));
670
- const remappedControlAid = detectedAid && !aids.has(detectedAid) && !control.aids.has(detectedAid)
671
- ? remapLegacyControlAunOwner(detectedAid, control)
672
- : undefined;
673
- const aid = remappedControlAid ?? detectedAid;
674
- if (!aid || aid === '_unknown') {
675
- warnings.push(`Unresolved session owner retained: ${chat.dirPath}`);
676
- continue;
677
- }
678
- if (!aids.has(aid) && !control.aids.has(aid)) {
679
- warnings.push(`Session owner has no Agent config and was retained: ${chat.dirPath}`);
680
- continue;
681
- }
682
- const channelKey = configuredChannelKey(instances, chat.channelType, aid, record?.channel, record?.metadata?.channelKey, chat.channelKey);
683
- if (!channelKey) {
684
- warnings.push(`Unresolved session channel retained: ${chat.dirPath}`);
685
- continue;
686
- }
687
- let destination;
688
- try {
689
- destination = sessionDestination(root, aid, channelKey, chat.channelId);
690
- }
691
- catch (error) {
692
- warnings.push(`Unmigratable session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
693
- continue;
694
- }
695
- if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
696
- const sessionId = nextId('session');
697
- addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId), [SESSION_SEEN_FILE], 'normalize-session-channel-key', channelKey);
698
- if (remappedControlAid) {
699
- const operation = operations.find(item => item.destination === destination);
700
- if (operation)
701
- operation.allowExistingDestinationMerge = true;
702
- }
703
- }
704
- else {
705
- // This retained data/sessions copy is older than an Agent-owned source
706
- // selected for the same canonical destination. It must not be replayed,
707
- // but --apply can archive and remove it as a verified
708
- // superseded source instead of exposing it as a fresh migration again.
709
- rememberSupersededSource(chat.dirPath);
710
- }
711
- if (chat.channelType === 'feishu') {
712
- rememberFeishuChat(channelKey, chat.channelId);
713
- addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'feishu-seen');
714
- }
715
- }
716
449
  // Rebind connector state written under a removed unique channel instance.
717
450
  // The source stays intact; a merge-copy handles an already-created target.
718
451
  try {
@@ -738,25 +471,106 @@ export function planDataMigration(root) {
738
471
  }
739
472
  }
740
473
  catch { }
474
+ // Agent-owned contact logs, Codex hook credentials, and outbox queues are
475
+ // migrated explicitly. Runtime code no longer imports any of these legacy
476
+ // paths, so a source is either copied here or reported for operator review.
477
+ for (const aid of [...aids].sort()) {
478
+ const contactOperationsId = nextId('contact-operations');
479
+ addCopy(operations, contactOperationsId, path.join(root, 'agents', aid, 'contact-operations.jsonl'), contactOperationsDestination(root, aid), stageFor(contactOperationsId));
480
+ const contactAuditId = nextId('contact-audit');
481
+ addCopy(operations, contactAuditId, path.join(root, 'agents', aid, 'data', 'contact-audit.jsonl'), contactAuditDestination(root, aid), stageFor(contactAuditId));
482
+ const codexTokensId = nextId('codex-tokens');
483
+ addCopy(operations, codexTokensId, path.join(root, 'agents', aid, 'sessions', 'codex-tokens'), codexTokensDestination(root, aid), stageFor(codexTokensId));
484
+ const legacySources = [
485
+ [path.join(root, 'data', 'outbox', encodeURIComponent(aid), 'outbox.jsonl'), 'delivery', 'outbox-shared'],
486
+ [path.join(root, 'data', 'outbox', encodeURIComponent(aid), 'activity-outbox.jsonl'), 'activity', 'activity-outbox-shared'],
487
+ [path.join(root, 'data', 'outbox', `${aid}.jsonl`), 'delivery', 'outbox-flat'],
488
+ [path.join(root, 'agents', aid, 'data', 'outbox.jsonl'), 'delivery', 'outbox-agent'],
489
+ [path.join(root, 'agents', aid, 'data', 'activity-outbox.jsonl'), 'activity', 'activity-outbox-agent'],
490
+ ];
491
+ for (const [source, queue, prefix] of legacySources) {
492
+ const operationId = nextId(prefix);
493
+ addMergeCopy(operations, operationId, source, outboxDestination(root, aid, queue), stageFor(operationId));
494
+ }
495
+ }
496
+ // Legacy outbox entries without a configured Agent have no safe destination.
497
+ // They are intentionally discarded during the stopped maintenance window.
498
+ try {
499
+ for (const entry of fs.readdirSync(path.join(root, 'agents'), { withFileTypes: true })) {
500
+ if (!entry.isDirectory() || aids.has(entry.name))
501
+ continue;
502
+ const agentRoot = path.join(root, 'agents', entry.name);
503
+ for (const candidate of [
504
+ path.join(agentRoot, 'data', 'outbox.jsonl'),
505
+ path.join(agentRoot, 'data', 'activity-outbox.jsonl'),
506
+ ]) {
507
+ addDiscard(operations, nextId('discard-outbox'), candidate, 'legacy outbox owner is not configured');
508
+ }
509
+ for (const candidate of [
510
+ path.join(agentRoot, 'contact-operations.jsonl'),
511
+ path.join(agentRoot, 'data', 'contact-audit.jsonl'),
512
+ path.join(agentRoot, 'sessions', 'codex-tokens'),
513
+ ]) {
514
+ if (fs.existsSync(candidate))
515
+ warnings.push(`Unresolved Agent-owned legacy source retained: ${candidate}`);
516
+ }
517
+ }
518
+ }
519
+ catch { }
520
+ // Shared outbox entries whose owner cannot be matched to a configured Agent
521
+ // are also intentionally discarded rather than retained indefinitely.
522
+ try {
523
+ const sharedOutbox = path.join(root, 'data', 'outbox');
524
+ for (const entry of fs.readdirSync(sharedOutbox, { withFileTypes: true })) {
525
+ if (entry.isDirectory()) {
526
+ let aid;
527
+ try {
528
+ aid = decodeURIComponent(entry.name);
529
+ }
530
+ catch {
531
+ aid = entry.name;
532
+ }
533
+ if (aids.has(aid))
534
+ continue;
535
+ addDiscard(operations, nextId('discard-outbox'), path.join(sharedOutbox, entry.name), 'shared outbox owner is not configured');
536
+ }
537
+ else if (entry.isFile() && /\.jsonl$/.test(entry.name)) {
538
+ const aid = entry.name.replace(/\.jsonl$/, '');
539
+ if (!aids.has(aid))
540
+ addDiscard(operations, nextId('discard-outbox'), path.join(sharedOutbox, entry.name), 'flat outbox owner is not configured');
541
+ }
542
+ }
543
+ }
544
+ catch { }
545
+ // Pre-v4 nested handoff snapshots are obsolete and intentionally discarded.
546
+ // The flat handoff files already used by the runtime remain untouched.
547
+ let agentEntries = [];
548
+ try {
549
+ agentEntries = fs.readdirSync(path.join(root, 'agents'), { withFileTypes: true });
550
+ }
551
+ catch { }
552
+ for (const entry of agentEntries.filter(item => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name))) {
553
+ const nestedRoot = path.join(root, 'agents', entry.name, 'data', 'handoff', 'handoffs');
554
+ let entries;
555
+ try {
556
+ entries = fs.readdirSync(nestedRoot, { withFileTypes: true });
557
+ }
558
+ catch {
559
+ continue;
560
+ }
561
+ for (const entry of entries) {
562
+ addDiscard(operations, nextId('discard-handoff'), path.join(nestedRoot, entry.name), 'legacy nested handoff is obsolete');
563
+ }
564
+ }
741
565
  const legacyHandoff = path.join(root, 'data', 'handoff');
742
566
  try {
743
567
  for (const entry of fs.readdirSync(legacyHandoff, { withFileTypes: true })) {
744
568
  if (!entry.isDirectory())
745
569
  continue;
746
- if (!aids.has(entry.name)) {
747
- warnings.push(`Unresolved handoff owner retained: ${entry.name}`);
748
- continue;
749
- }
750
- const operationId = nextId('handoff');
751
- addCopy(operations, operationId, path.join(legacyHandoff, entry.name), path.join(root, 'agents', entry.name, 'data', 'handoff'), stageFor(operationId));
570
+ addDiscard(operations, nextId('discard-handoff'), path.join(legacyHandoff, entry.name), aids.has(entry.name) ? 'legacy shared handoff is obsolete' : 'legacy handoff owner is not configured');
752
571
  }
753
572
  }
754
573
  catch { }
755
- // Outbox layouts are imported lazily by src/aun/outbox.ts when a queue is
756
- // first read or written. They are append-only delivery state, so including
757
- // them in the maintenance migration would unnecessarily block startup and
758
- // could race a live connector. Keep the explicit migration reserved for
759
- // data that needs operator review or an exclusive maintenance window.
760
574
  const contactAudit = path.join(root, 'data', 'contact-book-audit.jsonl');
761
575
  // The legacy audit file may be provisioned as an empty placeholder (for
762
576
  // example by a protected-path sandbox). An empty source has nothing to
@@ -880,7 +694,6 @@ export function planDataMigration(root) {
880
694
  state: 'planned',
881
695
  operations,
882
696
  warnings,
883
- ...(Object.keys(supersededSourceHashes).length ? { supersededSourceHashes } : {}),
884
697
  planSignature: migrationPlanSignature(operations),
885
698
  };
886
699
  }
@@ -911,70 +724,6 @@ function validateTree(filePath) {
911
724
  strictJsonValidation(child);
912
725
  }
913
726
  }
914
- function verifyRequiredTriggerPayload(source, destination, relative = '') {
915
- const sourceStat = fs.statSync(source);
916
- let destinationStat;
917
- try {
918
- destinationStat = fs.statSync(destination);
919
- }
920
- catch {
921
- throw new Error(`destination conflict: missing Trigger payload ${relative || '.'}`);
922
- }
923
- if (sourceStat.isFile()) {
924
- if (!destinationStat.isFile())
925
- throw new Error(`destination conflict: incompatible Trigger payload ${relative}`);
926
- if (relative === 'active.json') {
927
- strictJsonValidation(destination);
928
- return;
929
- }
930
- if (relative === 'trigger.json') {
931
- strictJsonValidation(destination);
932
- return;
933
- }
934
- if (!fs.readFileSync(source).equals(fs.readFileSync(destination))) {
935
- throw new Error(`destination conflict: changed Trigger payload ${relative}`);
936
- }
937
- return;
938
- }
939
- if (!destinationStat.isDirectory())
940
- throw new Error(`destination conflict: incompatible Trigger directory ${relative || '.'}`);
941
- for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
942
- if (!entry.isDirectory() && !entry.isFile())
943
- continue;
944
- const childRelative = relative ? path.join(relative, entry.name) : entry.name;
945
- verifyRequiredTriggerPayload(path.join(source, entry.name), path.join(destination, entry.name), childRelative);
946
- }
947
- }
948
- function verifyLiveTriggerDestination(operation) {
949
- if (operation.transform !== 'rewrite-trigger-session-paths' || !operation.destination) {
950
- throw new Error('operation is not a Trigger live-state reconciliation');
951
- }
952
- verifyRequiredTriggerPayload(operation.source, operation.destination);
953
- const sourceDefinition = readJson(path.join(operation.source, 'trigger.json'));
954
- const targetPath = path.join(operation.destination, 'trigger.json');
955
- const targetDefinition = readJson(targetPath);
956
- const aid = typeof sourceDefinition?.agentAid === 'string' ? sourceDefinition.agentAid : undefined;
957
- const id = typeof sourceDefinition?.id === 'string' ? sourceDefinition.id : undefined;
958
- if (!aid || !id || !isOwnedTriggerDefinition(targetDefinition, aid, id)) {
959
- throw new Error('destination conflict: Trigger definition owner or id changed');
960
- }
961
- const content = fs.readFileSync(targetPath, 'utf8');
962
- const transformed = transformedFileContent(targetPath, 'trigger.json', content, operation.transform, operation.sessionChannelKey);
963
- if (transformed !== content)
964
- throw new Error('destination conflict: Trigger definition still has legacy session paths');
965
- validateTree(operation.destination);
966
- }
967
- function reconcileLiveTriggerDestination(operation) {
968
- if (!operation.destination)
969
- throw new Error('Trigger live-state reconciliation has no destination');
970
- verifyRequiredTriggerPayload(operation.source, operation.destination);
971
- const targetPath = path.join(operation.destination, 'trigger.json');
972
- const content = fs.readFileSync(targetPath, 'utf8');
973
- const transformed = transformedFileContent(targetPath, 'trigger.json', content, operation.transform, operation.sessionChannelKey);
974
- if (transformed !== content)
975
- atomicWriteText(targetPath, transformed);
976
- verifyLiveTriggerDestination(operation);
977
- }
978
727
  function sessionIdentity(directory) {
979
728
  const active = readJson(path.join(directory, 'active.json'));
980
729
  if (!active || typeof active !== 'object')
@@ -1086,16 +835,6 @@ function normalizeSessionRecordChannelKeys(record, channelKey, forceCurrentRoute
1086
835
  return changed;
1087
836
  }
1088
837
  function transformedFileContent(filePath, relative, content, transform, sessionChannelKey) {
1089
- if (transform === 'rewrite-trigger-session-paths') {
1090
- if (path.basename(filePath) !== 'trigger.json')
1091
- return content;
1092
- const definition = JSON.parse(content);
1093
- const prompt = definition?.execution?.prompt;
1094
- if (typeof prompt !== 'string')
1095
- return content;
1096
- const rewritten = rewriteLegacyTriggerSessionPaths(prompt, definition.agentAid);
1097
- return rewritten === prompt ? content : JSON.stringify({ ...definition, execution: { ...definition.execution, prompt: rewritten } }, null, 2) + '\n';
1098
- }
1099
838
  if (transform !== 'normalize-session-channel-key')
1100
839
  return content;
1101
840
  const baseName = path.basename(filePath);
@@ -1199,10 +938,10 @@ function completedCopyProof(root, operation) {
1199
938
  }))
1200
939
  .sort((left, right) => right.mtime - left.mtime);
1201
940
  for (const { manifest } of manifests) {
1202
- if (manifest?.state !== 'completed')
941
+ if (manifest?.state !== 'completed' && manifest?.state !== 'completed_with_errors')
1203
942
  continue;
1204
943
  const proof = manifest.operations?.find(candidate => operationTopologyKey(candidate) === operationTopologyKey(operation)
1205
- && (candidate.status === 'committed' || candidate.status === 'skipped')
944
+ && ['committed', 'skipped', 'acknowledged'].includes(candidate.status)
1206
945
  && typeof candidate.sourceHash === 'string');
1207
946
  if (proof)
1208
947
  return proof;
@@ -1211,24 +950,41 @@ function completedCopyProof(root, operation) {
1211
950
  catch { }
1212
951
  return undefined;
1213
952
  }
1214
- function assertFinalizableOperation(root, operation) {
953
+ function assertFinalizableOperation(root, operation, archivedSources = new Set()) {
1215
954
  const checkSource = (source, expectedHash) => {
1216
955
  if (!expectedHash)
1217
956
  throw new Error(`migration source has no verified hash: ${source}`);
1218
- if (!fs.existsSync(source))
957
+ if (!fs.existsSync(source)) {
958
+ if (archivedSources.has(path.resolve(source)))
959
+ return;
1219
960
  throw new Error(`migration source is missing: ${source}`);
961
+ }
1220
962
  if (treeHash(source, operation.exclude) !== expectedHash) {
1221
963
  throw new Error(`migration source changed after verification: ${source}`);
1222
964
  }
1223
965
  };
966
+ if (operation.kind === 'discard') {
967
+ if (fs.existsSync(operation.source))
968
+ throw new Error(`discard source remains: ${operation.source}`);
969
+ return;
970
+ }
1224
971
  if (operation.status === 'acknowledged') {
1225
972
  // Acknowledged operations deliberately do not copy a live destination a
1226
973
  // second time. Resolve the earlier completed manifest that proved the
1227
974
  // identical source and topology before making that source removable.
1228
- if (operation.kind !== 'copy')
975
+ if (operation.kind !== 'copy' && operation.kind !== 'merge-copy' && operation.kind !== 'contact-audit-partition') {
1229
976
  throw new Error(`unsupported acknowledged migration operation: ${operation.id}`);
977
+ }
1230
978
  const proof = completedCopyProof(root, operation);
1231
- checkSource(operation.source, proof?.sourceHash);
979
+ if (operation.kind === 'merge-copy') {
980
+ for (const source of operationSources(operation)) {
981
+ checkSource(source, proof?.sourceHashes?.[source] ?? operation.sourceHashes?.[source]
982
+ ?? (source === operation.source ? proof?.sourceHash ?? operation.sourceHash : undefined));
983
+ }
984
+ }
985
+ else {
986
+ checkSource(operation.source, proof?.sourceHash ?? operation.sourceHash);
987
+ }
1232
988
  return;
1233
989
  }
1234
990
  if (operation.kind === 'merge-copy') {
@@ -1239,31 +995,29 @@ function assertFinalizableOperation(root, operation) {
1239
995
  }
1240
996
  checkSource(operation.source, operation.sourceHash);
1241
997
  }
1242
- function finalizationSourcePaths(root, manifest) {
1243
- if (manifest.state !== 'completed') {
998
+ function finalizationSourcePaths(root, manifest, existing) {
999
+ if (manifest.state !== 'completed' && manifest.state !== 'completed_with_errors') {
1244
1000
  throw new Error(`only a completed migration can be finalized: ${manifest.id}`);
1245
1001
  }
1246
- const allSources = new Set();
1002
+ const archivedSources = new Set((existing?.sourcePaths ?? []).map(source => path.resolve(root, source)));
1003
+ const allSources = new Set(archivedSources);
1247
1004
  for (const operation of manifest.operations) {
1248
- if (!['committed', 'skipped', 'acknowledged'].includes(operation.status)) {
1249
- throw new Error(`migration has an unfinished operation: ${operation.id}`);
1005
+ const terminal = ['committed', 'skipped', 'acknowledged'].includes(operation.status);
1006
+ if (!terminal) {
1007
+ if (manifest.state === 'completed')
1008
+ throw new Error(`migration has an unfinished operation: ${operation.id}`);
1009
+ continue;
1010
+ }
1011
+ if (operation.kind === 'discard') {
1012
+ assertFinalizableOperation(root, operation, archivedSources);
1013
+ continue;
1250
1014
  }
1251
- assertFinalizableOperation(root, operation);
1015
+ assertFinalizableOperation(root, operation, archivedSources);
1252
1016
  for (const source of operationSources(operation)) {
1253
1017
  rootRelative(root, source);
1254
1018
  allSources.add(path.resolve(source));
1255
1019
  }
1256
1020
  }
1257
- for (const [source, expectedHash] of Object.entries(manifest.supersededSourceHashes ?? {})) {
1258
- const absolute = path.resolve(source);
1259
- rootRelative(root, absolute);
1260
- if (!fs.existsSync(absolute))
1261
- throw new Error(`superseded migration source is missing: ${absolute}`);
1262
- if (treeHash(absolute) !== expectedHash) {
1263
- throw new Error(`superseded migration source changed after planning: ${absolute}`);
1264
- }
1265
- allSources.add(absolute);
1266
- }
1267
1021
  // A parent copy may intentionally omit a payload (currently Feishu dedup).
1268
1022
  // Do not let purging that parent remove an omitted file unless an independent
1269
1023
  // completed operation has verified that payload too.
@@ -1348,44 +1102,71 @@ function pruneEmptyLegacyParents(root, sourcePaths) {
1348
1102
  }
1349
1103
  }
1350
1104
  }
1351
- function pruneEmptyLegacyDirectories(root) {
1352
- // Some legacy roots contain empty per-Agent directories that never produced
1353
- // a migration operation. Remove only empty children of known legacy roots;
1354
- // unresolved directories containing data are left untouched.
1355
- const roots = [path.join(root, 'data', 'triggers')];
1356
- const pruneChildren = (directory) => {
1357
- let entries;
1358
- try {
1359
- entries = fs.readdirSync(directory, { withFileTypes: true });
1360
- }
1361
- catch {
1362
- return;
1363
- }
1364
- for (const entry of entries) {
1365
- if (!entry.isDirectory())
1366
- continue;
1367
- const child = path.join(directory, entry.name);
1368
- pruneChildren(child);
1369
- try {
1370
- if (fs.readdirSync(child).length === 0)
1371
- fs.rmdirSync(child);
1372
- }
1373
- catch { }
1374
- }
1375
- };
1376
- for (const directory of roots)
1377
- pruneChildren(directory);
1378
- }
1379
1105
  function removeMigrationStaging(manifest) {
1380
1106
  try {
1381
1107
  fs.rmSync(migrationStagingDir(manifest.root, manifest.id), { recursive: true, force: true });
1382
1108
  }
1383
1109
  catch { }
1384
1110
  }
1111
+ function createMigrationArchive(root, archivePath, sourcePaths, existingArchivePath) {
1112
+ const archiveRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'evolcore-migration-'));
1113
+ const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1114
+ const tar = systemTar();
1115
+ try {
1116
+ if (existingArchivePath) {
1117
+ execFileSync(tar, ['-xzf', existingArchivePath, '-C', archiveRoot], {
1118
+ stdio: 'pipe',
1119
+ windowsHide: process.platform === 'win32',
1120
+ });
1121
+ }
1122
+ for (const source of sourcePaths) {
1123
+ if (!fs.existsSync(source))
1124
+ continue;
1125
+ copyTree(source, path.join(archiveRoot, rootRelative(root, source)));
1126
+ }
1127
+ const relativeSources = sourcePaths.map(source => rootRelative(root, source));
1128
+ for (const relative of relativeSources) {
1129
+ if (!fs.existsSync(path.join(archiveRoot, relative))) {
1130
+ throw new Error(`migration archive source is missing: ${relative}`);
1131
+ }
1132
+ }
1133
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
1134
+ if (relativeSources.length > 0) {
1135
+ execFileSync(tar, ['-czf', temporary, ...relativeSources], {
1136
+ cwd: archiveRoot,
1137
+ stdio: 'pipe',
1138
+ windowsHide: process.platform === 'win32',
1139
+ });
1140
+ }
1141
+ else {
1142
+ execFileSync(tar, ['-czf', temporary, '--files-from', '-'], {
1143
+ cwd: archiveRoot,
1144
+ input: '',
1145
+ stdio: ['pipe', 'pipe', 'pipe'],
1146
+ windowsHide: process.platform === 'win32',
1147
+ });
1148
+ }
1149
+ verifyArchiveContains(temporary, relativeSources, tar);
1150
+ fs.renameSync(temporary, archivePath);
1151
+ }
1152
+ catch (error) {
1153
+ try {
1154
+ fs.rmSync(temporary, { force: true });
1155
+ }
1156
+ catch { }
1157
+ throw new Error(`cannot create migration archive: ${error instanceof Error ? error.message : String(error)}`);
1158
+ }
1159
+ finally {
1160
+ try {
1161
+ fs.rmSync(archiveRoot, { recursive: true, force: true });
1162
+ }
1163
+ catch { }
1164
+ }
1165
+ }
1385
1166
  /**
1386
- * Archive and remove only source paths already proven by a completed migration.
1387
- * The CLI invokes this as the final phase of --apply. An archive is
1388
- * verified before any source is removed.
1167
+ * Archive and remove source paths already proven by a migration. A manifest
1168
+ * with independent failures is finalized incrementally: successful sources
1169
+ * are archived and removed while failed sources remain for a later retry.
1389
1170
  */
1390
1171
  export function finalizeDataMigration(root, manifest) {
1391
1172
  if (path.resolve(manifest.root) !== path.resolve(root)) {
@@ -1396,43 +1177,27 @@ export function finalizeDataMigration(root, manifest) {
1396
1177
  if (!fs.existsSync(existing.archivePath) || archiveHash(existing.archivePath) !== existing.archiveSha256) {
1397
1178
  throw new Error(`existing migration archive is missing or corrupt: ${existing.archivePath}`);
1398
1179
  }
1399
- pruneEmptyLegacyDirectories(root);
1400
- removeMigrationStaging(manifest);
1401
- return existing;
1402
1180
  }
1403
- const absoluteSources = finalizationSourcePaths(root, manifest);
1181
+ const absoluteSources = finalizationSourcePaths(root, manifest, existing);
1404
1182
  const sourcePaths = absoluteSources.map(source => rootRelative(root, source));
1405
- const archivePath = path.join(root, 'backups', 'data-migration', `${manifest.id}-legacy-sources.tar.gz`);
1406
- if (fs.existsSync(archivePath)) {
1407
- throw new Error(`migration archive already exists without a finalization receipt: ${archivePath}`);
1408
- }
1409
- fs.mkdirSync(path.dirname(archivePath), { recursive: true });
1410
- const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1411
- const tar = systemTar();
1412
- try {
1413
- execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
1414
- cwd: root,
1415
- stdio: 'pipe',
1416
- windowsHide: process.platform === 'win32',
1417
- });
1418
- verifyArchiveContains(temporary, sourcePaths, tar);
1419
- fs.renameSync(temporary, archivePath);
1420
- }
1421
- catch (error) {
1422
- try {
1423
- fs.rmSync(temporary, { force: true });
1424
- }
1425
- catch { }
1426
- throw new Error(`cannot create migration archive: ${error instanceof Error ? error.message : String(error)}`);
1183
+ const archivePath = existing?.archivePath
1184
+ ?? path.join(root, 'backups', 'data-migration', `${manifest.id}-legacy-sources.tar.gz`);
1185
+ const liveSources = absoluteSources.filter(source => fs.existsSync(source));
1186
+ const archiveNeedsUpdate = !existing || liveSources.length > 0;
1187
+ if (archiveNeedsUpdate) {
1188
+ createMigrationArchive(root, archivePath, absoluteSources, existing?.archivePath);
1427
1189
  }
1428
1190
  // Recheck immediately before deletion in case another writer appeared in
1429
1191
  // the maintenance window after the archive was created.
1430
- for (const operation of manifest.operations)
1431
- assertFinalizableOperation(root, operation);
1432
- for (const source of absoluteSources)
1192
+ const archivedSources = new Set(sourcePaths.map(source => path.resolve(root, source)));
1193
+ for (const operation of manifest.operations) {
1194
+ if (['committed', 'skipped', 'acknowledged'].includes(operation.status)) {
1195
+ assertFinalizableOperation(root, operation, archivedSources);
1196
+ }
1197
+ }
1198
+ for (const source of liveSources)
1433
1199
  fs.rmSync(source, { recursive: true, force: false });
1434
- pruneEmptyLegacyParents(root, absoluteSources);
1435
- pruneEmptyLegacyDirectories(root);
1200
+ pruneEmptyLegacyParents(root, liveSources);
1436
1201
  const finalization = {
1437
1202
  schemaVersion: 1,
1438
1203
  migrationId: manifest.id,
@@ -1441,6 +1206,7 @@ export function finalizeDataMigration(root, manifest) {
1441
1206
  archivePath,
1442
1207
  archiveSha256: archiveHash(archivePath),
1443
1208
  sourcePaths,
1209
+ partial: manifest.state !== 'completed',
1444
1210
  };
1445
1211
  writeFinalization(finalization);
1446
1212
  removeMigrationStaging(manifest);
@@ -1502,7 +1268,7 @@ function executeContactAudit(manifest, operation, checkpoint) {
1502
1268
  groups.set(record.selfAid, records);
1503
1269
  }
1504
1270
  for (const [aid, lines] of groups) {
1505
- const destination = path.join(manifest.root, 'agents', aid, 'data', 'contact-audit.jsonl');
1271
+ const destination = contactAuditDestination(manifest.root, aid);
1506
1272
  const content = lines.join('\n') + '\n';
1507
1273
  const existing = fs.existsSync(destination) ? fs.readFileSync(destination, 'utf8') : undefined;
1508
1274
  if (existing !== undefined)
@@ -1571,16 +1337,6 @@ function executeCopy(operation, checkpoint) {
1571
1337
  operation.verificationError = undefined;
1572
1338
  return;
1573
1339
  }
1574
- if (operation.transform === 'rewrite-trigger-session-paths') {
1575
- reconcileLiveTriggerDestination(operation);
1576
- operation.preservedLiveTrigger = true;
1577
- operation.destinationHash = treeHash(operation.destination);
1578
- operation.status = 'committed';
1579
- operation.error = undefined;
1580
- operation.verificationError = undefined;
1581
- checkpoint();
1582
- return;
1583
- }
1584
1340
  if (operation.transform === 'normalize-session-channel-key'
1585
1341
  && (operation.allowExistingDestinationMerge || canMergeSessionDestination(operation))) {
1586
1342
  // A control-AID rotation can legitimately converge an old transcript
@@ -1627,6 +1383,18 @@ function executeCopy(operation, checkpoint) {
1627
1383
  operation.error = undefined;
1628
1384
  operation.verificationError = undefined;
1629
1385
  }
1386
+ function executeDiscard(operation, checkpoint) {
1387
+ if (fs.existsSync(operation.source)) {
1388
+ operation.sourceHash = treeHash(operation.source);
1389
+ checkpoint();
1390
+ fs.rmSync(operation.source, { recursive: true, force: false });
1391
+ }
1392
+ if (fs.existsSync(operation.source))
1393
+ throw new Error(`discard failed: ${operation.source}`);
1394
+ operation.status = 'skipped';
1395
+ operation.error = undefined;
1396
+ operation.verificationError = undefined;
1397
+ }
1630
1398
  function operationSources(operation) {
1631
1399
  return [...new Set([operation.source, ...(operation.sources ?? [])])];
1632
1400
  }
@@ -1744,7 +1512,6 @@ function executeMergeCopy(operation, checkpoint) {
1744
1512
  * later items still run and a subsequent `--resume` only retries failures.
1745
1513
  */
1746
1514
  export function applyDataMigration(manifest) {
1747
- restoreLegacyControlMergeEligibility(manifest);
1748
1515
  manifest.state = 'running';
1749
1516
  writeManifest(manifest);
1750
1517
  for (const operation of manifest.operations) {
@@ -1756,8 +1523,10 @@ export function applyDataMigration(manifest) {
1756
1523
  executeCopy(operation, checkpoint);
1757
1524
  else if (operation.kind === 'merge-copy')
1758
1525
  executeMergeCopy(operation, checkpoint);
1759
- else
1526
+ else if (operation.kind === 'contact-audit-partition')
1760
1527
  executeContactAudit(manifest, operation, checkpoint);
1528
+ else
1529
+ executeDiscard(operation, checkpoint);
1761
1530
  }
1762
1531
  catch (error) {
1763
1532
  operation.status = 'error';
@@ -1861,11 +1630,11 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1861
1630
  for (const operation of manifest.operations) {
1862
1631
  if (operation.status === 'acknowledged')
1863
1632
  continue;
1864
- if (operation.preservedLiveTrigger) {
1633
+ if (operation.kind === 'discard') {
1865
1634
  try {
1866
- verifyLiveTriggerDestination(operation);
1867
- operation.destinationHash = treeHash(operation.destination);
1868
- operation.status = 'committed';
1635
+ if (fs.existsSync(operation.source))
1636
+ throw new Error(`discard source remains: ${operation.source}`);
1637
+ operation.status = 'skipped';
1869
1638
  operation.error = undefined;
1870
1639
  operation.verificationError = undefined;
1871
1640
  }
@@ -1892,7 +1661,8 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1892
1661
  throw new Error(`destination missing: ${destination}`);
1893
1662
  strictJsonValidation(destination);
1894
1663
  const expectedHash = operation.destinationHashes?.[destination];
1895
- if (strictTargetContent && expectedHash && hashContent(fs.readFileSync(destination, 'utf8')) !== expectedHash) {
1664
+ const sourceWasArchived = !fs.existsSync(operation.source) && sourceArchived(operation.source);
1665
+ if (strictTargetContent && !sourceWasArchived && expectedHash && hashContent(fs.readFileSync(destination, 'utf8')) !== expectedHash) {
1896
1666
  throw new Error(`content hash mismatch: ${destination}`);
1897
1667
  }
1898
1668
  }
@@ -1925,8 +1695,10 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1925
1695
  operation.destinationHash = destinationHash;
1926
1696
  if (!operation.expectedHash)
1927
1697
  throw new Error('expected output hash missing');
1928
- if (strictTargetContent && operation.expectedHash !== destinationHash)
1698
+ const allSourcesArchived = liveSources.length === 0 && removedSources.every(source => sourceArchived(source));
1699
+ if (strictTargetContent && !allSourcesArchived && operation.expectedHash !== destinationHash) {
1929
1700
  throw new Error('content hash mismatch');
1701
+ }
1930
1702
  operation.status = 'committed';
1931
1703
  operation.error = undefined;
1932
1704
  operation.verificationError = undefined;
@@ -1948,6 +1720,7 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1948
1720
  validateTree(operation.destination);
1949
1721
  let sourceHash = operation.sourceHash;
1950
1722
  let expectedHash = operation.expectedHash;
1723
+ let sourceWasArchived = false;
1951
1724
  if (fs.existsSync(operation.source)) {
1952
1725
  sourceHash = treeHash(operation.source, operation.exclude);
1953
1726
  if (operation.sourceHash && operation.sourceHash !== sourceHash) {
@@ -1961,6 +1734,9 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1961
1734
  else if (!sourceArchived(operation.source)) {
1962
1735
  throw new Error(`source missing: ${operation.source}`);
1963
1736
  }
1737
+ else {
1738
+ sourceWasArchived = true;
1739
+ }
1964
1740
  if (!expectedHash)
1965
1741
  throw new Error('expected output hash missing');
1966
1742
  const destinationHash = treeHash(operation.destination);
@@ -1971,8 +1747,9 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1971
1747
  if (!strictTargetContent && operation.status === 'error' && operation.error?.startsWith('destination conflict') && expectedHash !== destinationHash) {
1972
1748
  throw new Error(operation.error ?? 'content hash mismatch');
1973
1749
  }
1974
- if (strictTargetContent && expectedHash !== destinationHash)
1750
+ if (strictTargetContent && !sourceWasArchived && expectedHash !== destinationHash) {
1975
1751
  throw new Error('content hash mismatch');
1752
+ }
1976
1753
  if (operation.status !== 'skipped')
1977
1754
  operation.status = 'committed';
1978
1755
  operation.error = undefined;