agentera 3.0.0-dev.47 → 3.0.0-dev.51

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 (35) hide show
  1. package/README.md +15 -4
  2. package/bundle/CHANGELOG.md +4 -4
  3. package/bundle/references/adapters/package-publication.json +4 -4
  4. package/bundle/references/adapters/package-registry.yaml +2 -1
  5. package/bundle/references/artifacts/state-storage-authority.yaml +51 -11
  6. package/bundle/skills/agentera/SKILL.md +11 -2
  7. package/bundle/skills/agentera/schemas/artifacts/plan.yaml +38 -4
  8. package/dist/capabilities/orchestrate/instructions.js +2 -0
  9. package/dist/capabilities/plan/instructions.js +1 -0
  10. package/dist/capabilities/status/instructions.js +5 -1
  11. package/dist/cli/commands/compact.js +125 -3
  12. package/dist/cli/commands/doctor.js +8 -1
  13. package/dist/cli/commands/prime/collectEntityOrientation.js +22 -7
  14. package/dist/cli/commands/prime/collectOrientationState.js +3 -3
  15. package/dist/cli/commands/prime/orientationOutput.js +2 -0
  16. package/dist/cli/commands/state/write.js +1 -5
  17. package/dist/cli/commands/validate.js +1 -1
  18. package/dist/cli/help.js +4 -0
  19. package/dist/cli/orientation/attention.js +4 -2
  20. package/dist/core/developmentInvocation.js +1 -1
  21. package/dist/registries/activationTuples.js +3 -2
  22. package/dist/registries/packagePublication.js +2 -2
  23. package/dist/state/entityStorage.js +9 -6
  24. package/dist/state/planEntities.js +485 -33
  25. package/dist/state/planLineageValidation.js +52 -0
  26. package/dist/state/planReplacementTransaction.js +475 -0
  27. package/dist/state/todoActivationSafety.js +58 -0
  28. package/dist/state/todoDocsEntities.js +22 -17
  29. package/dist/state/todoReconciliationActivation.js +12 -4
  30. package/dist/state/todoReconciliationInspection.js +8 -12
  31. package/dist/state/write/explain.js +32 -1
  32. package/dist/state/write/grammar.js +5 -0
  33. package/dist/state/write/operations.js +1 -0
  34. package/dist/state/write/runtimeOperations.js +6 -4
  35. package/package.json +2 -2
@@ -0,0 +1,52 @@
1
+ function mapping(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ function planStatus(plan) {
5
+ const header = plan.record?.header;
6
+ return mapping(header) ? header.status : undefined;
7
+ }
8
+ export function planLineageIssues(plans, recovery) {
9
+ const plansById = new Map(plans.map((plan) => [plan.id, plan]));
10
+ const successorsByPredecessor = new Map();
11
+ const issues = [];
12
+ for (const successor of plans) {
13
+ const predecessorId = successor.record?.previous_plan_archived;
14
+ if (typeof predecessorId !== "string")
15
+ continue;
16
+ const predecessor = plansById.get(predecessorId);
17
+ if (predecessorId === successor.id || (predecessor && planStatus(predecessor) !== "archived")) {
18
+ issues.push({
19
+ code: "unresolved_relation",
20
+ path: successor.relativePath,
21
+ id: successor.id,
22
+ artifact: successor.artifact ?? undefined,
23
+ boundary: successor.boundary ?? undefined,
24
+ relation: "previous_plan_archived",
25
+ targetId: predecessorId,
26
+ message: predecessorId === successor.id
27
+ ? `plan '${successor.id}' cannot name itself as its archived predecessor`
28
+ : `plan '${successor.id}' predecessor '${predecessorId}' must be archived`,
29
+ recovery: recovery(`set record.previous_plan_archived in '${successor.relativePath}' to one distinct archived plan ID, or remove the writer-owned field from invalid state`),
30
+ });
31
+ }
32
+ successorsByPredecessor.set(predecessorId, [...(successorsByPredecessor.get(predecessorId) ?? []), successor]);
33
+ }
34
+ for (const [predecessorId, successors] of successorsByPredecessor) {
35
+ if (successors.length < 2)
36
+ continue;
37
+ for (const successor of successors) {
38
+ issues.push({
39
+ code: "conflicting_ownership",
40
+ path: successor.relativePath,
41
+ id: successor.id,
42
+ artifact: successor.artifact ?? undefined,
43
+ boundary: successor.boundary ?? undefined,
44
+ relation: "previous_plan_archived",
45
+ targetId: predecessorId,
46
+ message: `archived predecessor '${predecessorId}' has multiple successor plan records: ${successors.map((candidate) => candidate.id).sort().join(", ")}`,
47
+ recovery: recovery(`retain record.previous_plan_archived on only one canonical successor of archived plan '${predecessorId}'`),
48
+ });
49
+ }
50
+ }
51
+ return issues;
52
+ }
@@ -0,0 +1,475 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { loadYamlMapping } from "../core/yaml.js";
5
+ import { canonicalRecordJson } from "./archiveDiscovery.js";
6
+ import { entityExactGetMaxBytes } from "./entityStorage.js";
7
+ import { FILE_REPLACEMENT_METADATA_NAME, FILE_REPLACEMENT_RECOVERY_VERSION, validateEntityRecoveryDirectory, } from "./entityPublicationContext.js";
8
+ import { reject } from "./write/errors.js";
9
+ const VERSION = "agentera.planReplacementTransaction.v1";
10
+ const DIRECTORY = ".agentera/.entity-recovery/plan-replacement";
11
+ const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
12
+ const MAX_TARGET_BYTES = 1024 * 1024;
13
+ const MAX_TARGETS = 102;
14
+ const ID = /^[a-z]{10}$/;
15
+ const TARGET = /^\.agentera\/entities\/plan\/(?:plan|plan_task)\/[a-z]{10}\.yaml$/;
16
+ function mapping(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ function sha256(value) {
20
+ return createHash("sha256").update(value).digest("hex");
21
+ }
22
+ function encode(value) {
23
+ return Buffer.from(value).toString("base64");
24
+ }
25
+ function decode(value) {
26
+ return Buffer.from(value, "base64");
27
+ }
28
+ function exactBase64(value) {
29
+ if (typeof value !== "string")
30
+ return false;
31
+ try {
32
+ return encode(decode(value)) === value;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function same(left, right) {
39
+ return left === null ? right === null : right !== null && left.equals(right);
40
+ }
41
+ function journalPath(id) {
42
+ return `${DIRECTORY}/${id}.json`;
43
+ }
44
+ function invalid(message) {
45
+ throw new Error(`plan replacement journal ${message}`);
46
+ }
47
+ function conflict(message) {
48
+ reject({
49
+ class: "conflict",
50
+ message,
51
+ recovery: "Preserve the pending plan replacement journal, canonical target bytes, and retained recovery roles. Retry only the exact original state plan replace request after reconciling any reported concurrent bytes; no competing bytes were overwritten.",
52
+ });
53
+ }
54
+ function targetPath(boundary, id) {
55
+ return `.agentera/entities/plan/${boundary}/${id}.yaml`;
56
+ }
57
+ function parseEnvelope(bytes, label) {
58
+ try {
59
+ const value = loadYamlMapping(bytes.toString("utf8"));
60
+ if (!mapping(value))
61
+ throw new Error("not a mapping");
62
+ return value;
63
+ }
64
+ catch {
65
+ invalid(`${label} is not a valid canonical entity envelope`);
66
+ }
67
+ }
68
+ function parseJournal(bytes, fileName) {
69
+ if (bytes.length > MAX_JOURNAL_BYTES)
70
+ invalid("exceeds its byte bound");
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
74
+ }
75
+ catch {
76
+ invalid("is not valid bounded UTF-8 JSON");
77
+ }
78
+ if (!mapping(parsed))
79
+ invalid("is not a mapping");
80
+ const value = parsed;
81
+ if (Object.keys(value).sort().join(",") !== "id,operation,schema_version,targets"
82
+ || value.schema_version !== VERSION
83
+ || typeof value.id !== "string"
84
+ || !/^[a-f0-9]{24}$/.test(value.id)
85
+ || !mapping(value.operation)
86
+ || !Array.isArray(value.targets)
87
+ || value.targets.length < 2
88
+ || value.targets.length > MAX_TARGETS)
89
+ invalid("is malformed");
90
+ const operation = value.operation;
91
+ if (Object.keys(operation).sort().join(",") !== "input_sha256,kind,predecessor,successor"
92
+ || !["existing", "create"].includes(String(operation.kind))
93
+ || typeof operation.predecessor !== "string"
94
+ || typeof operation.successor !== "string"
95
+ || !ID.test(operation.predecessor)
96
+ || !ID.test(operation.successor)
97
+ || operation.predecessor === operation.successor
98
+ || typeof operation.input_sha256 !== "string"
99
+ || !/^[a-f0-9]{64}$/.test(operation.input_sha256))
100
+ invalid("has an invalid operation identity");
101
+ const paths = new Set();
102
+ for (const target of value.targets) {
103
+ if (!mapping(target)
104
+ || Object.keys(target).sort().join(",") !== "after,before,path"
105
+ || typeof target.path !== "string"
106
+ || !TARGET.test(target.path)
107
+ || paths.has(target.path)
108
+ || (target.before !== null && !exactBase64(target.before))
109
+ || !exactBase64(target.after)
110
+ || decode(target.after).length > MAX_TARGET_BYTES
111
+ || (target.before !== null && decode(target.before).length > MAX_TARGET_BYTES))
112
+ invalid("has an invalid target");
113
+ paths.add(target.path);
114
+ }
115
+ const body = value.targets;
116
+ const identity = { operation, targets: body };
117
+ const expectedId = sha256(canonicalRecordJson(identity)).slice(0, 24);
118
+ if (value.id !== expectedId || (fileName !== undefined && fileName !== `${value.id}.json`)) {
119
+ invalid("identity does not match its operation and complete target set");
120
+ }
121
+ const predecessor = body.find((target) => target.path === targetPath("plan", operation.predecessor));
122
+ const successor = body.find((target) => target.path === targetPath("plan", operation.successor));
123
+ if (!predecessor || predecessor.before === null || !successor)
124
+ invalid("does not include both canonical plan targets");
125
+ if (operation.kind === "create" && successor.before !== null)
126
+ invalid("create target has a predecessor byte baseline");
127
+ if (operation.kind === "existing" && successor.before === null)
128
+ invalid("existing successor target lacks a byte baseline");
129
+ const predecessorEnvelope = parseEnvelope(decode(predecessor.after), "predecessor after target");
130
+ const successorEnvelope = parseEnvelope(decode(successor.after), "successor after target");
131
+ const predecessorHeader = mapping(predecessorEnvelope.record?.header)
132
+ ? predecessorEnvelope.record.header
133
+ : {};
134
+ const successorRecord = mapping(successorEnvelope.record) ? successorEnvelope.record : {};
135
+ if (predecessorEnvelope.id !== operation.predecessor
136
+ || predecessorEnvelope.artifact !== "plan"
137
+ || predecessorHeader.status !== "archived"
138
+ || successorEnvelope.id !== operation.successor
139
+ || successorEnvelope.artifact !== "plan"
140
+ || successorRecord.previous_plan_archived !== operation.predecessor
141
+ || successorRecord.replacement_input_sha256 !== operation.input_sha256)
142
+ invalid("does not encode the declared predecessor archive and immutable successor identity");
143
+ const taskTargets = body.filter((target) => target.path.includes("/plan_task/"));
144
+ if (operation.kind === "existing" && taskTargets.length)
145
+ invalid("existing-successor operation includes unexpected task targets");
146
+ if (operation.kind === "create") {
147
+ for (const target of taskTargets) {
148
+ if (target.before !== null)
149
+ invalid("create task target has a predecessor byte baseline");
150
+ const envelope = parseEnvelope(decode(target.after), "created task after target");
151
+ if (!mapping(envelope.record) || envelope.artifact !== "plan" || envelope.record.plan !== operation.successor) {
152
+ invalid("created task target does not belong to the declared successor");
153
+ }
154
+ }
155
+ }
156
+ return { schema_version: VERSION, id: value.id, operation: operation, targets: body };
157
+ }
158
+ function readJournal(root, name) {
159
+ const file = path.join(root, DIRECTORY, name);
160
+ const stat = fs.lstatSync(file);
161
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_JOURNAL_BYTES)
162
+ invalid("file is not a safe bounded regular file");
163
+ return parseJournal(fs.readFileSync(file), name);
164
+ }
165
+ function pendingJournalNames(root) {
166
+ const directory = path.join(root, DIRECTORY);
167
+ if (!fs.existsSync(directory))
168
+ return [];
169
+ const stat = fs.lstatSync(directory);
170
+ if (!stat.isDirectory() || stat.isSymbolicLink())
171
+ invalid("directory is not a safe real directory");
172
+ const names = fs.readdirSync(directory, { withFileTypes: true })
173
+ .filter((entry) => entry.name.endsWith(".json"))
174
+ .map((entry) => {
175
+ if (!entry.isFile() || entry.isSymbolicLink())
176
+ invalid("directory contains an unsafe journal entry");
177
+ return entry.name;
178
+ })
179
+ .sort();
180
+ if (names.length > 1)
181
+ invalid("has multiple pending operations");
182
+ return names;
183
+ }
184
+ export function inspectPendingPlanReplacement(root) {
185
+ const name = pendingJournalNames(root)[0];
186
+ if (!name)
187
+ return null;
188
+ const journal = readJournal(root, name);
189
+ return {
190
+ predecessor: journal.operation.predecessor,
191
+ successor: journal.operation.successor,
192
+ kind: journal.operation.kind,
193
+ inputSha256: journal.operation.input_sha256,
194
+ targetCount: journal.targets.length,
195
+ };
196
+ }
197
+ function assertExactRetry(journal, retry) {
198
+ const operation = journal.operation;
199
+ const matches = operation.predecessor === retry.predecessor
200
+ && (operation.kind === "existing"
201
+ ? retry.successor === operation.successor && retry.inputSha256 === undefined
202
+ : retry.successor === undefined && retry.inputSha256 === operation.input_sha256);
203
+ if (!matches) {
204
+ conflict(operation.kind === "existing"
205
+ ? `pending plan replacement for predecessor '${operation.predecessor}' requires successor '${operation.successor}', not this request`
206
+ : `pending plan replacement for predecessor '${operation.predecessor}' requires the exact original successor input`);
207
+ }
208
+ }
209
+ function bytesAt(root, relative) {
210
+ const file = path.join(root, relative);
211
+ try {
212
+ const stat = fs.lstatSync(file);
213
+ if (!stat.isFile() || stat.isSymbolicLink())
214
+ throw new Error("is not a safe regular file");
215
+ return fs.readFileSync(file);
216
+ }
217
+ catch (error) {
218
+ if (error.code === "ENOENT")
219
+ return null;
220
+ throw new Error(`plan replacement target '${relative}' ${error.message}`);
221
+ }
222
+ }
223
+ function fsyncDirectory(directory) {
224
+ if (process.platform === "win32")
225
+ return;
226
+ const descriptor = fs.openSync(directory, "r");
227
+ try {
228
+ fs.fsyncSync(descriptor);
229
+ }
230
+ finally {
231
+ fs.closeSync(descriptor);
232
+ }
233
+ }
234
+ function parseFileRecoveryMetadata(file) {
235
+ let parsed;
236
+ try {
237
+ const stat = fs.lstatSync(file);
238
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024)
239
+ throw new Error("is unsafe or over bound");
240
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(file)));
241
+ }
242
+ catch {
243
+ conflict(`plan replacement retained recovery metadata '${file}' is invalid`);
244
+ }
245
+ if (!mapping(parsed))
246
+ conflict(`plan replacement retained recovery metadata '${file}' is not a mapping`);
247
+ const value = parsed;
248
+ if (Object.keys(value).sort().join(",") !== "after_sha256,before_sha256,schema_version,target_path"
249
+ || value.schema_version !== FILE_REPLACEMENT_RECOVERY_VERSION
250
+ || typeof value.target_path !== "string"
251
+ || typeof value.before_sha256 !== "string"
252
+ || typeof value.after_sha256 !== "string"
253
+ || !/^[a-f0-9]{64}$/.test(value.before_sha256)
254
+ || !/^[a-f0-9]{64}$/.test(value.after_sha256))
255
+ conflict(`plan replacement retained recovery metadata '${file}' has an invalid canonical record`);
256
+ return value;
257
+ }
258
+ function roleBytes(file, label) {
259
+ const stat = fs.lstatSync(file);
260
+ if (!stat.isFile() || stat.isSymbolicLink())
261
+ conflict(`plan replacement ${label} '${file}' is unsafe`);
262
+ return fs.readFileSync(file);
263
+ }
264
+ function removeExactRole(file, expected) {
265
+ if (!roleBytes(file, "recovery role").equals(expected))
266
+ conflict(`plan replacement recovery role '${file}' changed`);
267
+ fs.unlinkSync(file);
268
+ }
269
+ function recoverImmutableStage(root, target) {
270
+ if (target.before !== null)
271
+ return;
272
+ const absolute = path.join(root, target.path);
273
+ const directory = path.dirname(absolute);
274
+ if (!fs.existsSync(directory))
275
+ return;
276
+ const name = path.basename(absolute);
277
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
278
+ const pattern = new RegExp(`^\\.${escaped}\\.\\d+\\.[a-f0-9-]+\\.tmp$`);
279
+ const stages = fs.readdirSync(directory).filter((candidate) => pattern.test(candidate));
280
+ if (stages.length > 1)
281
+ conflict(`plan replacement target '${target.path}' has multiple retained immutable publication stages`);
282
+ if (!stages.length)
283
+ return;
284
+ const stage = path.join(directory, stages[0]);
285
+ const after = decode(target.after);
286
+ if (!roleBytes(stage, "immutable publication stage").equals(after)) {
287
+ conflict(`plan replacement target '${target.path}' retained immutable publication stage changed`);
288
+ }
289
+ const current = bytesAt(root, target.path);
290
+ if (current !== null && !current.equals(after)) {
291
+ conflict(`plan replacement target '${target.path}' has concurrent canonical bytes beside its immutable publication stage`);
292
+ }
293
+ removeExactRole(stage, after);
294
+ fsyncDirectory(directory);
295
+ }
296
+ function recoverReplacementRoles(context, target) {
297
+ if (target.before === null)
298
+ return;
299
+ const root = context.pinnedPath();
300
+ const recoveryRoot = path.join(root, ".agentera/.entity-recovery");
301
+ if (!fs.existsSync(recoveryRoot))
302
+ return;
303
+ const targetDirectory = path.dirname(context.pinnedPath(target.path));
304
+ const recoveryRootIdentity = validateEntityRecoveryDirectory(context.validatedRoot, recoveryRoot, targetDirectory, "private entity recovery root '.agentera/.entity-recovery'");
305
+ const entries = fs.readdirSync(recoveryRoot, { withFileTypes: true });
306
+ if (entries.length > 128)
307
+ conflict("private entity recovery root exceeds its bounded entry count");
308
+ const before = decode(target.before);
309
+ const after = decode(target.after);
310
+ const matches = [];
311
+ for (const entry of entries) {
312
+ if (entry.name === ".gitignore" || entry.name === "plan-replacement")
313
+ continue;
314
+ const directory = path.join(recoveryRoot, entry.name);
315
+ const identity = validateEntityRecoveryDirectory(context.validatedRoot, directory, targetDirectory, `private entity recovery attempt '${path.relative(root, directory).split(path.sep).join("/")}'`);
316
+ const metadata = path.join(directory, FILE_REPLACEMENT_METADATA_NAME);
317
+ if (!fs.existsSync(metadata))
318
+ continue;
319
+ const value = parseFileRecoveryMetadata(metadata);
320
+ if (value.target_path !== target.path)
321
+ continue;
322
+ if (value.before_sha256 !== sha256(before) || value.after_sha256 !== sha256(after)) {
323
+ conflict(`plan replacement target '${target.path}' retained recovery role digests do not match its pending journal`);
324
+ }
325
+ const names = fs.readdirSync(directory).sort();
326
+ if (names.some((name) => !["original.previous", "replacement.tmp", FILE_REPLACEMENT_METADATA_NAME].includes(name))) {
327
+ conflict(`plan replacement target '${target.path}' retained recovery attempt contains an unknown role`);
328
+ }
329
+ const previous = path.join(directory, "original.previous");
330
+ const stage = path.join(directory, "replacement.tmp");
331
+ if (fs.existsSync(previous) && !roleBytes(previous, "prior-byte role").equals(before)) {
332
+ conflict(`plan replacement target '${target.path}' retained prior-byte role changed`);
333
+ }
334
+ if (fs.existsSync(stage) && !roleBytes(stage, "replacement stage").equals(after)) {
335
+ conflict(`plan replacement target '${target.path}' retained replacement stage changed`);
336
+ }
337
+ matches.push({ directory, identity, metadata, ...(fs.existsSync(previous) ? { previous } : {}), ...(fs.existsSync(stage) ? { stage } : {}) });
338
+ }
339
+ if (matches.length > 1)
340
+ conflict(`plan replacement target '${target.path}' has multiple retained recovery attempts`);
341
+ const match = matches[0];
342
+ if (!match)
343
+ return;
344
+ const current = bytesAt(root, target.path);
345
+ if (!same(current, before) && !same(current, after)) {
346
+ conflict(`plan replacement target '${target.path}' has concurrent canonical bytes beside retained recovery roles`);
347
+ }
348
+ validateEntityRecoveryDirectory(context.validatedRoot, recoveryRoot, targetDirectory, "private entity recovery root '.agentera/.entity-recovery'", recoveryRootIdentity);
349
+ validateEntityRecoveryDirectory(context.validatedRoot, match.directory, targetDirectory, `private entity recovery attempt '${path.relative(root, match.directory).split(path.sep).join("/")}'`, match.identity);
350
+ if (match.stage)
351
+ removeExactRole(match.stage, after);
352
+ if (match.previous)
353
+ removeExactRole(match.previous, before);
354
+ removeExactRole(match.metadata, roleBytes(match.metadata, "replacement metadata"));
355
+ fs.rmdirSync(match.directory);
356
+ fsyncDirectory(recoveryRoot);
357
+ }
358
+ function applyTarget(context, sourceRoot, target) {
359
+ const root = context.pinnedPath();
360
+ recoverImmutableStage(root, target);
361
+ recoverReplacementRoles(context, target);
362
+ const before = target.before === null ? null : decode(target.before);
363
+ const after = decode(target.after);
364
+ const current = bytesAt(root, target.path);
365
+ if (same(current, after))
366
+ return null;
367
+ if (!same(current, before)) {
368
+ conflict(`plan replacement target '${target.path}' changed after journal preparation`);
369
+ }
370
+ if (before === null) {
371
+ const identity = context.publishImmutable(target.path, after.toString("utf8"));
372
+ if (!identity)
373
+ conflict(`plan replacement target '${target.path}' appeared during publication`);
374
+ return identity;
375
+ }
376
+ return context.replaceExisting(target.path, before, after.toString("utf8"), entityExactGetMaxBytes(sourceRoot)).publishedIdentity;
377
+ }
378
+ function rollback(context, sourceRoot, applied) {
379
+ const failures = [];
380
+ for (const { target, identity } of [...applied].reverse()) {
381
+ try {
382
+ if (target.before === null) {
383
+ const result = context.removeExact(target.path, identity, false);
384
+ if (result !== "removed")
385
+ throw new Error(`exact removal returned ${result}`);
386
+ }
387
+ else {
388
+ context.restoreExact(target.path, identity, decode(target.before).toString("utf8"), entityExactGetMaxBytes(sourceRoot));
389
+ }
390
+ }
391
+ catch (error) {
392
+ failures.push(`${target.path}: ${error.message}`);
393
+ }
394
+ }
395
+ return failures;
396
+ }
397
+ function finishJournal(context, relative, bytes, knownIdentity) {
398
+ const identity = knownIdentity ?? context.replaceExisting(relative, bytes, bytes.toString("utf8"), MAX_JOURNAL_BYTES).publishedIdentity;
399
+ if (context.removeExact(relative, identity) !== "removed") {
400
+ throw new Error(`plan replacement journal '${relative}' changed before cleanup`);
401
+ }
402
+ }
403
+ function completeJournal(context, sourceRoot, journal, bytes, relative, options, knownIdentity) {
404
+ const applied = [];
405
+ try {
406
+ for (const target of journal.targets) {
407
+ const identity = applyTarget(context, sourceRoot, target);
408
+ if (identity)
409
+ applied.push({ target, identity });
410
+ }
411
+ options.validate();
412
+ context.assertValid();
413
+ finishJournal(context, relative, bytes, knownIdentity);
414
+ }
415
+ catch (error) {
416
+ const failures = rollback(context, sourceRoot, applied);
417
+ if (failures.length) {
418
+ conflict(`plan replacement could not restore every target after failure: ${failures.join("; ")}`);
419
+ }
420
+ throw error;
421
+ }
422
+ }
423
+ export function recoverPendingPlanReplacement(context, sourceRoot, retry, options) {
424
+ const root = context.pinnedPath();
425
+ let pending;
426
+ try {
427
+ pending = inspectPendingPlanReplacement(root);
428
+ }
429
+ catch (error) {
430
+ reject({
431
+ class: "conflict",
432
+ message: `pending plan replacement journal is invalid: ${error.message}`,
433
+ recovery: "Preserve '.agentera/.entity-recovery/plan-replacement', restore its last valid journal bytes, and retry the exact non-dry-run plan replacement; no journal target bytes were changed.",
434
+ });
435
+ }
436
+ if (!pending)
437
+ return null;
438
+ const name = pendingJournalNames(root)[0];
439
+ const file = path.join(root, DIRECTORY, name);
440
+ const bytes = fs.readFileSync(file);
441
+ const journal = parseJournal(bytes, name);
442
+ assertExactRetry(journal, retry);
443
+ completeJournal(context, sourceRoot, journal, bytes, journalPath(journal.id), options);
444
+ return pending;
445
+ }
446
+ export function publishPlanReplacement(context, sourceRoot, operation, targets, options) {
447
+ if (!ID.test(operation.predecessor)
448
+ || !ID.test(operation.successor)
449
+ || operation.predecessor === operation.successor
450
+ || !/^[a-f0-9]{64}$/.test(operation.inputSha256)
451
+ || targets.length < 2
452
+ || targets.length > MAX_TARGETS
453
+ || new Set(targets.map((target) => target.path)).size !== targets.length
454
+ || targets.some((target) => !TARGET.test(target.path) || Buffer.byteLength(target.after) > MAX_TARGET_BYTES || (target.before !== null && target.before.length > MAX_TARGET_BYTES))) {
455
+ reject({ class: "schema_violation", message: "plan replacement transaction has invalid bounded targets", recovery: "Recompute the complete plan replacement target set from the locked canonical snapshot and retry; no state was changed." });
456
+ }
457
+ const body = targets.map((target) => ({ path: target.path, before: target.before === null ? null : encode(target.before), after: encode(target.after) }));
458
+ const journalOperation = {
459
+ kind: operation.kind,
460
+ predecessor: operation.predecessor,
461
+ successor: operation.successor,
462
+ input_sha256: operation.inputSha256,
463
+ };
464
+ const id = sha256(canonicalRecordJson({ operation: journalOperation, targets: body })).slice(0, 24);
465
+ const journal = { schema_version: VERSION, id, operation: journalOperation, targets: body };
466
+ const bytes = Buffer.from(`${JSON.stringify(journal)}\n`);
467
+ parseJournal(bytes, `${id}.json`);
468
+ const relative = journalPath(id);
469
+ recoverImmutableStage(context.pinnedPath(), { path: relative, before: null, after: bytes.toString("base64") });
470
+ const identity = context.publishImmutable(relative, bytes.toString("utf8"));
471
+ if (!identity) {
472
+ conflict(`plan replacement journal '${id}' already exists; retry the exact operation once so it can be recovered before another target set is prepared`);
473
+ }
474
+ completeJournal(context, sourceRoot, journal, bytes, relative, options, identity);
475
+ }
@@ -0,0 +1,58 @@
1
+ import { renderTodoPublicRecord } from "../cli/todoMarkdown.js";
2
+ import { todoActivationRisks, TODO_UNSAFE_INACTIVE_RECOVERY } from "./todoReconciliationActivation.js";
3
+ import { reject } from "./write/errors.js";
4
+ function publicSnapshot(record) {
5
+ return { present: true, description: renderTodoPublicRecord(record), severity: String(record.severity), status: String(record.status) };
6
+ }
7
+ function rowSnapshot(row, record) {
8
+ return { ...row.snapshot, severity: row.section === "resolved" ? String(record.severity) : row.snapshot.severity };
9
+ }
10
+ function samePublic(left, right) {
11
+ return ["present", "description", "severity", "status"].every((field) => left[field] === right[field]);
12
+ }
13
+ export function unsafeInactiveDuplicateDiagnosis(conflicting = 0) {
14
+ return {
15
+ counts: { matched: 0, converted: 0, retained: 0, duplicate: 1, stale: 0, conflicting },
16
+ risks: todoActivationRisks([]),
17
+ };
18
+ }
19
+ /** The one read-only decision that gates both activation preview and apply. */
20
+ export function inactiveTodoActivationSafety(scan, entities) {
21
+ const todoEntities = entities.filter(({ boundary, id, record }) => boundary === "todo_item" && id && record);
22
+ const entityIds = new Set(todoEntities.map(({ id }) => id));
23
+ const unmatched = todoEntities.filter(({ id }) => !scan.rows.has(id));
24
+ const stale = todoEntities.filter(({ id, record }) => {
25
+ const row = scan.rows.get(id);
26
+ return row !== undefined && !samePublic(publicSnapshot(record), rowSnapshot(row, record));
27
+ });
28
+ const orphaned = [...scan.rows.keys()].filter((id) => !entityIds.has(id));
29
+ const resurrectedIds = todoEntities
30
+ .filter(({ id, record }) => {
31
+ const row = scan.rows.get(id);
32
+ return row === undefined ? record.status === "open" : record.status === "resolved" && !row.item.id;
33
+ })
34
+ .map(({ id }) => id)
35
+ .sort();
36
+ const counts = {
37
+ matched: scan.matchedRows,
38
+ converted: scan.convertedRows,
39
+ retained: scan.retainedLegacyRows.length,
40
+ duplicate: 0,
41
+ stale: stale.length,
42
+ conflicting: unmatched.length + orphaned.length,
43
+ };
44
+ return {
45
+ safe: unmatched.length === 0 && stale.length === 0 && orphaned.length === 0 && resurrectedIds.length === 0,
46
+ counts,
47
+ risks: todoActivationRisks(resurrectedIds),
48
+ resurrectedIds,
49
+ };
50
+ }
51
+ export function rejectUnsafeInactiveTodoActivation(safety) {
52
+ reject({
53
+ class: "conflict",
54
+ message: "TODO activation requires complete one-to-one inactive public projections",
55
+ diagnosis: { counts: safety.counts, risks: safety.risks },
56
+ recovery: TODO_UNSAFE_INACTIVE_RECOVERY,
57
+ });
58
+ }